video.py 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004
  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. Video Processing Service
  14. High-performance video composition service built on ffmpeg-python.
  15. Features:
  16. - Video concatenation
  17. - Audio/video merging
  18. - Background music addition
  19. - Image to video conversion
  20. Note: Requires FFmpeg to be installed on the system.
  21. """
  22. import os
  23. import shutil
  24. import tempfile
  25. import uuid
  26. from pathlib import Path
  27. from typing import List, Literal, Optional
  28. import ffmpeg
  29. from loguru import logger
  30. from pixelle_video.utils.os_util import (
  31. get_resource_path,
  32. list_resource_files,
  33. resource_exists
  34. )
  35. def check_ffmpeg() -> None:
  36. """
  37. Check if FFmpeg is installed on the system
  38. Raises:
  39. RuntimeError: If FFmpeg is not found
  40. """
  41. if not shutil.which("ffmpeg"):
  42. raise RuntimeError(
  43. "FFmpeg not found. Please install it:\n"
  44. " macOS: brew install ffmpeg\n"
  45. " Ubuntu/Debian: apt-get install ffmpeg\n"
  46. " Windows: https://ffmpeg.org/download.html"
  47. )
  48. class VideoService:
  49. """
  50. Video compositor for common video processing tasks
  51. Uses ffmpeg-python for high-performance video processing.
  52. All operations preserve video quality when possible (stream copy).
  53. Examples:
  54. >>> compositor = VideoCompositor()
  55. >>>
  56. >>> # Concatenate videos
  57. >>> compositor.concat_videos(
  58. ... ["intro.mp4", "main.mp4", "outro.mp4"],
  59. ... "final.mp4"
  60. ... )
  61. >>>
  62. >>> # Add voiceover
  63. >>> compositor.merge_audio_video(
  64. ... "visual.mp4",
  65. ... "voiceover.mp3",
  66. ... "final.mp4"
  67. ... )
  68. >>>
  69. >>> # Add background music
  70. >>> compositor.add_bgm(
  71. ... "video.mp4",
  72. ... "music.mp3",
  73. ... "final.mp4",
  74. ... bgm_volume=0.3
  75. ... )
  76. >>>
  77. >>> # Create video from image + audio
  78. >>> compositor.create_video_from_image(
  79. ... "frame.png",
  80. ... "narration.mp3",
  81. ... "segment.mp4"
  82. ... )
  83. """
  84. def __init__(self):
  85. self._ffmpeg_checked = False
  86. def _ensure_ffmpeg(self):
  87. """Lazily check FFmpeg availability on first use, not at import time"""
  88. if not self._ffmpeg_checked:
  89. check_ffmpeg()
  90. self._ffmpeg_checked = True
  91. def concat_videos(
  92. self,
  93. videos: List[str],
  94. output: str,
  95. method: Literal["demuxer", "filter"] = "demuxer",
  96. bgm_path: Optional[str] = None,
  97. bgm_volume: float = 0.2,
  98. bgm_mode: Literal["once", "loop"] = "loop"
  99. ) -> str:
  100. """
  101. Concatenate multiple videos into one
  102. Args:
  103. videos: List of video file paths to concatenate
  104. output: Output video file path
  105. method: Concatenation method
  106. - "demuxer": Fast, no re-encoding (requires identical formats)
  107. - "filter": Slower but handles different formats
  108. bgm_path: Background music file path (optional)
  109. - None: No BGM
  110. """
  111. self._ensure_ffmpeg()
  112. if not videos:
  113. raise ValueError("Videos list cannot be empty")
  114. if len(videos) == 1:
  115. logger.info(f"Only one video provided, copying to {output}")
  116. shutil.copy(videos[0], output)
  117. return output
  118. logger.info(f"Concatenating {len(videos)} videos using {method} method")
  119. # Step 1: Concatenate videos
  120. if bgm_path:
  121. # If BGM needed, concatenate to temp file first
  122. temp_output = output.replace('.mp4', '_no_bgm.mp4')
  123. concat_result = self._concat_demuxer(videos, temp_output) if method == "demuxer" else self._concat_filter(videos, temp_output)
  124. # Step 2: Add BGM
  125. logger.info(f"Adding BGM: {bgm_path} (volume={bgm_volume}, mode={bgm_mode})")
  126. final_result = self._add_bgm_to_video(
  127. video=concat_result,
  128. bgm_path=bgm_path,
  129. output=output,
  130. volume=bgm_volume,
  131. mode=bgm_mode
  132. )
  133. # Clean up temp file
  134. if os.path.exists(temp_output):
  135. os.unlink(temp_output)
  136. return final_result
  137. else:
  138. # No BGM, direct concatenation
  139. if method == "demuxer":
  140. return self._concat_demuxer(videos, output)
  141. else:
  142. return self._concat_filter(videos, output)
  143. def _concat_demuxer(self, videos: List[str], output: str) -> str:
  144. """
  145. Concatenate using concat demuxer (fast, no re-encoding)
  146. FFmpeg equivalent:
  147. ffmpeg -f concat -safe 0 -i filelist.txt -c copy output.mp4
  148. """
  149. # Create temporary file list
  150. with tempfile.NamedTemporaryFile(
  151. mode='w',
  152. delete=False,
  153. suffix='.txt',
  154. encoding='utf-8'
  155. ) as f:
  156. for video in videos:
  157. abs_path = Path(video).absolute()
  158. escaped_path = str(abs_path).replace("'", "'\\''")
  159. f.write(f"file '{escaped_path}'\n")
  160. filelist = f.name
  161. try:
  162. logger.debug(f"Created filelist: {filelist}")
  163. (
  164. ffmpeg
  165. .input(filelist, format='concat', safe=0)
  166. .output(output, c='copy')
  167. .overwrite_output()
  168. .run(capture_stdout=True, capture_stderr=True)
  169. )
  170. logger.success(f"Videos concatenated successfully: {output}")
  171. return output
  172. except ffmpeg.Error as e:
  173. error_msg = e.stderr.decode() if e.stderr else str(e)
  174. logger.error(f"FFmpeg concat error: {error_msg}")
  175. raise RuntimeError(f"Failed to concatenate videos: {error_msg}")
  176. finally:
  177. if os.path.exists(filelist):
  178. os.unlink(filelist)
  179. def _concat_filter(self, videos: List[str], output: str) -> str:
  180. """
  181. Concatenate using concat filter (slower but handles different formats)
  182. FFmpeg equivalent:
  183. ffmpeg -i v1.mp4 -i v2.mp4 -filter_complex "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]"
  184. -map "[v]" -map "[a]" output.mp4
  185. """
  186. try:
  187. # Build filter_complex string manually
  188. n = len(videos)
  189. # Build input stream labels: [0:v][0:a][1:v][1:a]...
  190. stream_spec = "".join([f"[{i}:v][{i}:a]" for i in range(n)])
  191. filter_complex = f"{stream_spec}concat=n={n}:v=1:a=1[v][a]"
  192. # Build ffmpeg command
  193. cmd = ['ffmpeg']
  194. for video in videos:
  195. cmd.extend(['-i', video])
  196. cmd.extend([
  197. '-filter_complex', filter_complex,
  198. '-map', '[v]',
  199. '-map', '[a]',
  200. '-y', # Overwrite output
  201. output
  202. ])
  203. # Run command
  204. import subprocess
  205. result = subprocess.run(
  206. cmd,
  207. capture_output=True,
  208. text=True,
  209. check=True
  210. )
  211. logger.success(f"Videos concatenated successfully: {output}")
  212. return output
  213. except subprocess.CalledProcessError as e:
  214. error_msg = e.stderr if e.stderr else str(e)
  215. logger.error(f"FFmpeg concat filter error: {error_msg}")
  216. raise RuntimeError(f"Failed to concatenate videos: {error_msg}")
  217. except Exception as e:
  218. logger.error(f"Concatenation error: {e}")
  219. raise RuntimeError(f"Failed to concatenate videos: {e}")
  220. def _get_video_duration(self, video: str) -> float:
  221. """Get video duration in seconds"""
  222. try:
  223. probe = ffmpeg.probe(video)
  224. duration = float(probe['format']['duration'])
  225. return duration
  226. except Exception as e:
  227. logger.warning(f"Failed to get video duration: {e}")
  228. return 0.0
  229. def _get_audio_duration(self, audio: str) -> float:
  230. """Get audio duration in seconds"""
  231. try:
  232. probe = ffmpeg.probe(audio)
  233. duration = float(probe['format']['duration'])
  234. return duration
  235. except Exception as e:
  236. logger.warning(f"Failed to get audio duration: {e}, using estimate")
  237. # Fallback: estimate based on file size (very rough)
  238. import os
  239. file_size = os.path.getsize(audio)
  240. # Assume ~16kbps for MP3, so 2KB per second
  241. estimated_duration = file_size / 2000
  242. return max(1.0, estimated_duration) # At least 1 second
  243. def has_audio_stream(self, video: str) -> bool:
  244. """
  245. Check if video has audio stream
  246. Args:
  247. video: Video file path
  248. Returns:
  249. True if video has audio stream, False otherwise
  250. """
  251. try:
  252. probe = ffmpeg.probe(video)
  253. audio_streams = [s for s in probe.get('streams', []) if s['codec_type'] == 'audio']
  254. has_audio = len(audio_streams) > 0
  255. logger.debug(f"Video {video} has_audio={has_audio}")
  256. return has_audio
  257. except Exception as e:
  258. logger.warning(f"Failed to probe video audio streams: {e}, assuming no audio")
  259. return False
  260. def merge_audio_video(
  261. self,
  262. video: str,
  263. audio: str,
  264. output: str,
  265. replace_audio: bool = True,
  266. audio_volume: float = 1.0,
  267. video_volume: float = 0.0,
  268. pad_strategy: str = "freeze", # "freeze" (freeze last frame) or "black" (black screen)
  269. auto_adjust_duration: bool = True, # Automatically adjust video duration to match audio
  270. duration_tolerance: float = 0.3, # Tolerance for video being longer than audio (seconds)
  271. ) -> str:
  272. """
  273. Merge audio with video with intelligent duration adjustment
  274. Automatically handles duration mismatches between video and audio:
  275. - If video < audio: Pad video to match audio (avoid black screen)
  276. - If video > audio (within tolerance): Keep as-is (acceptable)
  277. - If video > audio (exceeds tolerance): Trim video to match audio
  278. Automatically handles videos with or without audio streams.
  279. - If video has no audio: adds the audio track
  280. - If video has audio and replace_audio=True: replaces with new audio
  281. - If video has audio and replace_audio=False: mixes both audio tracks
  282. Args:
  283. video: Video file path
  284. audio: Audio file path
  285. output: Output video file path
  286. replace_audio: If True, replace video's audio; if False, mix with original
  287. audio_volume: Volume of the new audio (0.0 to 1.0+)
  288. video_volume: Volume of original video audio (0.0 to 1.0+)
  289. Only used when replace_audio=False
  290. pad_strategy: Strategy to pad video if audio is longer
  291. - "freeze": Freeze last frame (default)
  292. - "black": Fill with black screen
  293. auto_adjust_duration: Enable intelligent duration adjustment (default: True)
  294. duration_tolerance: Tolerance for video being longer than audio in seconds (default: 0.3)
  295. Videos within this tolerance won't be trimmed
  296. Returns:
  297. Path to the output video file
  298. Raises:
  299. RuntimeError: If FFmpeg execution fails
  300. Note:
  301. - Uses the longer duration between video and audio
  302. - When audio is longer, video is padded using pad_strategy
  303. - When video is longer, audio is looped or extended
  304. - Automatically detects if video has audio
  305. - When video is silent, audio is added regardless of replace_audio
  306. - When replace_audio=True and video has audio, original audio is removed
  307. - When replace_audio=False and video has audio, original and new audio are mixed
  308. """
  309. self._ensure_ffmpeg()
  310. # Get durations of video and audio
  311. video_duration = self._get_video_duration(video)
  312. audio_duration = self._get_audio_duration(audio)
  313. logger.info(f"Video duration: {video_duration:.2f}s, Audio duration: {audio_duration:.2f}s")
  314. # Intelligent duration adjustment (if enabled)
  315. if auto_adjust_duration:
  316. diff = video_duration - audio_duration
  317. if diff < 0:
  318. # Video shorter than audio → Must pad to avoid black screen
  319. logger.warning(f"⚠️ Video shorter than audio by {abs(diff):.2f}s, padding required")
  320. video = self._pad_video_to_duration(video, audio_duration, pad_strategy)
  321. video_duration = audio_duration # Update duration after padding
  322. logger.info(f"📌 Padded video to {audio_duration:.2f}s")
  323. elif diff > duration_tolerance:
  324. # Video significantly longer than audio → Trim
  325. logger.info(f"⚠️ Video longer than audio by {diff:.2f}s (tolerance: {duration_tolerance}s)")
  326. video = self._trim_video_to_duration(video, audio_duration)
  327. video_duration = audio_duration # Update duration after trimming
  328. logger.info(f"✂️ Trimmed video to {audio_duration:.2f}s")
  329. else: # 0 <= diff <= duration_tolerance
  330. # Video slightly longer but within tolerance → Keep as-is
  331. logger.info(f"✅ Duration acceptable: video={video_duration:.2f}s, audio={audio_duration:.2f}s (diff={diff:.2f}s)")
  332. # Determine target duration (max of both)
  333. target_duration = max(video_duration, audio_duration)
  334. logger.info(f"Target output duration: {target_duration:.2f}s")
  335. # Check if video has audio stream
  336. video_has_audio = self.has_audio_stream(video)
  337. # Prepare video stream (potentially with padding)
  338. input_video = ffmpeg.input(video)
  339. video_stream = input_video.video
  340. # Pad video if audio is longer
  341. if audio_duration > video_duration:
  342. pad_duration = audio_duration - video_duration
  343. logger.info(f"Audio is longer, padding video by {pad_duration:.2f}s using '{pad_strategy}' strategy")
  344. if pad_strategy == "freeze":
  345. # Freeze last frame: tpad filter
  346. video_stream = video_stream.filter('tpad', stop_mode='clone', stop_duration=pad_duration)
  347. else: # black
  348. # Generate black frames for padding duration
  349. # Get video properties
  350. probe = ffmpeg.probe(video)
  351. video_info = next(s for s in probe['streams'] if s['codec_type'] == 'video')
  352. width = int(video_info['width'])
  353. height = int(video_info['height'])
  354. fps_str = video_info['r_frame_rate']
  355. fps_num, fps_den = map(int, fps_str.split('/'))
  356. fps = fps_num / fps_den if fps_den != 0 else 30
  357. # Create black video for padding
  358. black_video_path = self._get_unique_temp_path("black_pad", os.path.basename(output))
  359. black_input = ffmpeg.input(
  360. f'color=c=black:s={width}x{height}:r={fps}',
  361. f='lavfi',
  362. t=pad_duration
  363. )
  364. # Concatenate original video with black padding
  365. video_stream = ffmpeg.concat(video_stream, black_input.video, v=1, a=0)
  366. # Prepare audio stream (pad if needed to match target duration)
  367. input_audio = ffmpeg.input(audio)
  368. audio_stream = input_audio.audio.filter('volume', audio_volume)
  369. # Pad audio with silence if video is longer
  370. if video_duration > audio_duration:
  371. pad_duration = video_duration - audio_duration
  372. logger.info(f"Video is longer, padding audio with {pad_duration:.2f}s silence")
  373. # Use apad to add silence at the end
  374. audio_stream = audio_stream.filter('apad', whole_dur=target_duration)
  375. if not video_has_audio:
  376. logger.info(f"Video has no audio stream, adding audio track")
  377. # Video is silent, just add the audio
  378. try:
  379. (
  380. ffmpeg
  381. .output(
  382. video_stream,
  383. audio_stream,
  384. output,
  385. vcodec='libx264', # Re-encode video if padded
  386. acodec='aac',
  387. audio_bitrate='192k'
  388. )
  389. .overwrite_output()
  390. .run(capture_stdout=True, capture_stderr=True)
  391. )
  392. logger.success(f"Audio added to silent video: {output}")
  393. return output
  394. except ffmpeg.Error as e:
  395. error_msg = e.stderr.decode() if e.stderr else str(e)
  396. logger.error(f"FFmpeg error adding audio to silent video: {error_msg}")
  397. raise RuntimeError(f"Failed to add audio to video: {error_msg}")
  398. # Video has audio, proceed with merging
  399. logger.info(f"Merging audio with video (replace={replace_audio})")
  400. try:
  401. if replace_audio:
  402. # Replace audio: use only new audio, ignore original
  403. (
  404. ffmpeg
  405. .output(
  406. video_stream,
  407. audio_stream,
  408. output,
  409. vcodec='libx264', # Re-encode video if padded
  410. acodec='aac',
  411. audio_bitrate='192k'
  412. )
  413. .overwrite_output()
  414. .run(capture_stdout=True, capture_stderr=True)
  415. )
  416. else:
  417. # Mix audio: combine original and new audio
  418. mixed_audio = ffmpeg.filter(
  419. [
  420. input_video.audio.filter('volume', video_volume),
  421. audio_stream
  422. ],
  423. 'amix',
  424. inputs=2,
  425. duration='longest' # Use longest audio
  426. )
  427. (
  428. ffmpeg
  429. .output(
  430. video_stream,
  431. mixed_audio,
  432. output,
  433. vcodec='libx264', # Re-encode video if padded
  434. acodec='aac',
  435. audio_bitrate='192k'
  436. )
  437. .overwrite_output()
  438. .run(capture_stdout=True, capture_stderr=True)
  439. )
  440. logger.success(f"Audio merged successfully: {output}")
  441. return output
  442. except ffmpeg.Error as e:
  443. error_msg = e.stderr.decode() if e.stderr else str(e)
  444. logger.error(f"FFmpeg merge error: {error_msg}")
  445. raise RuntimeError(f"Failed to merge audio and video: {error_msg}")
  446. def overlay_image_on_video(
  447. self,
  448. video: str,
  449. overlay_image: str,
  450. output: str,
  451. scale_mode: str = "contain"
  452. ) -> str:
  453. """
  454. Overlay a transparent image on top of video
  455. Args:
  456. video: Base video file path
  457. overlay_image: Transparent overlay image path (e.g., rendered HTML with transparent background)
  458. output: Output video file path
  459. scale_mode: How to scale the base video to fit the overlay size
  460. - "contain": Scale video to fit within overlay dimensions (letterbox/pillarbox)
  461. - "cover": Scale video to cover overlay dimensions (may crop)
  462. - "stretch": Stretch video to exact overlay dimensions
  463. Returns:
  464. Path to the output video file
  465. Raises:
  466. RuntimeError: If FFmpeg execution fails
  467. Note:
  468. - Overlay image should have transparent background
  469. - Video is scaled to match overlay dimensions based on scale_mode
  470. - Final video size matches overlay image size
  471. - Video codec is re-encoded to support overlay
  472. """
  473. self._ensure_ffmpeg()
  474. logger.info(f"Overlaying image on video (scale_mode={scale_mode})")
  475. try:
  476. # Get overlay image dimensions
  477. overlay_probe = ffmpeg.probe(overlay_image)
  478. overlay_stream = next(s for s in overlay_probe['streams'] if s['codec_type'] == 'video')
  479. overlay_width = int(overlay_stream['width'])
  480. overlay_height = int(overlay_stream['height'])
  481. logger.debug(f"Overlay dimensions: {overlay_width}x{overlay_height}")
  482. input_video = ffmpeg.input(video)
  483. input_overlay = ffmpeg.input(overlay_image)
  484. # Scale video to fit overlay size using scale_mode
  485. if scale_mode == "contain":
  486. # Scale to fit (letterbox/pillarbox if aspect ratio differs)
  487. # Use scale filter with force_original_aspect_ratio=decrease and pad to center
  488. scaled_video = (
  489. input_video
  490. .filter('scale', overlay_width, overlay_height, force_original_aspect_ratio='decrease')
  491. .filter('pad', overlay_width, overlay_height, '(ow-iw)/2', '(oh-ih)/2', color='black')
  492. )
  493. elif scale_mode == "cover":
  494. # Scale to cover (crop if aspect ratio differs)
  495. scaled_video = (
  496. input_video
  497. .filter('scale', overlay_width, overlay_height, force_original_aspect_ratio='increase')
  498. .filter('crop', overlay_width, overlay_height)
  499. )
  500. else: # stretch
  501. # Stretch to exact dimensions
  502. scaled_video = input_video.filter('scale', overlay_width, overlay_height)
  503. # Overlay the transparent image on top of the scaled video
  504. output_stream = ffmpeg.overlay(scaled_video, input_overlay)
  505. (
  506. ffmpeg
  507. .output(output_stream, output,
  508. vcodec='libx264',
  509. pix_fmt='yuv420p',
  510. preset='medium',
  511. crf=23)
  512. .overwrite_output()
  513. .run(capture_stdout=True, capture_stderr=True)
  514. )
  515. logger.success(f"Image overlaid on video: {output}")
  516. return output
  517. except ffmpeg.Error as e:
  518. error_msg = e.stderr.decode() if e.stderr else str(e)
  519. logger.error(f"FFmpeg overlay error: {error_msg}")
  520. raise RuntimeError(f"Failed to overlay image on video: {error_msg}")
  521. def create_video_from_image(
  522. self,
  523. image: str,
  524. audio: str,
  525. output: str,
  526. fps: int = 30,
  527. ) -> str:
  528. """
  529. Create video from static image and audio
  530. Args:
  531. image: Image file path
  532. audio: Audio file path
  533. output: Output video path
  534. fps: Frames per second
  535. Returns:
  536. Path to the output video
  537. Raises:
  538. RuntimeError: If FFmpeg execution fails
  539. Note:
  540. - Image is displayed as static frame for the duration of audio
  541. - Video duration matches audio duration
  542. - Useful for creating video segments from storyboard frames
  543. Example:
  544. >>> compositor.create_video_from_image(
  545. ... "frame.png",
  546. ... "narration.mp3",
  547. ... "segment.mp4"
  548. ... )
  549. """
  550. self._ensure_ffmpeg()
  551. logger.info("Creating video from image and audio")
  552. try:
  553. # Get audio duration to ensure exact video duration match
  554. probe = ffmpeg.probe(audio)
  555. audio_duration = float(probe['format']['duration'])
  556. logger.debug(f"Audio duration: {audio_duration:.3f}s")
  557. # Input image with loop (loop=1 means loop indefinitely)
  558. # Use framerate to set input framerate
  559. input_image = ffmpeg.input(image, loop=1, framerate=fps)
  560. input_audio = ffmpeg.input(audio)
  561. # Combine image and audio
  562. # Use -t to explicitly set video duration = audio duration
  563. (
  564. ffmpeg
  565. .output(
  566. input_image,
  567. input_audio,
  568. output,
  569. t=audio_duration, # Force video duration to match audio exactly
  570. vcodec='libx264',
  571. acodec='aac',
  572. pix_fmt='yuv420p',
  573. audio_bitrate='192k',
  574. preset='medium',
  575. crf=23,
  576. **{'b:v': '2M'} # Video bitrate
  577. )
  578. .overwrite_output()
  579. .run(capture_stdout=True, capture_stderr=True)
  580. )
  581. logger.success(f"Video created from image: {output} (duration: {audio_duration:.3f}s)")
  582. return output
  583. except ffmpeg.Error as e:
  584. error_msg = e.stderr.decode() if e.stderr else str(e)
  585. logger.error(f"FFmpeg error creating video from image: {error_msg}")
  586. raise RuntimeError(f"Failed to create video from image: {error_msg}")
  587. def add_bgm(
  588. self,
  589. video: str,
  590. bgm: str,
  591. output: str,
  592. bgm_volume: float = 0.3,
  593. loop: bool = True,
  594. fade_in: float = 0.0,
  595. fade_out: float = 0.0,
  596. ) -> str:
  597. """
  598. Add background music to video
  599. Args:
  600. video: Video file path
  601. bgm: Background music file path
  602. output: Output video file path
  603. bgm_volume: BGM volume relative to original (0.0 to 1.0+)
  604. loop: If True, loop BGM to match video duration
  605. fade_in: BGM fade-in duration in seconds
  606. fade_out: BGM fade-out duration in seconds (not yet implemented)
  607. Returns:
  608. Path to the output video file
  609. Raises:
  610. RuntimeError: If FFmpeg execution fails
  611. Note:
  612. - BGM is mixed with original video audio
  613. - If loop=True, BGM repeats until video ends
  614. - Fade effects are applied to BGM only
  615. """
  616. self._ensure_ffmpeg()
  617. logger.info(f"Adding BGM to video (volume={bgm_volume}, loop={loop})")
  618. try:
  619. input_video = ffmpeg.input(video)
  620. # Configure BGM input with looping if needed
  621. bgm_input = ffmpeg.input(
  622. bgm,
  623. stream_loop=-1 if loop else 0 # -1 = infinite loop
  624. )
  625. # Apply volume adjustment to BGM
  626. bgm_audio = bgm_input.audio.filter('volume', bgm_volume)
  627. # Apply fade effects if specified
  628. if fade_in > 0:
  629. bgm_audio = bgm_audio.filter('afade', type='in', duration=fade_in)
  630. # Note: fade_out at the end requires knowing the duration, which is complex
  631. # For now, we skip fade_out in this implementation
  632. # A more advanced implementation would need to:
  633. # 1. Get video duration
  634. # 2. Calculate fade_out start time
  635. # 3. Apply fade filter with specific start_time
  636. # Mix original audio with BGM
  637. mixed_audio = ffmpeg.filter(
  638. [input_video.audio, bgm_audio],
  639. 'amix',
  640. inputs=2,
  641. duration='first' # Use video's duration
  642. )
  643. (
  644. ffmpeg
  645. .output(
  646. input_video.video,
  647. mixed_audio,
  648. output,
  649. vcodec='copy',
  650. acodec='aac',
  651. audio_bitrate='192k'
  652. )
  653. .overwrite_output()
  654. .run(capture_stdout=True, capture_stderr=True)
  655. )
  656. logger.success(f"BGM added successfully: {output}")
  657. return output
  658. except ffmpeg.Error as e:
  659. error_msg = e.stderr.decode() if e.stderr else str(e)
  660. logger.error(f"FFmpeg BGM error: {error_msg}")
  661. raise RuntimeError(f"Failed to add BGM: {error_msg}")
  662. def _add_bgm_to_video(
  663. self,
  664. video: str,
  665. bgm_path: str,
  666. output: str,
  667. volume: float = 0.2,
  668. mode: Literal["once", "loop"] = "loop"
  669. ) -> str:
  670. """
  671. Internal helper to add BGM to video with path resolution
  672. Args:
  673. video: Video file path
  674. bgm_path: BGM path (can be preset name or custom path)
  675. output: Output file path
  676. volume: BGM volume (0.0-1.0)
  677. mode: "once" or "loop"
  678. Returns:
  679. Path to output video
  680. Raises:
  681. FileNotFoundError: If BGM file not found
  682. """
  683. # Resolve BGM path (raises FileNotFoundError if not found)
  684. resolved_bgm = self._resolve_bgm_path(bgm_path)
  685. # Add BGM using existing method
  686. loop = (mode == "loop")
  687. return self.add_bgm(
  688. video=video,
  689. bgm=resolved_bgm,
  690. output=output,
  691. bgm_volume=volume,
  692. loop=loop,
  693. fade_in=0.0
  694. )
  695. def _get_unique_temp_path(self, prefix: str, original_filename: str) -> str:
  696. """
  697. Generate unique temporary file path to avoid concurrent conflicts
  698. Args:
  699. prefix: Prefix for the temp file (e.g., "trimmed", "padded", "black_pad")
  700. original_filename: Original filename to preserve in temp path
  701. Returns:
  702. Unique temporary file path with format: temp/{prefix}_{uuid}_{original_filename}
  703. Example:
  704. >>> self._get_unique_temp_path("trimmed", "video.mp4")
  705. >>> # Returns: "temp/trimmed_a3f2d8c1_video.mp4"
  706. """
  707. from pixelle_video.utils.os_util import get_temp_path
  708. unique_id = uuid.uuid4().hex[:8]
  709. return get_temp_path(f"{prefix}_{unique_id}_{original_filename}")
  710. def _resolve_bgm_path(self, bgm_path: str) -> str:
  711. """
  712. Resolve BGM path (filename or custom path) with custom override support
  713. Search priority:
  714. 1. Direct path (absolute or relative)
  715. 2. data/bgm/{filename} (custom)
  716. 3. bgm/{filename} (default)
  717. Args:
  718. bgm_path: Can be:
  719. - Filename with extension (e.g., "default.mp3", "happy.mp3"): auto-resolved from bgm/ or data/bgm/
  720. - Custom file path (absolute or relative)
  721. Returns:
  722. Resolved absolute path
  723. Raises:
  724. FileNotFoundError: If BGM file not found
  725. """
  726. # Try direct path first (absolute or relative)
  727. if os.path.exists(bgm_path):
  728. return os.path.abspath(bgm_path)
  729. # Try as filename in resource directories (custom > default)
  730. if resource_exists("bgm", bgm_path):
  731. return get_resource_path("bgm", bgm_path)
  732. # Not found - provide helpful error message
  733. tried_paths = [
  734. os.path.abspath(bgm_path),
  735. f"data/bgm/{bgm_path} or bgm/{bgm_path}"
  736. ]
  737. # List available BGM files
  738. available_bgm = self._list_available_bgm()
  739. available_msg = f"\n Available BGM files: {', '.join(available_bgm)}" if available_bgm else ""
  740. raise FileNotFoundError(
  741. f"BGM file not found: '{bgm_path}'\n"
  742. f" Tried paths:\n"
  743. f" 1. {tried_paths[0]}\n"
  744. f" 2. {tried_paths[1]}"
  745. f"{available_msg}"
  746. )
  747. def _list_available_bgm(self) -> list[str]:
  748. """
  749. List available BGM files (merged from bgm/ and data/bgm/)
  750. Returns:
  751. List of filenames (with extensions), sorted
  752. """
  753. try:
  754. # Use resource API to get merged list
  755. all_files = list_resource_files("bgm")
  756. # Filter to audio files only
  757. audio_extensions = ('.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac')
  758. return sorted([f for f in all_files if f.lower().endswith(audio_extensions)])
  759. except Exception as e:
  760. logger.warning(f"Failed to list BGM files: {e}")
  761. return []
  762. def _trim_video_to_duration(self, video: str, target_duration: float) -> str:
  763. """
  764. Trim video to specified duration
  765. Args:
  766. video: Input video file path
  767. target_duration: Target duration in seconds
  768. Returns:
  769. Path to trimmed video (temp file)
  770. Raises:
  771. RuntimeError: If FFmpeg execution fails
  772. """
  773. output = self._get_unique_temp_path("trimmed", os.path.basename(video))
  774. try:
  775. # Use stream copy when possible for fast trimming
  776. input_stream = ffmpeg.input(video, t=target_duration)
  777. output_kwargs = {"vcodec": "copy"}
  778. if self.has_audio_stream(video):
  779. output_kwargs["acodec"] = "copy"
  780. (
  781. input_stream
  782. .output(output, **output_kwargs)
  783. .overwrite_output()
  784. .run(capture_stdout=True, capture_stderr=True, quiet=True)
  785. )
  786. return output
  787. except ffmpeg.Error as e:
  788. error_msg = e.stderr.decode() if e.stderr else str(e)
  789. logger.error(f"FFmpeg error trimming video: {error_msg}")
  790. raise RuntimeError(f"Failed to trim video: {error_msg}")
  791. def _pad_video_to_duration(self, video: str, target_duration: float, pad_strategy: str = "freeze") -> str:
  792. """
  793. Pad video to specified duration by extending the last frame or adding black frames
  794. Args:
  795. video: Input video file path
  796. target_duration: Target duration in seconds
  797. pad_strategy: Padding strategy - "freeze" (freeze last frame) or "black" (black screen)
  798. Returns:
  799. Path to padded video (temp file)
  800. Raises:
  801. RuntimeError: If FFmpeg execution fails
  802. """
  803. output = self._get_unique_temp_path("padded", os.path.basename(video))
  804. video_duration = self._get_video_duration(video)
  805. pad_duration = target_duration - video_duration
  806. if pad_duration <= 0:
  807. # No padding needed, return original
  808. return video
  809. try:
  810. input_video = ffmpeg.input(video)
  811. video_stream = input_video.video
  812. if pad_strategy == "freeze":
  813. # Freeze last frame using tpad filter
  814. video_stream = video_stream.filter('tpad', stop_mode='clone', stop_duration=pad_duration)
  815. # Output with re-encoding (tpad requires it)
  816. (
  817. ffmpeg
  818. .output(
  819. video_stream,
  820. output,
  821. vcodec='libx264',
  822. preset='fast',
  823. crf=23
  824. )
  825. .overwrite_output()
  826. .run(capture_stdout=True, capture_stderr=True, quiet=True)
  827. )
  828. else: # black
  829. # Generate black frames for padding duration
  830. # Get video properties
  831. probe = ffmpeg.probe(video)
  832. video_info = next(s for s in probe['streams'] if s['codec_type'] == 'video')
  833. width = int(video_info['width'])
  834. height = int(video_info['height'])
  835. fps_str = video_info['r_frame_rate']
  836. fps_num, fps_den = map(int, fps_str.split('/'))
  837. fps = fps_num / fps_den if fps_den != 0 else 30
  838. # Create black video for padding
  839. black_input = ffmpeg.input(
  840. f'color=c=black:s={width}x{height}:r={fps}',
  841. f='lavfi',
  842. t=pad_duration
  843. )
  844. # Concatenate original video with black padding
  845. video_stream = ffmpeg.concat(video_stream, black_input.video, v=1, a=0)
  846. (
  847. ffmpeg
  848. .output(
  849. video_stream,
  850. output,
  851. vcodec='libx264',
  852. preset='fast',
  853. crf=23
  854. )
  855. .overwrite_output()
  856. .run(capture_stdout=True, capture_stderr=True, quiet=True)
  857. )
  858. return output
  859. except ffmpeg.Error as e:
  860. error_msg = e.stderr.decode() if e.stderr else str(e)
  861. logger.error(f"FFmpeg error padding video: {error_msg}")
  862. raise RuntimeError(f"Failed to pad video: {error_msg}")