standard.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. # Copyright (C) 2025 AIDC-AI
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. # http://www.apache.org/licenses/LICENSE-2.0
  7. # Unless required by applicable law or agreed to in writing, software
  8. # distributed under the License is distributed on an "AS IS" BASIS,
  9. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. # See the License for the specific language governing permissions and
  11. # limitations under the License.
  12. """
  13. Standard Video Generation Pipeline
  14. Standard workflow for generating short videos from topic or fixed script.
  15. This is the default pipeline for general-purpose video generation.
  16. Refactored to use LinearVideoPipeline (Template Method Pattern).
  17. """
  18. from datetime import datetime
  19. from pathlib import Path
  20. from typing import Optional, Callable, Literal, List
  21. import asyncio
  22. import shutil
  23. from loguru import logger
  24. from pixelle_video.pipelines.linear import LinearVideoPipeline, PipelineContext
  25. from pixelle_video.models.progress import ProgressEvent
  26. from pixelle_video.models.storyboard import (
  27. Storyboard,
  28. StoryboardFrame,
  29. StoryboardConfig,
  30. ContentMetadata,
  31. VideoGenerationResult
  32. )
  33. from pixelle_video.utils.content_generators import (
  34. generate_title,
  35. generate_narrations_from_topic,
  36. split_narration_script,
  37. generate_image_prompts,
  38. )
  39. from pixelle_video.utils.os_util import (
  40. create_task_output_dir,
  41. get_task_final_video_path
  42. )
  43. from pixelle_video.utils.template_util import get_template_type
  44. from pixelle_video.utils.prompt_helper import build_image_prompt
  45. from pixelle_video.services.video import VideoService
  46. class StandardPipeline(LinearVideoPipeline):
  47. """
  48. Standard video generation pipeline
  49. Workflow:
  50. 1. Generate/determine title
  51. 2. Generate narrations (from topic or split fixed script)
  52. 3. Generate image prompts for each narration
  53. 4. For each frame:
  54. - Generate audio (TTS)
  55. - Generate image
  56. - Compose frame with template
  57. - Create video segment
  58. 5. Concatenate all segments
  59. 6. Add BGM (optional)
  60. Supports two modes:
  61. - "generate": LLM generates narrations from topic
  62. - "fixed": Use provided script as-is (each line = one narration)
  63. """
  64. # ==================== Lifecycle Methods ====================
  65. async def setup_environment(self, ctx: PipelineContext):
  66. """Step 1: Setup task directory and environment."""
  67. text = ctx.input_text
  68. mode = ctx.params.get("mode", "generate")
  69. logger.info(f"🚀 Starting StandardPipeline in '{mode}' mode")
  70. logger.info(f" Text length: {len(text)} chars")
  71. # Create isolated task directory
  72. task_dir, task_id = create_task_output_dir()
  73. ctx.task_id = task_id
  74. ctx.task_dir = task_dir
  75. logger.info(f"📁 Task directory created: {task_dir}")
  76. logger.info(f" Task ID: {task_id}")
  77. # Determine final video path
  78. output_path = ctx.params.get("output_path")
  79. if output_path is None:
  80. ctx.final_video_path = get_task_final_video_path(task_id)
  81. else:
  82. # We will copy to this path in finalize/post_production
  83. # For internal processing, we still use the task dir path?
  84. # Actually StandardPipeline logic used get_task_final_video_path as the target for concat
  85. # and then copied. Let's stick to that.
  86. ctx.final_video_path = get_task_final_video_path(task_id)
  87. logger.info(f" Will copy final video to: {output_path}")
  88. async def generate_content(self, ctx: PipelineContext):
  89. """Step 2: Generate or process script/narrations."""
  90. mode = ctx.params.get("mode", "generate")
  91. text = ctx.input_text
  92. n_scenes = ctx.params.get("n_scenes", 5)
  93. min_words = ctx.params.get("min_narration_words", 5)
  94. max_words = ctx.params.get("max_narration_words", 20)
  95. if mode == "generate":
  96. self._report_progress(ctx.progress_callback, "generating_narrations", 0.05)
  97. ctx.narrations = await generate_narrations_from_topic(
  98. self.llm,
  99. topic=text,
  100. n_scenes=n_scenes,
  101. min_words=min_words,
  102. max_words=max_words
  103. )
  104. logger.info(f"✅ Generated {len(ctx.narrations)} narrations")
  105. else: # fixed
  106. self._report_progress(ctx.progress_callback, "splitting_script", 0.05)
  107. split_mode = ctx.params.get("split_mode", "paragraph")
  108. ctx.narrations = await split_narration_script(text, split_mode=split_mode)
  109. logger.info(f"✅ Split script into {len(ctx.narrations)} segments (mode={split_mode})")
  110. logger.info(f" Note: n_scenes={n_scenes} is ignored in fixed mode")
  111. async def determine_title(self, ctx: PipelineContext):
  112. """Step 3: Determine or generate video title."""
  113. # Note: Swapped order with generate_content in base class call,
  114. # but in StandardPipeline original code, title was determined BEFORE narrations.
  115. # However, LinearVideoPipeline defines generate_content BEFORE determine_title.
  116. # This is fine as they are independent in StandardPipeline logic.
  117. title = ctx.params.get("title")
  118. mode = ctx.params.get("mode", "generate")
  119. text = ctx.input_text
  120. if title:
  121. ctx.title = title
  122. logger.info(f" Title: '{title}' (user-specified)")
  123. else:
  124. self._report_progress(ctx.progress_callback, "generating_title", 0.01)
  125. if mode == "generate":
  126. ctx.title = await generate_title(self.llm, text, strategy="auto")
  127. logger.info(f" Title: '{ctx.title}' (auto-generated)")
  128. else: # fixed
  129. ctx.title = await generate_title(self.llm, text, strategy="llm")
  130. logger.info(f" Title: '{ctx.title}' (LLM-generated)")
  131. async def plan_visuals(self, ctx: PipelineContext):
  132. """Step 4: Generate image prompts or visual descriptions."""
  133. # Detect template type to determine if media generation is needed
  134. frame_template = ctx.params.get("frame_template") or "1080x1920/default.html"
  135. template_name = Path(frame_template).name
  136. template_type = get_template_type(template_name)
  137. template_requires_media = (template_type in ["image", "video"])
  138. if template_type == "image":
  139. logger.info(f"📸 Template requires image generation")
  140. elif template_type == "video":
  141. logger.info(f"🎬 Template requires video generation")
  142. else: # static
  143. logger.info(f"⚡ Static template - skipping media generation pipeline")
  144. logger.info(f" 💡 Benefits: Faster generation + Lower cost + No ComfyUI dependency")
  145. # Only generate image prompts if template requires media
  146. if template_requires_media:
  147. self._report_progress(ctx.progress_callback, "generating_image_prompts", 0.15)
  148. prompt_prefix = ctx.params.get("prompt_prefix")
  149. min_words = ctx.params.get("min_image_prompt_words", 30)
  150. max_words = ctx.params.get("max_image_prompt_words", 60)
  151. # Override prompt_prefix if provided
  152. original_prefix = None
  153. if prompt_prefix is not None:
  154. image_config = self.core.config.get("comfyui", {}).get("image", {})
  155. original_prefix = image_config.get("prompt_prefix")
  156. image_config["prompt_prefix"] = prompt_prefix
  157. logger.info(f"Using custom prompt_prefix: '{prompt_prefix}'")
  158. try:
  159. # Create progress callback wrapper for image prompt generation
  160. def image_prompt_progress(completed: int, total: int, message: str):
  161. batch_progress = completed / total if total > 0 else 0
  162. overall_progress = 0.15 + (batch_progress * 0.15)
  163. self._report_progress(
  164. ctx.progress_callback,
  165. "generating_image_prompts",
  166. overall_progress,
  167. extra_info=message
  168. )
  169. # Generate base image prompts
  170. base_image_prompts = await generate_image_prompts(
  171. self.llm,
  172. narrations=ctx.narrations,
  173. min_words=min_words,
  174. max_words=max_words,
  175. progress_callback=image_prompt_progress
  176. )
  177. # Apply prompt prefix
  178. image_config = self.core.config.get("comfyui", {}).get("image", {})
  179. prompt_prefix_to_use = prompt_prefix if prompt_prefix is not None else image_config.get("prompt_prefix", "")
  180. ctx.image_prompts = []
  181. for base_prompt in base_image_prompts:
  182. final_prompt = build_image_prompt(base_prompt, prompt_prefix_to_use)
  183. ctx.image_prompts.append(final_prompt)
  184. finally:
  185. # Restore original prompt_prefix
  186. if original_prefix is not None:
  187. image_config["prompt_prefix"] = original_prefix
  188. logger.info(f"✅ Generated {len(ctx.image_prompts)} image prompts")
  189. else:
  190. # Static template - skip image prompt generation entirely
  191. ctx.image_prompts = [None] * len(ctx.narrations)
  192. logger.info(f"⚡ Skipped image prompt generation (static template)")
  193. logger.info(f" 💡 Savings: {len(ctx.narrations)} LLM calls + {len(ctx.narrations)} media generations")
  194. async def initialize_storyboard(self, ctx: PipelineContext):
  195. """Step 5: Create Storyboard object and frames."""
  196. # === Handle TTS parameter compatibility ===
  197. tts_inference_mode = ctx.params.get("tts_inference_mode")
  198. tts_voice = ctx.params.get("tts_voice")
  199. voice_id = ctx.params.get("voice_id")
  200. tts_workflow = ctx.params.get("tts_workflow")
  201. final_voice_id = None
  202. final_tts_workflow = tts_workflow
  203. if tts_inference_mode:
  204. # New API from web UI
  205. if tts_inference_mode == "local":
  206. final_voice_id = tts_voice or "zh-CN-YunjianNeural"
  207. final_tts_workflow = None
  208. logger.debug(f"TTS Mode: local (voice={final_voice_id})")
  209. elif tts_inference_mode == "comfyui":
  210. final_voice_id = None
  211. logger.debug(f"TTS Mode: comfyui (workflow={final_tts_workflow})")
  212. else:
  213. # Old API
  214. final_voice_id = voice_id or tts_voice or "zh-CN-YunjianNeural"
  215. logger.debug(f"TTS Mode: legacy (voice_id={final_voice_id}, workflow={final_tts_workflow})")
  216. # Create config
  217. ctx.config = StoryboardConfig(
  218. task_id=ctx.task_id,
  219. n_storyboard=len(ctx.narrations), # Use actual length
  220. min_narration_words=ctx.params.get("min_narration_words", 5),
  221. max_narration_words=ctx.params.get("max_narration_words", 20),
  222. min_image_prompt_words=ctx.params.get("min_image_prompt_words", 30),
  223. max_image_prompt_words=ctx.params.get("max_image_prompt_words", 60),
  224. video_fps=ctx.params.get("video_fps", 30),
  225. tts_inference_mode=tts_inference_mode or "local",
  226. voice_id=final_voice_id,
  227. tts_workflow=final_tts_workflow,
  228. tts_speed=ctx.params.get("tts_speed", 1.2),
  229. ref_audio=ctx.params.get("ref_audio"),
  230. media_width=ctx.params.get("media_width"),
  231. media_height=ctx.params.get("media_height"),
  232. media_workflow=ctx.params.get("media_workflow"),
  233. frame_template=ctx.params.get("frame_template") or "1080x1920/default.html",
  234. template_params=ctx.params.get("template_params")
  235. )
  236. # Create storyboard
  237. ctx.storyboard = Storyboard(
  238. title=ctx.title,
  239. config=ctx.config,
  240. content_metadata=ctx.params.get("content_metadata"),
  241. created_at=datetime.now()
  242. )
  243. # Create frames
  244. for i, (narration, image_prompt) in enumerate(zip(ctx.narrations, ctx.image_prompts)):
  245. frame = StoryboardFrame(
  246. index=i,
  247. narration=narration,
  248. image_prompt=image_prompt,
  249. created_at=datetime.now()
  250. )
  251. ctx.storyboard.frames.append(frame)
  252. async def produce_assets(self, ctx: PipelineContext):
  253. """Step 6: Generate audio, images, and render frames (Core processing)."""
  254. storyboard = ctx.storyboard
  255. config = ctx.config
  256. # Check if using RunningHub workflows for parallel processing
  257. is_runninghub = (
  258. (config.tts_workflow and config.tts_workflow.startswith("runninghub/")) or
  259. (config.media_workflow and config.media_workflow.startswith("runninghub/"))
  260. )
  261. # Get concurrent limit from config_manager (supports hot reload without restart)
  262. from pixelle_video.config import config_manager
  263. runninghub_concurrent_limit = config_manager.config.comfyui.runninghub_concurrent_limit or 1
  264. if is_runninghub and runninghub_concurrent_limit > 1:
  265. logger.info(f"🚀 Using parallel processing for RunningHub workflows (max {runninghub_concurrent_limit} concurrent)")
  266. semaphore = asyncio.Semaphore(runninghub_concurrent_limit)
  267. completed_count = 0
  268. async def process_frame_with_semaphore(i: int, frame: StoryboardFrame):
  269. nonlocal completed_count
  270. async with semaphore:
  271. base_progress = 0.2
  272. frame_range = 0.6
  273. per_frame_progress = frame_range / len(storyboard.frames)
  274. # Create frame-specific progress callback
  275. def frame_progress_callback(event: ProgressEvent):
  276. overall_progress = base_progress + (per_frame_progress * completed_count) + (per_frame_progress * event.progress)
  277. if ctx.progress_callback:
  278. adjusted_event = ProgressEvent(
  279. event_type=event.event_type,
  280. progress=overall_progress,
  281. frame_current=i+1,
  282. frame_total=len(storyboard.frames),
  283. step=event.step,
  284. action=event.action
  285. )
  286. ctx.progress_callback(adjusted_event)
  287. # Report frame start
  288. self._report_progress(
  289. ctx.progress_callback,
  290. "processing_frame",
  291. base_progress + (per_frame_progress * completed_count),
  292. frame_current=i+1,
  293. frame_total=len(storyboard.frames)
  294. )
  295. processed_frame = await self.core.frame_processor(
  296. frame=frame,
  297. storyboard=storyboard,
  298. config=config,
  299. total_frames=len(storyboard.frames),
  300. progress_callback=frame_progress_callback
  301. )
  302. completed_count += 1
  303. logger.info(f"✅ Frame {i+1} completed ({processed_frame.duration:.2f}s) [{completed_count}/{len(storyboard.frames)}]")
  304. return i, processed_frame
  305. # Create all tasks and execute in parallel
  306. tasks = [process_frame_with_semaphore(i, frame) for i, frame in enumerate(storyboard.frames)]
  307. results = await asyncio.gather(*tasks)
  308. # Update frames in order and calculate total duration
  309. for idx, processed_frame in sorted(results, key=lambda x: x[0]):
  310. storyboard.frames[idx] = processed_frame
  311. storyboard.total_duration += processed_frame.duration
  312. logger.info(f"✅ All frames processed in parallel (total duration: {storyboard.total_duration:.2f}s)")
  313. else:
  314. # Serial processing for non-RunningHub workflows
  315. logger.info("⚙️ Using serial processing (non-RunningHub workflow)")
  316. for i, frame in enumerate(storyboard.frames):
  317. base_progress = 0.2
  318. frame_range = 0.6
  319. per_frame_progress = frame_range / len(storyboard.frames)
  320. # Create frame-specific progress callback
  321. def frame_progress_callback(event: ProgressEvent):
  322. overall_progress = base_progress + (per_frame_progress * i) + (per_frame_progress * event.progress)
  323. if ctx.progress_callback:
  324. adjusted_event = ProgressEvent(
  325. event_type=event.event_type,
  326. progress=overall_progress,
  327. frame_current=event.frame_current,
  328. frame_total=event.frame_total,
  329. step=event.step,
  330. action=event.action
  331. )
  332. ctx.progress_callback(adjusted_event)
  333. # Report frame start
  334. self._report_progress(
  335. ctx.progress_callback,
  336. "processing_frame",
  337. base_progress + (per_frame_progress * i),
  338. frame_current=i+1,
  339. frame_total=len(storyboard.frames)
  340. )
  341. processed_frame = await self.core.frame_processor(
  342. frame=frame,
  343. storyboard=storyboard,
  344. config=config,
  345. total_frames=len(storyboard.frames),
  346. progress_callback=frame_progress_callback
  347. )
  348. storyboard.total_duration += processed_frame.duration
  349. logger.info(f"✅ Frame {i+1} completed ({processed_frame.duration:.2f}s)")
  350. async def post_production(self, ctx: PipelineContext):
  351. """Step 7: Concatenate videos and add BGM."""
  352. self._report_progress(ctx.progress_callback, "concatenating", 0.85)
  353. storyboard = ctx.storyboard
  354. segment_paths = [frame.video_segment_path for frame in storyboard.frames]
  355. video_service = VideoService()
  356. final_video_path = video_service.concat_videos(
  357. videos=segment_paths,
  358. output=ctx.final_video_path,
  359. bgm_path=ctx.params.get("bgm_path"),
  360. bgm_volume=ctx.params.get("bgm_volume", 0.2),
  361. bgm_mode=ctx.params.get("bgm_mode", "loop")
  362. )
  363. storyboard.final_video_path = final_video_path
  364. storyboard.completed_at = datetime.now()
  365. # Copy to user-specified path if provided
  366. user_specified_output = ctx.params.get("output_path")
  367. if user_specified_output:
  368. Path(user_specified_output).parent.mkdir(parents=True, exist_ok=True)
  369. shutil.copy2(final_video_path, user_specified_output)
  370. logger.info(f"📹 Final video copied to: {user_specified_output}")
  371. ctx.final_video_path = user_specified_output
  372. storyboard.final_video_path = user_specified_output
  373. logger.success(f"🎬 Video generation completed: {ctx.final_video_path}")
  374. async def finalize(self, ctx: PipelineContext) -> VideoGenerationResult:
  375. """Step 8: Create result object and persist metadata."""
  376. self._report_progress(ctx.progress_callback, "completed", 1.0)
  377. video_path_obj = Path(ctx.final_video_path)
  378. file_size = video_path_obj.stat().st_size
  379. result = VideoGenerationResult(
  380. video_path=ctx.final_video_path,
  381. storyboard=ctx.storyboard,
  382. duration=ctx.storyboard.total_duration,
  383. file_size=file_size
  384. )
  385. ctx.result = result
  386. logger.info(f"✅ Generated video: {ctx.final_video_path}")
  387. logger.info(f" Duration: {ctx.storyboard.total_duration:.2f}s")
  388. logger.info(f" Size: {file_size / (1024*1024):.2f} MB")
  389. logger.info(f" Frames: {len(ctx.storyboard.frames)}")
  390. # Persist metadata
  391. await self._persist_task_data(ctx)
  392. return result
  393. async def _persist_task_data(self, ctx: PipelineContext):
  394. """
  395. Persist task metadata and storyboard to filesystem
  396. """
  397. try:
  398. storyboard = ctx.storyboard
  399. result = ctx.result
  400. task_id = storyboard.config.task_id
  401. if not task_id:
  402. logger.warning("No task_id in storyboard, skipping persistence")
  403. return
  404. # Build metadata
  405. input_with_title = ctx.params.copy()
  406. input_with_title["text"] = ctx.input_text # Ensure text is included
  407. if not input_with_title.get("title"):
  408. input_with_title["title"] = storyboard.title
  409. metadata = {
  410. "task_id": task_id,
  411. "created_at": storyboard.created_at.isoformat() if storyboard.created_at else None,
  412. "completed_at": storyboard.completed_at.isoformat() if storyboard.completed_at else None,
  413. "status": "completed",
  414. "input": input_with_title,
  415. "result": {
  416. "video_path": result.video_path,
  417. "duration": result.duration,
  418. "file_size": result.file_size,
  419. "n_frames": len(storyboard.frames)
  420. },
  421. "config": {
  422. "llm_model": self.core.config.get("llm", {}).get("model", "unknown"),
  423. "llm_base_url": self.core.config.get("llm", {}).get("base_url", "unknown"),
  424. "comfyui_url": self.core.config.get("comfyui", {}).get("comfyui_url", "unknown"),
  425. "runninghub_enabled": bool(self.core.config.get("comfyui", {}).get("runninghub_api_key")),
  426. }
  427. }
  428. # Save metadata
  429. await self.core.persistence.save_task_metadata(task_id, metadata)
  430. logger.info(f"💾 Saved task metadata: {task_id}")
  431. # Save storyboard
  432. await self.core.persistence.save_storyboard(task_id, storyboard)
  433. logger.info(f"💾 Saved storyboard: {task_id}")
  434. except Exception as e:
  435. logger.error(f"Failed to persist task data: {e}")
  436. # Don't raise - persistence failure shouldn't break video generation