Squashed 'core/' content from commit 4957443
git-subtree-dir: core git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
This commit is contained in:
224
artdag/nodes/transform.py
Normal file
224
artdag/nodes/transform.py
Normal file
@@ -0,0 +1,224 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user