frame_processor.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  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. Frame processor - Process single frame through complete pipeline
  14. Orchestrates: TTS → Image Generation → Frame Composition → Video Segment
  15. Key Feature:
  16. - TTS-driven video duration: Audio duration from TTS is passed to video generation workflows
  17. to ensure perfect sync between audio and video (no padding, no trimming needed)
  18. """
  19. from typing import Callable, Optional
  20. import httpx
  21. from loguru import logger
  22. from pixelle_video.models.progress import ProgressEvent
  23. from pixelle_video.models.storyboard import Storyboard, StoryboardFrame, StoryboardConfig
  24. class FrameProcessor:
  25. """Frame processor"""
  26. def __init__(self, pixelle_video_core):
  27. """
  28. Initialize
  29. Args:
  30. pixelle_video_core: PixelleVideoCore instance
  31. """
  32. self.core = pixelle_video_core
  33. async def __call__(
  34. self,
  35. frame: StoryboardFrame,
  36. storyboard: 'Storyboard',
  37. config: StoryboardConfig,
  38. total_frames: int = 1,
  39. progress_callback: Optional[Callable[[ProgressEvent], None]] = None
  40. ) -> StoryboardFrame:
  41. """
  42. Process single frame through complete pipeline
  43. Steps:
  44. 1. Generate audio (TTS)
  45. 2. Generate image (ComfyKit)
  46. 3. Compose frame (add subtitle)
  47. 4. Create video segment (image + audio)
  48. Args:
  49. frame: Storyboard frame to process
  50. storyboard: Storyboard instance
  51. config: Storyboard configuration
  52. total_frames: Total number of frames in storyboard
  53. progress_callback: Optional callback for progress updates (receives ProgressEvent)
  54. Returns:
  55. Processed frame with all paths filled
  56. """
  57. logger.info(f"Processing frame {frame.index}...")
  58. frame_num = frame.index + 1
  59. # Determine if this frame needs image generation
  60. # If image_path or video_path is already set (e.g. asset-based pipeline), we consider it "has existing media" but skip generation
  61. has_existing_media = frame.image_path is not None or frame.video_path is not None
  62. needs_generation = frame.image_prompt is not None
  63. try:
  64. # Step 1: Generate audio (TTS)
  65. if not frame.audio_path:
  66. if progress_callback:
  67. progress_callback(ProgressEvent(
  68. event_type="frame_step",
  69. progress=0.0,
  70. frame_current=frame_num,
  71. frame_total=total_frames,
  72. step=1,
  73. action="audio"
  74. ))
  75. await self._step_generate_audio(frame, config)
  76. else:
  77. logger.debug(f" 1/4: Using existing audio: {frame.audio_path}")
  78. # Step 2: Generate media (image or video, conditional)
  79. if needs_generation:
  80. if progress_callback:
  81. progress_callback(ProgressEvent(
  82. event_type="frame_step",
  83. progress=0.25,
  84. frame_current=frame_num,
  85. frame_total=total_frames,
  86. step=2,
  87. action="media"
  88. ))
  89. await self._step_generate_media(frame, config)
  90. elif has_existing_media:
  91. # Log appropriate message based on media type
  92. if frame.video_path:
  93. logger.debug(f" 2/4: Using existing video: {frame.video_path}")
  94. else:
  95. logger.debug(f" 2/4: Using existing image: {frame.image_path}")
  96. else:
  97. frame.image_path = None
  98. frame.media_type = None
  99. logger.debug(f" 2/4: Skipped media generation (not required by template)")
  100. # Step 3: Compose frame (add subtitle)
  101. if progress_callback:
  102. progress_callback(ProgressEvent(
  103. event_type="frame_step",
  104. progress=0.50 if (needs_generation or has_existing_media) else 0.33,
  105. frame_current=frame_num,
  106. frame_total=total_frames,
  107. step=3,
  108. action="compose"
  109. ))
  110. await self._step_compose_frame(frame, storyboard, config)
  111. # Step 4: Create video segment
  112. if progress_callback:
  113. progress_callback(ProgressEvent(
  114. event_type="frame_step",
  115. progress=0.75 if (needs_generation or has_existing_media) else 0.67,
  116. frame_current=frame_num,
  117. frame_total=total_frames,
  118. step=4,
  119. action="video"
  120. ))
  121. await self._step_create_video_segment(frame, config)
  122. logger.info(f"✅ Frame {frame.index} completed")
  123. return frame
  124. except Exception as e:
  125. logger.error(f"❌ Failed to process frame {frame.index}: {e}")
  126. raise
  127. async def _step_generate_audio(
  128. self,
  129. frame: StoryboardFrame,
  130. config: StoryboardConfig
  131. ):
  132. """Step 1: Generate audio using TTS"""
  133. logger.debug(f" 1/4: Generating audio for frame {frame.index}...")
  134. # Generate output path using task_id
  135. from pixelle_video.utils.os_util import get_task_frame_path
  136. output_path = get_task_frame_path(config.task_id, frame.index, "audio")
  137. # Build TTS params based on inference mode
  138. tts_params = {
  139. "text": frame.narration,
  140. "inference_mode": config.tts_inference_mode,
  141. "output_path": output_path,
  142. "index": frame.index + 1, # 1-based index for workflow
  143. }
  144. if config.tts_inference_mode == "local":
  145. # Local mode: pass voice and speed
  146. if config.voice_id:
  147. tts_params["voice"] = config.voice_id
  148. if config.tts_speed is not None:
  149. tts_params["speed"] = config.tts_speed
  150. else: # comfyui
  151. # ComfyUI mode: pass workflow, voice, speed, and ref_audio
  152. if config.tts_workflow:
  153. tts_params["workflow"] = config.tts_workflow
  154. if config.voice_id:
  155. tts_params["voice"] = config.voice_id
  156. if config.tts_speed is not None:
  157. tts_params["speed"] = config.tts_speed
  158. if config.ref_audio:
  159. tts_params["ref_audio"] = config.ref_audio
  160. audio_path = await self.core.tts(**tts_params)
  161. frame.audio_path = audio_path
  162. # Get audio duration
  163. frame.duration = await self._get_audio_duration(audio_path)
  164. logger.debug(f" ✓ Audio generated: {audio_path} ({frame.duration:.2f}s)")
  165. async def _step_generate_media(
  166. self,
  167. frame: StoryboardFrame,
  168. config: StoryboardConfig
  169. ):
  170. """Step 2: Generate media (image or video) using ComfyKit"""
  171. logger.debug(f" 2/4: Generating media for frame {frame.index}...")
  172. # Determine media type based on workflow
  173. # video_ prefix in workflow name indicates video generation
  174. workflow_name = config.media_workflow or ""
  175. is_video_workflow = "video_" in workflow_name.lower()
  176. media_type = "video" if is_video_workflow else "image"
  177. logger.debug(f" → Media type: {media_type} (workflow: {workflow_name})")
  178. # Build media generation parameters
  179. media_params = {
  180. "prompt": frame.image_prompt,
  181. "workflow": config.media_workflow, # Pass workflow from config (None = use default)
  182. "media_type": media_type,
  183. "width": config.media_width,
  184. "height": config.media_height,
  185. "index": frame.index + 1, # 1-based index for workflow
  186. }
  187. # For video workflows: pass audio duration as target video duration
  188. # This ensures video length matches audio length from the source
  189. if is_video_workflow and frame.duration:
  190. media_params["duration"] = frame.duration
  191. logger.info(f" → Generating video with target duration: {frame.duration:.2f}s (from TTS audio)")
  192. # Call Media generation
  193. media_result = await self.core.media(**media_params)
  194. # Store media type
  195. frame.media_type = media_result.media_type
  196. if media_result.is_image:
  197. # Download image to local (pass task_id)
  198. local_path = await self._download_media(
  199. media_result.url,
  200. frame.index,
  201. config.task_id,
  202. media_type="image"
  203. )
  204. frame.image_path = local_path
  205. logger.debug(f" ✓ Image generated: {local_path}")
  206. elif media_result.is_video:
  207. # Download video to local (pass task_id)
  208. local_path = await self._download_media(
  209. media_result.url,
  210. frame.index,
  211. config.task_id,
  212. media_type="video"
  213. )
  214. frame.video_path = local_path
  215. # Update duration from video if available
  216. if media_result.duration:
  217. frame.duration = media_result.duration
  218. logger.debug(f" ✓ Video generated: {local_path} (duration: {frame.duration:.2f}s)")
  219. else:
  220. # Get video duration from file
  221. frame.duration = await self._get_video_duration(local_path)
  222. logger.debug(f" ✓ Video generated: {local_path} (duration: {frame.duration:.2f}s)")
  223. else:
  224. raise ValueError(f"Unknown media type: {media_result.media_type}")
  225. async def _step_compose_frame(
  226. self,
  227. frame: StoryboardFrame,
  228. storyboard: 'Storyboard',
  229. config: StoryboardConfig
  230. ):
  231. """Step 3: Compose frame with subtitle using HTML template"""
  232. logger.debug(f" 3/4: Composing frame {frame.index}...")
  233. # Generate output path using task_id
  234. from pixelle_video.utils.os_util import get_task_frame_path
  235. output_path = get_task_frame_path(config.task_id, frame.index, "composed")
  236. # For video type: render HTML as transparent overlay image
  237. # For image type: render HTML with image background
  238. # In both cases, we need the composed image
  239. composed_path = await self._compose_frame_html(frame, storyboard, config, output_path)
  240. frame.composed_image_path = composed_path
  241. logger.debug(f" ✓ Frame composed: {composed_path}")
  242. async def _compose_frame_html(
  243. self,
  244. frame: StoryboardFrame,
  245. storyboard: 'Storyboard',
  246. config: StoryboardConfig,
  247. output_path: str
  248. ) -> str:
  249. """Compose frame using HTML template"""
  250. from pixelle_video.services.frame_html import HTMLFrameGenerator
  251. from pixelle_video.utils.template_util import resolve_template_path
  252. # Resolve template path (handles various input formats)
  253. template_path = resolve_template_path(config.frame_template)
  254. # Get content metadata from storyboard
  255. content_metadata = storyboard.content_metadata if storyboard else None
  256. # Build ext data
  257. ext = {
  258. "index": frame.index + 1,
  259. }
  260. # Add custom template parameters
  261. if config.template_params:
  262. ext.update(config.template_params)
  263. # Generate frame using HTML (size is auto-parsed from template path)
  264. generator = HTMLFrameGenerator(template_path)
  265. # Use video_path for video media, image_path for images
  266. media_path = frame.video_path if frame.media_type == "video" else frame.image_path
  267. logger.debug(f"Generating frame with media: '{media_path}' (type: {frame.media_type})")
  268. composed_path = await generator.generate_frame(
  269. title=storyboard.title,
  270. text=frame.narration,
  271. image=media_path, # HTMLFrameGenerator handles both image and video paths
  272. ext=ext,
  273. output_path=output_path
  274. )
  275. return composed_path
  276. async def _step_create_video_segment(
  277. self,
  278. frame: StoryboardFrame,
  279. config: StoryboardConfig
  280. ):
  281. """Step 4: Create video segment from media + audio"""
  282. logger.debug(f" 4/4: Creating video segment for frame {frame.index}...")
  283. # Generate output path using task_id
  284. from pixelle_video.utils.os_util import get_task_frame_path
  285. output_path = get_task_frame_path(config.task_id, frame.index, "segment")
  286. from pixelle_video.services.video import VideoService
  287. video_service = VideoService()
  288. # Branch based on media type
  289. if frame.media_type == "video":
  290. # Video workflow: overlay HTML template on video, then add audio
  291. logger.debug(f" → Using video-based composition with HTML overlay")
  292. # Step 1: Overlay transparent HTML image on video
  293. # The composed_image_path contains the rendered HTML with transparent background
  294. temp_video_with_overlay = get_task_frame_path(config.task_id, frame.index, "video") + "_overlay.mp4"
  295. video_service.overlay_image_on_video(
  296. video=frame.video_path,
  297. overlay_image=frame.composed_image_path,
  298. output=temp_video_with_overlay,
  299. scale_mode="contain" # Scale video to fit template size (contain mode)
  300. )
  301. # Step 2: Add narration audio to the overlaid video
  302. # Note: The video might have audio (replaced) or be silent (audio added)
  303. segment_path = video_service.merge_audio_video(
  304. video=temp_video_with_overlay,
  305. audio=frame.audio_path,
  306. output=output_path,
  307. replace_audio=True, # Replace video audio with narration
  308. audio_volume=1.0
  309. )
  310. # Clean up temp file
  311. import os
  312. if os.path.exists(temp_video_with_overlay):
  313. os.unlink(temp_video_with_overlay)
  314. elif frame.media_type == "image" or frame.media_type is None:
  315. # Image workflow: Use composed image directly
  316. # The asset_default.html template includes the image in the composition
  317. logger.debug(f" → Using image-based composition")
  318. segment_path = video_service.create_video_from_image(
  319. image=frame.composed_image_path,
  320. audio=frame.audio_path,
  321. output=output_path,
  322. fps=config.video_fps
  323. )
  324. else:
  325. raise ValueError(f"Unknown media type: {frame.media_type}")
  326. frame.video_segment_path = segment_path
  327. logger.debug(f" ✓ Video segment created: {segment_path}")
  328. async def _get_audio_duration(self, audio_path: str) -> float:
  329. """Get audio duration in seconds"""
  330. try:
  331. # Try using ffmpeg-python
  332. import ffmpeg
  333. probe = ffmpeg.probe(audio_path)
  334. duration = float(probe['format']['duration'])
  335. return duration
  336. except Exception as e:
  337. logger.warning(f"Failed to get audio duration: {e}, using estimate")
  338. # Fallback: estimate based on file size (very rough)
  339. import os
  340. file_size = os.path.getsize(audio_path)
  341. # Assume ~16kbps for MP3, so 2KB per second
  342. estimated_duration = file_size / 2000
  343. return max(1.0, estimated_duration) # At least 1 second
  344. async def _download_media(
  345. self,
  346. url: str,
  347. frame_index: int,
  348. task_id: str,
  349. media_type: str
  350. ) -> str:
  351. """Download media (image or video) from URL to local file"""
  352. from pixelle_video.utils.os_util import get_task_frame_path
  353. output_path = get_task_frame_path(task_id, frame_index, media_type)
  354. timeout = httpx.Timeout(connect=10.0, read=60, write=60, pool=60)
  355. async with httpx.AsyncClient(timeout=timeout) as client:
  356. response = await client.get(url)
  357. response.raise_for_status()
  358. with open(output_path, 'wb') as f:
  359. f.write(response.content)
  360. return output_path
  361. async def _get_video_duration(self, video_path: str) -> float:
  362. """Get video duration in seconds"""
  363. try:
  364. import ffmpeg
  365. probe = ffmpeg.probe(video_path)
  366. duration = float(probe['format']['duration'])
  367. return duration
  368. except Exception as e:
  369. logger.warning(f"Failed to get video duration: {e}, using audio duration")
  370. # Fallback: use audio duration if available
  371. return 1.0 # Default to 1 second if unable to determine