"""Video assembly service - combines all elements into final export.""" import os import subprocess import logging import json from pathlib import Path from typing import List, Optional, Dict from dataclasses import dataclass logger = logging.getLogger(__name__) @dataclass class AssemblyConfig: """Configuration for video assembly.""" clip_start: float clip_end: float source_video: str output_path: str voiceover_path: Optional[str] = None subtitle_path: Optional[str] = None stock_images: List[str] = None sound_effects: List[Dict] = None music_path: Optional[str] = None color_grade: str = "teal_orange" include_original_audio: bool = False intro_card_path: Optional[str] = None title: str = "" def __post_init__(self): if self.stock_images is None: self.stock_images = [] if self.sound_effects is None: self.sound_effects = [] # Color grading LUT filter definitions (ffmpeg eq/colorbalance approximations) COLOR_GRADES = { "teal_orange": "colorbalance=rs=0.1:gs=-0.1:bs=-0.1:rm=0.1:gm=0:bm=-0.1:rh=0.05:gh=-0.05:bh=-0.1,eq=saturation=1.3", "high_contrast_bw": "colorchannelmixer=.3:.4:.3:0:.3:.4:.3:0:.3:.4:.3,eq=contrast=1.4:brightness=0.05", "warm_golden": "colorbalance=rs=0.15:gs=0.05:bs=-0.1:rm=0.1:gm=0.05:bm=-0.05,eq=saturation=1.2:brightness=0.05", "cold_blue": "colorbalance=rs=-0.1:gs=-0.05:bs=0.15:rm=-0.05:gm=0:bm=0.1,eq=saturation=1.1:contrast=1.1", "vivid_oversaturated": "eq=saturation=1.8:contrast=1.2:brightness=0.03,unsharp=5:5:1.0", } def trim_clip(source_video: str, start: float, end: float, output_path: str) -> str: """Trim a clip from the source video. Args: source_video: Path to source video start: Start time in seconds end: End time in seconds output_path: Output file path Returns: Path to trimmed clip """ cmd = [ "ffmpeg", "-y", "-ss", str(start), "-i", source_video, "-t", str(end - start), "-c:v", "libx264", "-crf", "18", "-c:a", "aac", "-b:a", "192k", "-preset", "fast", output_path ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0: raise RuntimeError(f"Trim failed: {result.stderr[:200]}") return output_path def resize_to_portrait(input_path: str, output_path: str, width: int = 1080, height: int = 1920) -> str: """Resize and crop video to portrait (9:16) format. Center-crops the video to fit 1080x1920. """ # Scale to fill and center crop filter_str = ( f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}" ) cmd = [ "ffmpeg", "-y", "-i", input_path, "-vf", filter_str, "-c:v", "libx264", "-crf", "18", "-c:a", "copy", "-preset", "fast", output_path ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0: raise RuntimeError(f"Resize failed: {result.stderr[:200]}") return output_path def apply_color_grade(input_path: str, output_path: str, grade_name: str) -> str: """Apply color grading to video. Args: input_path: Input video path output_path: Output path grade_name: Color grade preset name Returns: Path to graded video """ grade_filter = COLOR_GRADES.get(grade_name, COLOR_GRADES["teal_orange"]) cmd = [ "ffmpeg", "-y", "-i", input_path, "-vf", grade_filter, "-c:v", "libx264", "-crf", "18", "-c:a", "copy", "-preset", "fast", output_path ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0: logger.warning(f"Color grade failed, using original: {result.stderr[:200]}") return input_path return output_path def apply_vignette_and_grain(input_path: str, output_path: str) -> str: """Apply vignette and film grain effects.""" filter_str = ( "vignette=PI/5," "noise=c0s=8:c0f=t+u" # Light film grain ) cmd = [ "ffmpeg", "-y", "-i", input_path, "-vf", filter_str, "-c:v", "libx264", "-crf", "18", "-c:a", "copy", "-preset", "fast", output_path ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0: logger.warning(f"Effects failed: {result.stderr[:200]}") return input_path return output_path def create_ken_burns_clip(image_path: str, duration: float, output_path: str) -> str: """Create a Ken Burns effect video from a still image. Applies slow zoom and pan over the image. """ fps = 30 total_frames = int(duration * fps) # Zoompan filter: slow zoom in from 1.0x to 1.3x over duration filter_str = ( f"zoompan=z='min(zoom+0.001,1.3)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'" f":d={total_frames}:s=1080x1920:fps={fps}" ) cmd = [ "ffmpeg", "-y", "-loop", "1", "-i", image_path, "-vf", filter_str, "-t", str(duration), "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p", "-preset", "fast", output_path ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: logger.warning(f"Ken Burns failed: {result.stderr[:200]}") # Create simple static image video as fallback cmd_fallback = [ "ffmpeg", "-y", "-loop", "1", "-i", image_path, "-vf", f"scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2", "-t", str(duration), "-c:v", "libx264", "-pix_fmt", "yuv420p", output_path ] subprocess.run(cmd_fallback, capture_output=True, timeout=30) return output_path def create_intro_card( title: str, keyframe_path: Optional[str], output_path: str, duration: float = 3.0, style: str = "heatwave" ) -> str: """Create an animated intro card. Args: title: Clip title text keyframe_path: Background keyframe image (will be blurred) output_path: Output video path duration: Intro duration in seconds style: Visual style matching caption style Returns: Path to intro card video """ from PIL import Image, ImageDraw, ImageFont, ImageFilter import tempfile width, height = 1080, 1920 fps = 30 frames_dir = Path(output_path).parent / "intro_frames" frames_dir.mkdir(exist_ok=True) # Create background if keyframe_path and os.path.exists(keyframe_path): bg = Image.open(keyframe_path).resize((width, height)).filter(ImageFilter.GaussianBlur(20)) else: # Gradient background bg = Image.new("RGB", (width, height), (20, 20, 40)) draw = ImageDraw.Draw(bg) for y in range(height): ratio = y / height r = int(20 + 30 * ratio) g = int(20 + 10 * ratio) b = int(40 + 60 * ratio) draw.line([(0, y), (width, y)], fill=(r, g, b)) # Darken background overlay = Image.new("RGBA", (width, height), (0, 0, 0, 150)) bg = bg.convert("RGBA") bg = Image.alpha_composite(bg, overlay).convert("RGB") # Load font try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 72) font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 36) except: font = ImageFont.load_default() font_small = font # Generate frames with text animation (slide up + fade in) total_frames = int(duration * fps) for frame_idx in range(total_frames): frame = bg.copy() draw = ImageDraw.Draw(frame) # Animation progress (0 to 1) progress = min(frame_idx / (fps * 1.0), 1.0) # Animate over first 1 second # Text slide up effect y_offset = int(50 * (1 - progress)) # Slide up 50px alpha = int(255 * progress) # Draw title (word wrap) words = title.split() lines = [] current_line = "" for word in words: test_line = f"{current_line} {word}".strip() bbox = draw.textbbox((0, 0), test_line, font=font) if bbox[2] - bbox[0] > width - 100: lines.append(current_line) current_line = word else: current_line = test_line if current_line: lines.append(current_line) # Center text vertically line_height = 90 total_text_height = len(lines) * line_height start_y = (height - total_text_height) // 2 + y_offset for i, line in enumerate(lines): bbox = draw.textbbox((0, 0), line, font=font) text_width = bbox[2] - bbox[0] x = (width - text_width) // 2 y = start_y + i * line_height # Draw text shadow draw.text((x + 3, y + 3), line, font=font, fill=(0, 0, 0)) # Draw text draw.text((x, y), line, font=font, fill=(255, 255, 255)) # Save frame frame_path = str(frames_dir / f"frame_{frame_idx:05d}.png") frame.save(frame_path) # Assemble frames into video cmd = [ "ffmpeg", "-y", "-framerate", str(fps), "-i", str(frames_dir / "frame_%05d.png"), "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p", "-preset", "fast", output_path ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) # Clean up frames import shutil shutil.rmtree(frames_dir, ignore_errors=True) if result.returncode != 0: logger.error(f"Intro card creation failed: {result.stderr[:200]}") # Create a simple colored clip as fallback cmd_fallback = [ "ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=black:s={width}x{height}:d={duration}:r={fps}", "-vf", f"drawtext=text='{title[:30]}':fontsize=60:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2", "-c:v", "libx264", "-pix_fmt", "yuv420p", output_path ] subprocess.run(cmd_fallback, capture_output=True, timeout=30) return output_path def mix_audio( voiceover_path: str, output_path: str, original_audio_path: Optional[str] = None, music_path: Optional[str] = None, sfx_paths: List[Dict] = None, clip_duration: float = 60.0 ) -> str: """Mix all audio tracks together. Audio levels: - Voiceover: 0dB (primary) - Original audio: -18dB - Background music: -20dB (ducked under voiceover) - Sound effects: -12dB Args: voiceover_path: Path to voiceover audio output_path: Output mixed audio path original_audio_path: Optional original video audio music_path: Optional background music sfx_paths: List of {path, offset_seconds} for sound effects clip_duration: Target clip duration Returns: Path to mixed audio file """ if sfx_paths is None: sfx_paths = [] inputs = ["-i", voiceover_path] filter_parts = [] input_idx = 0 # Voiceover is always input [0] filter_parts.append(f"[0:a]volume=1.0,apad=pad_dur=0[vo]") input_idx = 1 mix_inputs = ["[vo]"] # Add original audio if provided if original_audio_path and os.path.exists(original_audio_path): inputs.extend(["-i", original_audio_path]) filter_parts.append(f"[{input_idx}:a]volume=0.125[orig]") # -18dB mix_inputs.append("[orig]") input_idx += 1 # Add background music if provided if music_path and os.path.exists(music_path): inputs.extend(["-i", music_path]) filter_parts.append(f"[{input_idx}:a]volume=0.1,aloop=loop=-1:size=2e+09,atrim=0:{clip_duration}[music]") # -20dB, looped mix_inputs.append("[music]") input_idx += 1 # Add sound effects for i, sfx in enumerate(sfx_paths[:5]): # Max 5 SFX sfx_file = sfx.get("path", "") offset = sfx.get("offset_seconds", 0) if os.path.exists(sfx_file): inputs.extend(["-i", sfx_file]) filter_parts.append(f"[{input_idx}:a]volume=0.25,adelay={int(offset*1000)}|{int(offset*1000)}[sfx{i}]") # -12dB mix_inputs.append(f"[sfx{i}]") input_idx += 1 # Mix all inputs num_inputs = len(mix_inputs) mix_str = "".join(mix_inputs) filter_parts.append(f"{mix_str}amix=inputs={num_inputs}:duration=first:dropout_transition=2[out]") filter_complex = ";".join(filter_parts) cmd = [ "ffmpeg", "-y", *inputs, "-filter_complex", filter_complex, "-map", "[out]", "-c:a", "aac", "-b:a", "192k", "-t", str(clip_duration), output_path ] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode == 0: return output_path else: logger.warning(f"Audio mix failed: {result.stderr[:200]}") # Fallback: just use voiceover subprocess.run( ["ffmpeg", "-y", "-i", voiceover_path, "-c:a", "aac", "-b:a", "192k", output_path], capture_output=True, timeout=30 ) return output_path except Exception as e: logger.error(f"Audio mix error: {e}") return voiceover_path def assemble_final_video(config: AssemblyConfig) -> str: """Full video assembly pipeline. Steps: 1. Trim source video 2. Resize to portrait 3. Apply color grading 4. Apply effects (vignette + grain) 5. Burn subtitles 6. Mix audio 7. Combine video + audio 8. Prepend intro card 9. Export final Args: config: AssemblyConfig with all parameters Returns: Path to final assembled video """ work_dir = Path(config.output_path).parent work_dir.mkdir(parents=True, exist_ok=True) clip_duration = config.clip_end - config.clip_start # Step 1: Trim logger.info(f"Step 1: Trimming {config.clip_start:.1f}s - {config.clip_end:.1f}s") trimmed = str(work_dir / "01_trimmed.mp4") trim_clip(config.source_video, config.clip_start, config.clip_end, trimmed) # Step 2: Resize to portrait logger.info("Step 2: Resizing to 1080x1920") resized = str(work_dir / "02_resized.mp4") resize_to_portrait(trimmed, resized) # Step 3: Color grading logger.info(f"Step 3: Applying color grade: {config.color_grade}") graded = str(work_dir / "03_graded.mp4") apply_color_grade(resized, graded, config.color_grade) # Step 4: Vignette + grain logger.info("Step 4: Applying vignette and grain") effected = str(work_dir / "04_effects.mp4") apply_vignette_and_grain(graded, effected) # Step 5: Burn subtitles current_video = effected if config.subtitle_path and os.path.exists(config.subtitle_path): logger.info("Step 5: Burning subtitles") subtitled = str(work_dir / "05_subtitled.mp4") current_video = burn_subtitles(effected, config.subtitle_path, subtitled) # Step 6: Mix audio logger.info("Step 6: Mixing audio") mixed_audio = str(work_dir / "06_mixed_audio.aac") original_audio = None if config.include_original_audio: original_audio = str(work_dir / "original_audio.aac") subprocess.run( ["ffmpeg", "-y", "-i", trimmed, "-vn", "-c:a", "aac", original_audio], capture_output=True, timeout=30 ) if config.voiceover_path and os.path.exists(config.voiceover_path): mix_audio( voiceover_path=config.voiceover_path, output_path=mixed_audio, original_audio_path=original_audio, music_path=config.music_path, sfx_paths=config.sound_effects, clip_duration=clip_duration ) else: # Just use original audio if original_audio: mixed_audio = original_audio else: # Generate silence subprocess.run( ["ffmpeg", "-y", "-f", "lavfi", "-i", f"anullsrc=r=44100:cl=stereo", "-t", str(clip_duration), "-c:a", "aac", mixed_audio], capture_output=True, timeout=10 ) # Step 7: Combine video + mixed audio logger.info("Step 7: Combining video and audio") combined = str(work_dir / "07_combined.mp4") cmd = [ "ffmpeg", "-y", "-i", current_video, "-i", mixed_audio, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac", "-shortest", combined ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0: combined = current_video # Fallback # Step 8: Prepend intro card final_input = combined if config.intro_card_path and os.path.exists(config.intro_card_path): logger.info("Step 8: Prepending intro card") # Add silent audio to intro card intro_with_audio = str(work_dir / "intro_audio.mp4") cmd = [ "ffmpeg", "-y", "-i", config.intro_card_path, "-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo", "-c:v", "copy", "-c:a", "aac", "-shortest", intro_with_audio ] subprocess.run(cmd, capture_output=True, timeout=30) # Concatenate intro + main clip concat_file = str(work_dir / "concat.txt") with open(concat_file, "w") as f: f.write(f"file '{intro_with_audio}'\nfile '{combined}'\n") with_intro = str(work_dir / "08_with_intro.mp4") cmd = [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file, "-c:v", "libx264", "-crf", "18", "-c:a", "aac", "-b:a", "192k", with_intro ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode == 0: final_input = with_intro # Step 9: Final export with target settings logger.info("Step 9: Final export") cmd = [ "ffmpeg", "-y", "-i", final_input, "-c:v", "libx264", "-crf", "18", "-preset", "medium", "-r", "60", # 60fps "-s", "1080x1920", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", config.output_path ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) if result.returncode != 0: logger.error(f"Final export failed: {result.stderr[:300]}") # Try simpler export subprocess.run( ["cp", final_input, config.output_path], capture_output=True ) # Cleanup intermediate files for f in work_dir.glob("0*_*.mp4"): try: os.remove(f) except: pass logger.info(f"Assembly complete: {config.output_path}") return config.output_path