asset_based.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866
  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. Asset-Based Video Pipeline
  14. Generates marketing videos from user-provided assets (images/videos) rather than
  15. AI-generated media. Ideal for small businesses with existing media libraries.
  16. Workflow:
  17. 1. Analyze uploaded assets (images/videos)
  18. 2. Generate script based on user intent and available assets
  19. 3. Match assets to script scenes
  20. 4. Compose final video with narrations
  21. Example:
  22. pipeline = AssetBasedPipeline(pixelle_video)
  23. result = await pipeline(
  24. assets=["/path/img1.jpg", "/path/img2.jpg"],
  25. video_title="Pet Store Year-End Sale",
  26. intent="Promote our pet store's year-end sale with a warm and friendly tone",
  27. duration=30
  28. )
  29. """
  30. from typing import List, Dict, Any, Optional, Callable
  31. from pathlib import Path
  32. from loguru import logger
  33. from pydantic import BaseModel, Field
  34. from pixelle_video.pipelines.linear import LinearVideoPipeline, PipelineContext
  35. from pixelle_video.models.progress import ProgressEvent
  36. from pixelle_video.utils.os_util import (
  37. create_task_output_dir,
  38. get_task_final_video_path
  39. )
  40. # Type alias for progress callback
  41. ProgressCallback = Optional[Callable[[ProgressEvent], None]]
  42. # ==================== Structured Output Models ====================
  43. class SceneScript(BaseModel):
  44. """Single scene in the video script"""
  45. scene_number: int = Field(description="Scene number starting from 1")
  46. asset_path: str = Field(description="Path to the asset file for this scene")
  47. narrations: List[str] = Field(description="List of narration sentences for this scene (1-5 sentences)")
  48. duration: int = Field(description="Estimated duration in seconds for this scene")
  49. class VideoScript(BaseModel):
  50. """Complete video script with scenes"""
  51. scenes: List[SceneScript] = Field(description="List of scenes in the video")
  52. class AssetBasedPipeline(LinearVideoPipeline):
  53. """
  54. Asset-Based Video Pipeline
  55. Generates videos from user-provided assets instead of AI-generated media.
  56. """
  57. def __init__(self, core):
  58. """
  59. Initialize pipeline
  60. Args:
  61. core: PixelleVideoCore instance
  62. """
  63. super().__init__(core)
  64. self.asset_index: Dict[str, Any] = {} # In-memory asset metadata
  65. async def __call__(
  66. self,
  67. assets: List[str],
  68. video_title: str = "",
  69. intent: Optional[str] = None,
  70. duration: int = 30,
  71. source: str = "runninghub",
  72. bgm_path: Optional[str] = None,
  73. bgm_volume: float = 0.2,
  74. bgm_mode: str = "loop",
  75. progress_callback: ProgressCallback = None,
  76. **kwargs
  77. ) -> PipelineContext:
  78. """
  79. Execute pipeline with user-provided assets
  80. Args:
  81. assets: List of asset file paths
  82. video_title: Video title
  83. intent: Video intent/purpose (defaults to video_title)
  84. duration: Target duration in seconds
  85. source: Workflow source ("runninghub" or "selfhost")
  86. bgm_path: Path to background music file (optional)
  87. bgm_volume: BGM volume (0.0-1.0, default 0.2)
  88. bgm_mode: BGM mode ("loop" or "once", default "loop")
  89. progress_callback: Optional callback for progress updates
  90. **kwargs: Additional parameters
  91. Returns:
  92. Pipeline context with generated video
  93. """
  94. from pixelle_video.pipelines.linear import PipelineContext
  95. # Store progress callback
  96. self._progress_callback = progress_callback
  97. # Create custom context with asset-specific parameters
  98. ctx = PipelineContext(
  99. input_text=intent or video_title, # Use intent or title as input_text
  100. params={
  101. "assets": assets,
  102. "video_title": video_title,
  103. "intent": intent or video_title,
  104. "duration": duration,
  105. "source": source,
  106. "bgm_path": bgm_path,
  107. "bgm_volume": bgm_volume,
  108. "bgm_mode": bgm_mode,
  109. **kwargs
  110. }
  111. )
  112. # Store request parameters in context for easy access
  113. ctx.request = ctx.params
  114. try:
  115. # Execute pipeline lifecycle
  116. await self.setup_environment(ctx)
  117. await self.determine_title(ctx)
  118. await self.generate_content(ctx)
  119. await self.plan_visuals(ctx)
  120. await self.initialize_storyboard(ctx)
  121. await self.produce_assets(ctx)
  122. await self.post_production(ctx)
  123. await self.finalize(ctx)
  124. return ctx
  125. except Exception as e:
  126. await self.handle_exception(ctx, e)
  127. raise
  128. def _emit_progress(self, event: ProgressEvent):
  129. """Emit progress event to callback if available"""
  130. if self._progress_callback:
  131. self._progress_callback(event)
  132. async def setup_environment(self, context: PipelineContext) -> PipelineContext:
  133. """
  134. Analyze uploaded assets and build asset index
  135. Args:
  136. context: Pipeline context with assets list
  137. Returns:
  138. Updated context with asset_index
  139. """
  140. # Create isolated task directory
  141. task_dir, task_id = create_task_output_dir()
  142. context.task_id = task_id
  143. context.task_dir = Path(task_dir) # Convert to Path for easier usage
  144. # Determine final video path
  145. context.final_video_path = get_task_final_video_path(task_id)
  146. logger.info(f"📁 Task directory created: {task_dir}")
  147. logger.info("🔍 Analyzing uploaded assets...")
  148. assets: List[str] = context.request.get("assets", [])
  149. if not assets:
  150. raise ValueError("No assets provided. Please upload at least one image or video.")
  151. total_assets = len(assets)
  152. logger.info(f"Found {total_assets} assets to analyze")
  153. # Emit initial progress (0-15% for asset analysis)
  154. self._emit_progress(ProgressEvent(
  155. event_type="analyzing_assets",
  156. progress=0.01,
  157. frame_current=0,
  158. frame_total=total_assets,
  159. extra_info="start"
  160. ))
  161. self.asset_index = {}
  162. for i, asset_path in enumerate(assets, 1):
  163. asset_path_obj = Path(asset_path)
  164. if not asset_path_obj.exists():
  165. logger.warning(f"Asset not found: {asset_path}")
  166. continue
  167. logger.info(f"Analyzing asset {i}/{total_assets}: {asset_path_obj.name}")
  168. # Emit progress for this asset
  169. progress = 0.01 + (i - 1) / total_assets * 0.14 # 1% - 15%
  170. self._emit_progress(ProgressEvent(
  171. event_type="analyzing_asset",
  172. progress=progress,
  173. frame_current=i,
  174. frame_total=total_assets,
  175. extra_info=asset_path_obj.name
  176. ))
  177. # Determine asset type
  178. asset_type = self._get_asset_type(asset_path_obj)
  179. if asset_type == "image":
  180. # Analyze image using ImageAnalysisService
  181. analysis_source = context.request.get("source", "runninghub")
  182. description = await self.core.image_analysis(asset_path, source=analysis_source)
  183. self.asset_index[asset_path] = {
  184. "path": asset_path,
  185. "type": "image",
  186. "name": asset_path_obj.name,
  187. "description": description
  188. }
  189. logger.info(f"✅ Image analyzed: {description[:50]}...")
  190. elif asset_type == "video":
  191. # Analyze video using VideoAnalysisService
  192. analysis_source = context.request.get("source", "runninghub")
  193. try:
  194. description = await self.core.video_analysis(asset_path, source=analysis_source)
  195. self.asset_index[asset_path] = {
  196. "path": asset_path,
  197. "type": "video",
  198. "name": asset_path_obj.name,
  199. "description": description
  200. }
  201. logger.info(f"✅ Video analyzed: {description[:50]}...")
  202. except Exception as e:
  203. logger.warning(f"Video analysis failed for {asset_path_obj.name}: {e}, using fallback")
  204. self.asset_index[asset_path] = {
  205. "path": asset_path,
  206. "type": "video",
  207. "name": asset_path_obj.name,
  208. "description": "Video asset (analysis failed)"
  209. }
  210. else:
  211. logger.warning(f"Unknown asset type: {asset_path}")
  212. logger.success(f"✅ Asset analysis complete: {len(self.asset_index)} assets indexed")
  213. # Store asset index in context
  214. context.asset_index = self.asset_index
  215. # Emit completion of asset analysis
  216. self._emit_progress(ProgressEvent(
  217. event_type="analyzing_assets",
  218. progress=0.15,
  219. frame_current=total_assets,
  220. frame_total=total_assets,
  221. extra_info="complete"
  222. ))
  223. return context
  224. async def determine_title(self, context: PipelineContext) -> PipelineContext:
  225. """
  226. Use user-provided title if available, otherwise leave empty
  227. Args:
  228. context: Pipeline context
  229. Returns:
  230. Updated context with title (may be empty)
  231. """
  232. title = context.request.get("video_title")
  233. if title:
  234. context.title = title
  235. logger.info(f"📝 Video title: {title} (user-specified)")
  236. else:
  237. context.title = ""
  238. logger.info(f"📝 No video title specified (will be hidden in template)")
  239. return context
  240. async def generate_content(self, context: PipelineContext) -> PipelineContext:
  241. """
  242. Generate video script using LLM with structured output
  243. LLM directly assigns assets to scenes - no complex matching logic needed.
  244. Args:
  245. context: Pipeline context
  246. Returns:
  247. Updated context with generated script (scenes already have asset_path assigned)
  248. """
  249. from pixelle_video.prompts.asset_script_generation import build_asset_script_prompt
  250. logger.info("🤖 Generating video script with LLM...")
  251. # Emit progress for script generation (15% - 25%)
  252. self._emit_progress(ProgressEvent(
  253. event_type="generating_script",
  254. progress=0.16
  255. ))
  256. # Build prompt for LLM
  257. intent = context.request.get("intent", context.input_text)
  258. duration = context.request.get("duration", 30)
  259. title = context.title # May be empty if user didn't provide one
  260. # Prepare asset descriptions with full paths for LLM to reference
  261. asset_info = []
  262. for asset_path, metadata in self.asset_index.items():
  263. asset_info.append(f"- Path: {asset_path}\n Description: {metadata['description']}")
  264. assets_text = "\n".join(asset_info)
  265. # Build prompt using the centralized prompt function
  266. prompt = build_asset_script_prompt(
  267. intent=intent,
  268. duration=duration,
  269. assets_text=assets_text,
  270. title=title
  271. )
  272. # Call LLM with structured output
  273. script: VideoScript = await self.core.llm(
  274. prompt=prompt,
  275. response_type=VideoScript,
  276. temperature=0.8,
  277. max_tokens=4000
  278. )
  279. # Convert to dict format for compatibility with downstream code
  280. context.script = [scene.model_dump() for scene in script.scenes]
  281. # Validate asset paths exist
  282. for scene in context.script:
  283. asset_path = scene.get("asset_path")
  284. if asset_path not in self.asset_index:
  285. # Find closest match (in case LLM slightly modified the path)
  286. matched = False
  287. for known_path in self.asset_index.keys():
  288. if Path(known_path).name == Path(asset_path).name:
  289. scene["asset_path"] = known_path
  290. matched = True
  291. logger.warning(f"Corrected asset path: {asset_path} -> {known_path}")
  292. break
  293. if not matched:
  294. # Fallback to first available asset
  295. fallback_path = list(self.asset_index.keys())[0]
  296. logger.warning(f"Unknown asset path '{asset_path}', using fallback: {fallback_path}")
  297. scene["asset_path"] = fallback_path
  298. logger.success(f"✅ Generated script with {len(context.script)} scenes")
  299. # Emit progress after script generation
  300. self._emit_progress(ProgressEvent(
  301. event_type="generating_script",
  302. progress=0.25,
  303. extra_info="complete"
  304. ))
  305. # Log script preview
  306. for scene in context.script:
  307. narrations = scene.get("narrations", [])
  308. if isinstance(narrations, str):
  309. narrations = [narrations]
  310. narration_preview = " | ".join([n[:30] + "..." if len(n) > 30 else n for n in narrations[:2]])
  311. asset_name = Path(scene.get("asset_path", "unknown")).name
  312. logger.info(f"Scene {scene['scene_number']} [{asset_name}]: {narration_preview}")
  313. return context
  314. async def plan_visuals(self, context: PipelineContext) -> PipelineContext:
  315. """
  316. Prepare matched scenes from LLM-generated script
  317. Since LLM already assigned asset_path in generate_content, this method
  318. simply converts the script format to matched_scenes format.
  319. Args:
  320. context: Pipeline context
  321. Returns:
  322. Updated context with matched_scenes
  323. """
  324. logger.info("🎯 Preparing scene-asset mapping...")
  325. # LLM already assigned asset_path to each scene in generate_content
  326. # Just convert to matched_scenes format for downstream compatibility
  327. context.matched_scenes = [
  328. {
  329. **scene,
  330. "matched_asset": scene["asset_path"] # Alias for compatibility
  331. }
  332. for scene in context.script
  333. ]
  334. # Log asset usage summary
  335. asset_usage = {}
  336. for scene in context.matched_scenes:
  337. asset = scene["matched_asset"]
  338. asset_usage[asset] = asset_usage.get(asset, 0) + 1
  339. logger.info(f"📊 Asset usage summary:")
  340. for asset_path, count in asset_usage.items():
  341. logger.info(f" {Path(asset_path).name}: {count} scene(s)")
  342. return context
  343. async def initialize_storyboard(self, context: PipelineContext) -> PipelineContext:
  344. """
  345. Initialize storyboard from matched scenes
  346. Args:
  347. context: Pipeline context
  348. Returns:
  349. Updated context with storyboard
  350. """
  351. from pixelle_video.models.storyboard import (
  352. Storyboard,
  353. StoryboardFrame,
  354. StoryboardConfig
  355. )
  356. from datetime import datetime
  357. # Extract all narrations in order for compatibility
  358. all_narrations = []
  359. for scene in context.matched_scenes:
  360. narrations = scene.get("narrations", [scene.get("narration", "")])
  361. if isinstance(narrations, str):
  362. narrations = [narrations]
  363. all_narrations.extend(narrations)
  364. context.narrations = all_narrations
  365. # Get template dimensions
  366. # Use asset_default.html template which supports both image and video assets
  367. # (conditionally shows background image or provides transparent overlay)
  368. template_name = "1080x1920/asset_default.html"
  369. # Extract dimensions from template name (e.g., "1080x1920")
  370. try:
  371. dims = template_name.split("/")[0].split("x")
  372. media_width = int(dims[0])
  373. media_height = int(dims[1])
  374. except:
  375. # Default to 1080x1920
  376. media_width = 1080
  377. media_height = 1920
  378. # Create StoryboardConfig
  379. context.config = StoryboardConfig(
  380. task_id=context.task_id,
  381. n_storyboard=len(context.matched_scenes), # Number of scenes
  382. min_narration_words=5,
  383. max_narration_words=50,
  384. video_fps=30,
  385. tts_inference_mode="local",
  386. voice_id=context.params.get("voice_id", "zh-CN-YunjianNeural"),
  387. tts_speed=context.params.get("tts_speed", 1.2),
  388. media_width=media_width,
  389. media_height=media_height,
  390. frame_template=template_name,
  391. template_params=context.params.get("template_params")
  392. )
  393. # Create Storyboard
  394. context.storyboard = Storyboard(
  395. title=context.title,
  396. config=context.config,
  397. created_at=datetime.now()
  398. )
  399. # Create StoryboardFrames - one per scene
  400. for i, scene in enumerate(context.matched_scenes):
  401. # Get first narration for the frame (we'll combine audios later)
  402. narrations = scene.get("narrations", [scene.get("narration", "")])
  403. if isinstance(narrations, str):
  404. narrations = [narrations]
  405. # Use first narration as the main text (for subtitle)
  406. # We'll combine all narrations in the audio
  407. main_narration = " ".join(narrations) # Combine for subtitle display
  408. frame = StoryboardFrame(
  409. index=i,
  410. narration=main_narration,
  411. image_prompt=None, # We're using user assets, not generating images
  412. created_at=datetime.now()
  413. )
  414. # Get asset path and determine actual media type from asset_index
  415. asset_path = scene["matched_asset"]
  416. asset_metadata = self.asset_index.get(asset_path, {})
  417. asset_type = asset_metadata.get("type", "image") # Default to image if not found
  418. # Set media type and path based on actual asset type
  419. if asset_type == "video":
  420. frame.media_type = "video"
  421. frame.video_path = asset_path
  422. logger.debug(f"Scene {i}: Using video asset: {Path(asset_path).name}")
  423. else:
  424. frame.media_type = "image"
  425. frame.image_path = asset_path
  426. logger.debug(f"Scene {i}: Using image asset: {Path(asset_path).name}")
  427. # Store scene info for later audio generation
  428. frame._scene_data = scene # Temporary storage for multi-narration
  429. context.storyboard.frames.append(frame)
  430. logger.info(f"✅ Created storyboard with {len(context.storyboard.frames)} scenes")
  431. return context
  432. async def produce_assets(self, context: PipelineContext) -> PipelineContext:
  433. """
  434. Generate scene videos using FrameProcessor (asset + multiple narrations + template)
  435. Args:
  436. context: Pipeline context
  437. Returns:
  438. Updated context with processed frames
  439. """
  440. logger.info("🎬 Producing scene videos...")
  441. storyboard = context.storyboard
  442. config = context.config
  443. total_frames = len(storyboard.frames)
  444. # Progress range: 30% - 85% for frame production
  445. base_progress = 0.30
  446. progress_range = 0.55 # 85% - 30%
  447. for i, frame in enumerate(storyboard.frames, 1):
  448. logger.info(f"Producing scene {i}/{total_frames}...")
  449. # Emit progress for this frame (each frame has 4 steps: audio, combine, duration, compose)
  450. frame_progress = base_progress + (i - 1) / total_frames * progress_range
  451. self._emit_progress(ProgressEvent(
  452. event_type="frame_step",
  453. progress=frame_progress,
  454. frame_current=i,
  455. frame_total=total_frames,
  456. step=1,
  457. action="audio"
  458. ))
  459. # Get scene data with narrations
  460. scene = frame._scene_data
  461. narrations = scene.get("narrations", [scene.get("narration", "")])
  462. if isinstance(narrations, str):
  463. narrations = [narrations]
  464. logger.info(f"Scene {i} has {len(narrations)} narration(s)")
  465. # Step 1: Generate audio for each narration and combine
  466. narration_audios = []
  467. for j, narration_text in enumerate(narrations, 1):
  468. audio_path = Path(context.task_dir) / "frames" / f"{i:02d}_narration_{j}.mp3"
  469. audio_path.parent.mkdir(parents=True, exist_ok=True)
  470. await self.core.tts(
  471. text=narration_text,
  472. output_path=str(audio_path),
  473. voice=config.voice_id,
  474. speed=config.tts_speed
  475. )
  476. narration_audios.append(str(audio_path))
  477. logger.debug(f" Narration {j}/{len(narrations)}: {narration_text[:30]}...")
  478. # Concatenate all narration audios for this scene
  479. if len(narration_audios) > 1:
  480. from pixelle_video.utils.os_util import get_task_frame_path
  481. # Emit progress for combining audio
  482. frame_progress = base_progress + ((i - 1) + 0.25) / total_frames * progress_range
  483. self._emit_progress(ProgressEvent(
  484. event_type="frame_step",
  485. progress=frame_progress,
  486. frame_current=i,
  487. frame_total=total_frames,
  488. step=2,
  489. action="audio"
  490. ))
  491. combined_audio_path = Path(context.task_dir) / "frames" / f"{i:02d}_audio.mp3"
  492. # Use FFmpeg to concatenate audio files
  493. import subprocess
  494. # Create a file list for FFmpeg concat
  495. filelist_path = Path(context.task_dir) / "frames" / f"{i:02d}_audiolist.txt"
  496. with open(filelist_path, 'w') as f:
  497. for audio_file in narration_audios:
  498. escaped_path = str(Path(audio_file).absolute()).replace("'", "'\\''")
  499. f.write(f"file '{escaped_path}'\n")
  500. # Concatenate audio files
  501. concat_cmd = [
  502. 'ffmpeg',
  503. '-f', 'concat',
  504. '-safe', '0',
  505. '-i', str(filelist_path),
  506. '-c', 'copy',
  507. '-y',
  508. str(combined_audio_path)
  509. ]
  510. subprocess.run(concat_cmd, check=True, capture_output=True)
  511. frame.audio_path = str(combined_audio_path)
  512. logger.info(f"✅ Combined {len(narration_audios)} narrations into one audio")
  513. else:
  514. frame.audio_path = narration_audios[0]
  515. # Step 2: Use FrameProcessor to generate composed frame and video
  516. # FrameProcessor will handle:
  517. # - Template rendering (with proper dimensions)
  518. # - Subtitle composition
  519. # - Video segment creation
  520. # - Proper file naming in frames/
  521. # Since we already have the audio and image, we bypass some steps
  522. # by manually calling the composition steps
  523. # Emit progress for duration calculation
  524. frame_progress = base_progress + ((i - 1) + 0.5) / total_frames * progress_range
  525. self._emit_progress(ProgressEvent(
  526. event_type="frame_step",
  527. progress=frame_progress,
  528. frame_current=i,
  529. frame_total=total_frames,
  530. step=3,
  531. action="compose"
  532. ))
  533. # Get audio duration for frame duration
  534. import subprocess
  535. duration_cmd = [
  536. 'ffprobe',
  537. '-v', 'error',
  538. '-show_entries', 'format=duration',
  539. '-of', 'default=noprint_wrappers=1:nokey=1',
  540. frame.audio_path
  541. ]
  542. duration_result = subprocess.run(duration_cmd, capture_output=True, text=True, check=True)
  543. frame.duration = float(duration_result.stdout.strip())
  544. # Emit progress for video composition
  545. frame_progress = base_progress + ((i - 1) + 0.75) / total_frames * progress_range
  546. self._emit_progress(ProgressEvent(
  547. event_type="frame_step",
  548. progress=frame_progress,
  549. frame_current=i,
  550. frame_total=total_frames,
  551. step=4,
  552. action="video"
  553. ))
  554. # Use FrameProcessor for proper composition
  555. processed_frame = await self.core.frame_processor(
  556. frame=frame,
  557. storyboard=storyboard,
  558. config=config,
  559. total_frames=total_frames
  560. )
  561. logger.success(f"✅ Scene {i} complete")
  562. # Emit completion of frame production
  563. self._emit_progress(ProgressEvent(
  564. event_type="processing_frame",
  565. progress=0.85,
  566. frame_current=total_frames,
  567. frame_total=total_frames
  568. ))
  569. return context
  570. async def post_production(self, context: PipelineContext) -> PipelineContext:
  571. """
  572. Concatenate scene videos and add BGM
  573. Args:
  574. context: Pipeline context
  575. Returns:
  576. Updated context with final video path
  577. """
  578. logger.info("🎞️ Concatenating scenes...")
  579. # Emit progress for concatenation (85% - 95%)
  580. self._emit_progress(ProgressEvent(
  581. event_type="concatenating",
  582. progress=0.86
  583. ))
  584. # Collect video segments from storyboard frames
  585. scene_videos = [frame.video_segment_path for frame in context.storyboard.frames]
  586. # Generate filename: use title if provided, otherwise use task_id or default name
  587. if context.title:
  588. filename = f"{context.title}.mp4"
  589. else:
  590. filename = f"{context.task_id}.mp4" # Use task_id as filename when title is empty
  591. final_video_path = Path(context.task_dir) / filename
  592. # Get BGM parameters
  593. bgm_path = context.request.get("bgm_path")
  594. bgm_volume = context.request.get("bgm_volume", 0.2)
  595. bgm_mode = context.request.get("bgm_mode", "loop")
  596. if bgm_path:
  597. logger.info(f"🎵 Adding BGM: {bgm_path} (volume={bgm_volume}, mode={bgm_mode})")
  598. self.core.video.concat_videos(
  599. videos=scene_videos,
  600. output=str(final_video_path),
  601. bgm_path=bgm_path,
  602. bgm_volume=bgm_volume,
  603. bgm_mode=bgm_mode
  604. )
  605. context.final_video_path = str(final_video_path)
  606. context.storyboard.final_video_path = str(final_video_path)
  607. logger.success(f"✅ Final video: {final_video_path}")
  608. # Emit completion of concatenation
  609. self._emit_progress(ProgressEvent(
  610. event_type="concatenating",
  611. progress=0.95,
  612. extra_info="complete"
  613. ))
  614. return context
  615. async def finalize(self, context: PipelineContext) -> PipelineContext:
  616. """
  617. Finalize and return result
  618. Args:
  619. context: Pipeline context
  620. Returns:
  621. Final context
  622. """
  623. logger.success(f"🎉 Asset-based video generation complete!")
  624. logger.info(f"Video: {context.final_video_path}")
  625. # Emit completion
  626. self._emit_progress(ProgressEvent(
  627. event_type="completed",
  628. progress=1.0
  629. ))
  630. # Persist metadata for history tracking
  631. await self._persist_task_data(context)
  632. return context
  633. async def _persist_task_data(self, ctx: PipelineContext):
  634. """
  635. Persist task metadata and storyboard to filesystem for history tracking
  636. """
  637. from pathlib import Path
  638. try:
  639. storyboard = ctx.storyboard
  640. task_id = ctx.task_id
  641. if not task_id:
  642. logger.warning("No task_id in context, skipping persistence")
  643. return
  644. # Get file size
  645. video_path_obj = Path(ctx.final_video_path)
  646. file_size = video_path_obj.stat().st_size if video_path_obj.exists() else 0
  647. # Build metadata
  648. input_params = {
  649. "text": ctx.input_text,
  650. "mode": "asset_based",
  651. "title": ctx.title or "",
  652. "n_scenes": len(storyboard.frames) if storyboard else 0,
  653. "assets": ctx.request.get("assets", []),
  654. "intent": ctx.request.get("intent"),
  655. "duration": ctx.request.get("duration"),
  656. "source": ctx.request.get("source"),
  657. "voice_id": ctx.request.get("voice_id"),
  658. "tts_speed": ctx.request.get("tts_speed"),
  659. }
  660. metadata = {
  661. "task_id": task_id,
  662. "created_at": storyboard.created_at.isoformat() if storyboard and storyboard.created_at else None,
  663. "completed_at": storyboard.completed_at.isoformat() if storyboard and storyboard.completed_at else None,
  664. "status": "completed",
  665. "input": input_params,
  666. "result": {
  667. "video_path": ctx.final_video_path,
  668. "duration": storyboard.total_duration if storyboard else 0,
  669. "file_size": file_size,
  670. "n_frames": len(storyboard.frames) if storyboard else 0
  671. },
  672. "config": {
  673. "llm_model": self.core.config.get("llm", {}).get("model", "unknown"),
  674. "llm_base_url": self.core.config.get("llm", {}).get("base_url", "unknown"),
  675. "source": ctx.request.get("source", "runninghub"),
  676. }
  677. }
  678. # Save metadata
  679. await self.core.persistence.save_task_metadata(task_id, metadata)
  680. logger.info(f"💾 Saved task metadata: {task_id}")
  681. # Save storyboard
  682. if storyboard:
  683. await self.core.persistence.save_storyboard(task_id, storyboard)
  684. logger.info(f"💾 Saved storyboard: {task_id}")
  685. except Exception as e:
  686. logger.error(f"Failed to persist task data: {e}")
  687. # Don't raise - persistence failure shouldn't break video generation
  688. # Helper methods
  689. def _get_asset_type(self, path: Path) -> str:
  690. """Determine asset type from file extension"""
  691. image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
  692. video_exts = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
  693. ext = path.suffix.lower()
  694. if ext in image_exts:
  695. return "image"
  696. elif ext in video_exts:
  697. return "video"
  698. else:
  699. return "unknown"