# primitive/nodes/transform.py """ Transform executors: Modify single media inputs. Primitives: SEGMENT, RESIZE, TRANSFORM """ import logging import subprocess from pathlib import Path from typing import Any, Dict, List from ..dag import NodeType from ..executor import Executor, register_executor from .encoding import get_web_encoding_args, get_web_video_args logger = logging.getLogger(__name__) @register_executor(NodeType.SEGMENT) class SegmentExecutor(Executor): """ Extract a time segment from media. Config: offset: Start time in seconds (default: 0) duration: Duration in seconds (optional, default: to end) precise: Use frame-accurate seeking (default: True) """ def execute( self, config: Dict[str, Any], inputs: List[Path], output_path: Path, ) -> Path: if len(inputs) != 1: raise ValueError("SEGMENT requires exactly one input") input_path = inputs[0] offset = config.get("offset", 0) duration = config.get("duration") precise = config.get("precise", True) output_path.parent.mkdir(parents=True, exist_ok=True) if precise: # Frame-accurate: decode-seek (slower but precise) cmd = ["ffmpeg", "-y", "-i", str(input_path)] if offset > 0: cmd.extend(["-ss", str(offset)]) if duration: cmd.extend(["-t", str(duration)]) cmd.extend([*get_web_encoding_args(), str(output_path)]) else: # Fast: input-seek at keyframes (may be slightly off) cmd = ["ffmpeg", "-y"] if offset > 0: cmd.extend(["-ss", str(offset)]) cmd.extend(["-i", str(input_path)]) if duration: cmd.extend(["-t", str(duration)]) cmd.extend(["-c", "copy", str(output_path)]) logger.debug(f"SEGMENT: offset={offset}, duration={duration}, precise={precise}") result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"Segment failed: {result.stderr}") return output_path @register_executor(NodeType.RESIZE) class ResizeExecutor(Executor): """ Resize media to target dimensions. Config: width: Target width height: Target height mode: "fit" (letterbox), "fill" (crop), "stretch", "pad" background: Background color for pad mode (default: black) """ def execute( self, config: Dict[str, Any], inputs: List[Path], output_path: Path, ) -> Path: if len(inputs) != 1: raise ValueError("RESIZE requires exactly one input") input_path = inputs[0] width = config["width"] height = config["height"] mode = config.get("mode", "fit") background = config.get("background", "black") output_path.parent.mkdir(parents=True, exist_ok=True) if mode == "fit": # Scale to fit, add letterboxing vf = f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color={background}" elif mode == "fill": # Scale to fill, crop excess vf = f"scale={width}:{height}:force_original_aspect_ratio=increase,crop={width}:{height}" elif mode == "stretch": # Stretch to exact size vf = f"scale={width}:{height}" elif mode == "pad": # Scale down only if larger, then pad vf = f"scale='min({width},iw)':'min({height},ih)':force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color={background}" else: raise ValueError(f"Unknown resize mode: {mode}") cmd = [ "ffmpeg", "-y", "-i", str(input_path), "-vf", vf, *get_web_video_args(), "-c:a", "copy", str(output_path) ] logger.debug(f"RESIZE: {width}x{height} ({mode}) (web-optimized)") result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"Resize failed: {result.stderr}") return output_path @register_executor(NodeType.TRANSFORM) class TransformExecutor(Executor): """ Apply visual effects to media. Config: effects: Dict of effect -> value saturation: 0.0-2.0 (1.0 = normal) contrast: 0.0-2.0 (1.0 = normal) brightness: -1.0 to 1.0 (0.0 = normal) gamma: 0.1-10.0 (1.0 = normal) hue: degrees shift blur: blur radius sharpen: sharpen amount speed: playback speed multiplier """ def execute( self, config: Dict[str, Any], inputs: List[Path], output_path: Path, ) -> Path: if len(inputs) != 1: raise ValueError("TRANSFORM requires exactly one input") input_path = inputs[0] effects = config.get("effects", {}) if not effects: # No effects - just copy import shutil output_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(input_path, output_path) return output_path output_path.parent.mkdir(parents=True, exist_ok=True) # Build filter chain vf_parts = [] af_parts = [] # Color adjustments via eq filter eq_parts = [] if "saturation" in effects: eq_parts.append(f"saturation={effects['saturation']}") if "contrast" in effects: eq_parts.append(f"contrast={effects['contrast']}") if "brightness" in effects: eq_parts.append(f"brightness={effects['brightness']}") if "gamma" in effects: eq_parts.append(f"gamma={effects['gamma']}") if eq_parts: vf_parts.append(f"eq={':'.join(eq_parts)}") # Hue adjustment if "hue" in effects: vf_parts.append(f"hue=h={effects['hue']}") # Blur if "blur" in effects: vf_parts.append(f"boxblur={effects['blur']}") # Sharpen if "sharpen" in effects: vf_parts.append(f"unsharp=5:5:{effects['sharpen']}:5:5:0") # Speed change if "speed" in effects: speed = effects["speed"] vf_parts.append(f"setpts={1/speed}*PTS") af_parts.append(f"atempo={speed}") cmd = ["ffmpeg", "-y", "-i", str(input_path)] if vf_parts: cmd.extend(["-vf", ",".join(vf_parts)]) if af_parts: cmd.extend(["-af", ",".join(af_parts)]) cmd.extend([*get_web_encoding_args(), str(output_path)]) logger.debug(f"TRANSFORM: {list(effects.keys())} (web-optimized)") result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"Transform failed: {result.stderr}") return output_path