Files
rose-ash/artdag/nodes/transform.py
giles cc2dcbddd4 Squashed 'core/' content from commit 4957443
git-subtree-dir: core
git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
2026-02-24 23:09:39 +00:00

225 lines
6.9 KiB
Python

# 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