Squashed 'core/' content from commit 4957443
git-subtree-dir: core git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
This commit is contained in:
11
artdag/nodes/__init__.py
Normal file
11
artdag/nodes/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# primitive/nodes/__init__.py
|
||||
"""
|
||||
Built-in node executors.
|
||||
|
||||
Import this module to register all built-in executors.
|
||||
"""
|
||||
|
||||
from . import source
|
||||
from . import transform
|
||||
from . import compose
|
||||
from . import effect
|
||||
548
artdag/nodes/compose.py
Normal file
548
artdag/nodes/compose.py
Normal file
@@ -0,0 +1,548 @@
|
||||
# primitive/nodes/compose.py
|
||||
"""
|
||||
Compose executors: Combine multiple media inputs.
|
||||
|
||||
Primitives: SEQUENCE, LAYER, MUX, BLEND
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
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 WEB_ENCODING_ARGS_STR, get_web_encoding_args
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_duration(path: Path) -> float:
|
||||
"""Get media duration in seconds."""
|
||||
cmd = [
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "csv=p=0",
|
||||
str(path)
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return float(result.stdout.strip())
|
||||
|
||||
|
||||
def _get_video_info(path: Path) -> dict:
|
||||
"""Get video width, height, frame rate, and sample rate."""
|
||||
cmd = [
|
||||
"ffprobe", "-v", "error",
|
||||
"-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height,r_frame_rate",
|
||||
"-of", "csv=p=0",
|
||||
str(path)
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
parts = result.stdout.strip().split(",")
|
||||
width = int(parts[0]) if len(parts) > 0 and parts[0] else 1920
|
||||
height = int(parts[1]) if len(parts) > 1 and parts[1] else 1080
|
||||
fps_str = parts[2] if len(parts) > 2 else "30/1"
|
||||
# Parse frame rate (e.g., "30/1" or "30000/1001")
|
||||
if "/" in fps_str:
|
||||
num, den = fps_str.split("/")
|
||||
fps = float(num) / float(den) if float(den) != 0 else 30
|
||||
else:
|
||||
fps = float(fps_str) if fps_str else 30
|
||||
|
||||
# Get audio sample rate
|
||||
cmd_audio = [
|
||||
"ffprobe", "-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate",
|
||||
"-of", "csv=p=0",
|
||||
str(path)
|
||||
]
|
||||
result_audio = subprocess.run(cmd_audio, capture_output=True, text=True)
|
||||
sample_rate = int(result_audio.stdout.strip()) if result_audio.stdout.strip() else 44100
|
||||
|
||||
return {"width": width, "height": height, "fps": fps, "sample_rate": sample_rate}
|
||||
|
||||
|
||||
@register_executor(NodeType.SEQUENCE)
|
||||
class SequenceExecutor(Executor):
|
||||
"""
|
||||
Concatenate inputs in time order.
|
||||
|
||||
Config:
|
||||
transition: Transition config
|
||||
type: "cut" | "crossfade" | "fade"
|
||||
duration: Transition duration in seconds
|
||||
target_size: How to determine output dimensions when inputs differ
|
||||
"first": Use first input's dimensions (default)
|
||||
"last": Use last input's dimensions
|
||||
"largest": Use largest width and height from all inputs
|
||||
"explicit": Use width/height config values
|
||||
width: Target width (when target_size="explicit")
|
||||
height: Target height (when target_size="explicit")
|
||||
background: Padding color for letterbox/pillarbox (default: "black")
|
||||
"""
|
||||
|
||||
def execute(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
inputs: List[Path],
|
||||
output_path: Path,
|
||||
) -> Path:
|
||||
if len(inputs) < 1:
|
||||
raise ValueError("SEQUENCE requires at least one input")
|
||||
|
||||
if len(inputs) == 1:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(inputs[0], output_path)
|
||||
return output_path
|
||||
|
||||
transition = config.get("transition", {"type": "cut"})
|
||||
transition_type = transition.get("type", "cut")
|
||||
transition_duration = transition.get("duration", 0.5)
|
||||
|
||||
# Size handling config
|
||||
target_size = config.get("target_size", "first")
|
||||
width = config.get("width")
|
||||
height = config.get("height")
|
||||
background = config.get("background", "black")
|
||||
|
||||
if transition_type == "cut":
|
||||
return self._concat_cut(inputs, output_path, target_size, width, height, background)
|
||||
elif transition_type == "crossfade":
|
||||
return self._concat_crossfade(inputs, output_path, transition_duration)
|
||||
elif transition_type == "fade":
|
||||
return self._concat_fade(inputs, output_path, transition_duration)
|
||||
else:
|
||||
raise ValueError(f"Unknown transition type: {transition_type}")
|
||||
|
||||
def _concat_cut(
|
||||
self,
|
||||
inputs: List[Path],
|
||||
output_path: Path,
|
||||
target_size: str = "first",
|
||||
width: int = None,
|
||||
height: int = None,
|
||||
background: str = "black",
|
||||
) -> Path:
|
||||
"""
|
||||
Concatenate with scaling/padding to handle different resolutions.
|
||||
|
||||
Args:
|
||||
inputs: Input video paths
|
||||
output_path: Output path
|
||||
target_size: How to determine output size:
|
||||
- "first": Use first input's dimensions (default)
|
||||
- "last": Use last input's dimensions
|
||||
- "largest": Use largest dimensions from all inputs
|
||||
- "explicit": Use width/height params
|
||||
width: Explicit width (when target_size="explicit")
|
||||
height: Explicit height (when target_size="explicit")
|
||||
background: Padding color (default: black)
|
||||
"""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
n = len(inputs)
|
||||
input_args = []
|
||||
for p in inputs:
|
||||
input_args.extend(["-i", str(p)])
|
||||
|
||||
# Get video info for all inputs
|
||||
infos = [_get_video_info(p) for p in inputs]
|
||||
|
||||
# Determine target dimensions
|
||||
if target_size == "explicit" and width and height:
|
||||
target_w, target_h = width, height
|
||||
elif target_size == "last":
|
||||
target_w, target_h = infos[-1]["width"], infos[-1]["height"]
|
||||
elif target_size == "largest":
|
||||
target_w = max(i["width"] for i in infos)
|
||||
target_h = max(i["height"] for i in infos)
|
||||
else: # "first" or default
|
||||
target_w, target_h = infos[0]["width"], infos[0]["height"]
|
||||
|
||||
# Use common frame rate (from first input) and sample rate
|
||||
target_fps = infos[0]["fps"]
|
||||
target_sr = max(i["sample_rate"] for i in infos)
|
||||
|
||||
# Build filter for each input: scale to fit + pad to target size
|
||||
filter_parts = []
|
||||
for i in range(n):
|
||||
# Scale to fit within target, maintaining aspect ratio, then pad
|
||||
vf = (
|
||||
f"[{i}:v]scale={target_w}:{target_h}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2:color={background},"
|
||||
f"setsar=1,fps={target_fps:.6f}[v{i}]"
|
||||
)
|
||||
# Resample audio to common rate
|
||||
af = f"[{i}:a]aresample={target_sr}[a{i}]"
|
||||
filter_parts.append(vf)
|
||||
filter_parts.append(af)
|
||||
|
||||
# Build concat filter
|
||||
stream_labels = "".join(f"[v{i}][a{i}]" for i in range(n))
|
||||
filter_parts.append(f"{stream_labels}concat=n={n}:v=1:a=1[outv][outa]")
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
*input_args,
|
||||
"-filter_complex", filter_complex,
|
||||
"-map", "[outv]",
|
||||
"-map", "[outa]",
|
||||
*get_web_encoding_args(),
|
||||
str(output_path)
|
||||
]
|
||||
|
||||
logger.debug(f"SEQUENCE cut: {n} clips -> {target_w}x{target_h} (web-optimized)")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Concat failed: {result.stderr}")
|
||||
|
||||
return output_path
|
||||
|
||||
def _concat_crossfade(
|
||||
self,
|
||||
inputs: List[Path],
|
||||
output_path: Path,
|
||||
duration: float,
|
||||
) -> Path:
|
||||
"""Concatenate with crossfade transitions."""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
durations = [_get_duration(p) for p in inputs]
|
||||
n = len(inputs)
|
||||
input_args = " ".join(f"-i {p}" for p in inputs)
|
||||
|
||||
# Build xfade filter chain
|
||||
filter_parts = []
|
||||
current = "[0:v]"
|
||||
|
||||
for i in range(1, n):
|
||||
offset = sum(durations[:i]) - duration * i
|
||||
next_input = f"[{i}:v]"
|
||||
output_label = f"[v{i}]" if i < n - 1 else "[outv]"
|
||||
filter_parts.append(
|
||||
f"{current}{next_input}xfade=transition=fade:duration={duration}:offset={offset}{output_label}"
|
||||
)
|
||||
current = output_label
|
||||
|
||||
# Audio crossfade chain
|
||||
audio_current = "[0:a]"
|
||||
for i in range(1, n):
|
||||
next_input = f"[{i}:a]"
|
||||
output_label = f"[a{i}]" if i < n - 1 else "[outa]"
|
||||
filter_parts.append(
|
||||
f"{audio_current}{next_input}acrossfade=d={duration}{output_label}"
|
||||
)
|
||||
audio_current = output_label
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
cmd = f'ffmpeg -y {input_args} -filter_complex "{filter_complex}" -map [outv] -map [outa] {WEB_ENCODING_ARGS_STR} {output_path}'
|
||||
|
||||
logger.debug(f"SEQUENCE crossfade: {n} clips (web-optimized)")
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.warning(f"Crossfade failed, falling back to cut: {result.stderr[:200]}")
|
||||
return self._concat_cut(inputs, output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
def _concat_fade(
|
||||
self,
|
||||
inputs: List[Path],
|
||||
output_path: Path,
|
||||
duration: float,
|
||||
) -> Path:
|
||||
"""Concatenate with fade out/in transitions."""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
faded_paths = []
|
||||
for i, path in enumerate(inputs):
|
||||
clip_dur = _get_duration(path)
|
||||
faded_path = output_path.parent / f"_faded_{i}.mkv"
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(path),
|
||||
"-vf", f"fade=in:st=0:d={duration},fade=out:st={clip_dur - duration}:d={duration}",
|
||||
"-af", f"afade=in:st=0:d={duration},afade=out:st={clip_dur - duration}:d={duration}",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "18",
|
||||
"-c:a", "aac",
|
||||
str(faded_path)
|
||||
]
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
faded_paths.append(faded_path)
|
||||
|
||||
result = self._concat_cut(faded_paths, output_path)
|
||||
|
||||
for p in faded_paths:
|
||||
p.unlink()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@register_executor(NodeType.LAYER)
|
||||
class LayerExecutor(Executor):
|
||||
"""
|
||||
Layer inputs spatially (overlay/composite).
|
||||
|
||||
Config:
|
||||
inputs: List of per-input configs
|
||||
position: [x, y] offset
|
||||
opacity: 0.0-1.0
|
||||
scale: Scale factor
|
||||
"""
|
||||
|
||||
def execute(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
inputs: List[Path],
|
||||
output_path: Path,
|
||||
) -> Path:
|
||||
if len(inputs) < 1:
|
||||
raise ValueError("LAYER requires at least one input")
|
||||
|
||||
if len(inputs) == 1:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(inputs[0], output_path)
|
||||
return output_path
|
||||
|
||||
input_configs = config.get("inputs", [{}] * len(inputs))
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
input_args = " ".join(f"-i {p}" for p in inputs)
|
||||
n = len(inputs)
|
||||
filter_parts = []
|
||||
current = "[0:v]"
|
||||
|
||||
for i in range(1, n):
|
||||
cfg = input_configs[i] if i < len(input_configs) else {}
|
||||
x, y = cfg.get("position", [0, 0])
|
||||
opacity = cfg.get("opacity", 1.0)
|
||||
scale = cfg.get("scale", 1.0)
|
||||
|
||||
scale_label = f"[s{i}]"
|
||||
if scale != 1.0:
|
||||
filter_parts.append(f"[{i}:v]scale=iw*{scale}:ih*{scale}{scale_label}")
|
||||
overlay_input = scale_label
|
||||
else:
|
||||
overlay_input = f"[{i}:v]"
|
||||
|
||||
output_label = f"[v{i}]" if i < n - 1 else "[outv]"
|
||||
|
||||
if opacity < 1.0:
|
||||
filter_parts.append(
|
||||
f"{overlay_input}format=rgba,colorchannelmixer=aa={opacity}[a{i}]"
|
||||
)
|
||||
overlay_input = f"[a{i}]"
|
||||
|
||||
filter_parts.append(
|
||||
f"{current}{overlay_input}overlay=x={x}:y={y}:format=auto{output_label}"
|
||||
)
|
||||
current = output_label
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
cmd = f'ffmpeg -y {input_args} -filter_complex "{filter_complex}" -map [outv] -map 0:a? {WEB_ENCODING_ARGS_STR} {output_path}'
|
||||
|
||||
logger.debug(f"LAYER: {n} inputs (web-optimized)")
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Layer failed: {result.stderr}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
@register_executor(NodeType.MUX)
|
||||
class MuxExecutor(Executor):
|
||||
"""
|
||||
Combine video and audio streams.
|
||||
|
||||
Config:
|
||||
video_stream: Index of video input (default: 0)
|
||||
audio_stream: Index of audio input (default: 1)
|
||||
shortest: End when shortest stream ends (default: True)
|
||||
"""
|
||||
|
||||
def execute(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
inputs: List[Path],
|
||||
output_path: Path,
|
||||
) -> Path:
|
||||
if len(inputs) < 2:
|
||||
raise ValueError("MUX requires at least 2 inputs (video + audio)")
|
||||
|
||||
video_idx = config.get("video_stream", 0)
|
||||
audio_idx = config.get("audio_stream", 1)
|
||||
shortest = config.get("shortest", True)
|
||||
|
||||
video_path = inputs[video_idx]
|
||||
audio_path = inputs[audio_idx]
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(video_path),
|
||||
"-i", str(audio_path),
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac",
|
||||
"-map", "0:v:0",
|
||||
"-map", "1:a:0",
|
||||
]
|
||||
|
||||
if shortest:
|
||||
cmd.append("-shortest")
|
||||
|
||||
cmd.append(str(output_path))
|
||||
|
||||
logger.debug(f"MUX: video={video_path.name} + audio={audio_path.name}")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Mux failed: {result.stderr}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
@register_executor(NodeType.BLEND)
|
||||
class BlendExecutor(Executor):
|
||||
"""
|
||||
Blend two inputs using a blend mode.
|
||||
|
||||
Config:
|
||||
mode: Blend mode (multiply, screen, overlay, add, etc.)
|
||||
opacity: 0.0-1.0 for second input
|
||||
"""
|
||||
|
||||
BLEND_MODES = {
|
||||
"multiply": "multiply",
|
||||
"screen": "screen",
|
||||
"overlay": "overlay",
|
||||
"add": "addition",
|
||||
"subtract": "subtract",
|
||||
"average": "average",
|
||||
"difference": "difference",
|
||||
"lighten": "lighten",
|
||||
"darken": "darken",
|
||||
}
|
||||
|
||||
def execute(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
inputs: List[Path],
|
||||
output_path: Path,
|
||||
) -> Path:
|
||||
if len(inputs) != 2:
|
||||
raise ValueError("BLEND requires exactly 2 inputs")
|
||||
|
||||
mode = config.get("mode", "overlay")
|
||||
opacity = config.get("opacity", 0.5)
|
||||
|
||||
if mode not in self.BLEND_MODES:
|
||||
raise ValueError(f"Unknown blend mode: {mode}")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
blend_mode = self.BLEND_MODES[mode]
|
||||
|
||||
if opacity < 1.0:
|
||||
filter_complex = (
|
||||
f"[1:v]format=rgba,colorchannelmixer=aa={opacity}[b];"
|
||||
f"[0:v][b]blend=all_mode={blend_mode}"
|
||||
)
|
||||
else:
|
||||
filter_complex = f"[0:v][1:v]blend=all_mode={blend_mode}"
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(inputs[0]),
|
||||
"-i", str(inputs[1]),
|
||||
"-filter_complex", filter_complex,
|
||||
"-map", "0:a?",
|
||||
*get_web_encoding_args(),
|
||||
str(output_path)
|
||||
]
|
||||
|
||||
logger.debug(f"BLEND: {mode} (opacity={opacity}) (web-optimized)")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Blend failed: {result.stderr}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
@register_executor(NodeType.AUDIO_MIX)
|
||||
class AudioMixExecutor(Executor):
|
||||
"""
|
||||
Mix multiple audio streams.
|
||||
|
||||
Config:
|
||||
gains: List of gain values per input (0.0-2.0, default 1.0)
|
||||
normalize: Normalize output to prevent clipping (default True)
|
||||
"""
|
||||
|
||||
def execute(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
inputs: List[Path],
|
||||
output_path: Path,
|
||||
) -> Path:
|
||||
if len(inputs) < 2:
|
||||
raise ValueError("AUDIO_MIX requires at least 2 inputs")
|
||||
|
||||
gains = config.get("gains", [1.0] * len(inputs))
|
||||
normalize = config.get("normalize", True)
|
||||
|
||||
# Pad gains list if too short
|
||||
while len(gains) < len(inputs):
|
||||
gains.append(1.0)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build filter: apply volume to each input, then mix
|
||||
filter_parts = []
|
||||
mix_inputs = []
|
||||
|
||||
for i, gain in enumerate(gains[:len(inputs)]):
|
||||
if gain != 1.0:
|
||||
filter_parts.append(f"[{i}:a]volume={gain}[a{i}]")
|
||||
mix_inputs.append(f"[a{i}]")
|
||||
else:
|
||||
mix_inputs.append(f"[{i}:a]")
|
||||
|
||||
# amix filter
|
||||
normalize_flag = 1 if normalize else 0
|
||||
mix_filter = f"{''.join(mix_inputs)}amix=inputs={len(inputs)}:normalize={normalize_flag}[aout]"
|
||||
filter_parts.append(mix_filter)
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
]
|
||||
for p in inputs:
|
||||
cmd.extend(["-i", str(p)])
|
||||
|
||||
cmd.extend([
|
||||
"-filter_complex", filter_complex,
|
||||
"-map", "[aout]",
|
||||
"-c:a", "aac",
|
||||
str(output_path)
|
||||
])
|
||||
|
||||
logger.debug(f"AUDIO_MIX: {len(inputs)} inputs, gains={gains[:len(inputs)]}")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Audio mix failed: {result.stderr}")
|
||||
|
||||
return output_path
|
||||
520
artdag/nodes/effect.py
Normal file
520
artdag/nodes/effect.py
Normal file
@@ -0,0 +1,520 @@
|
||||
# artdag/nodes/effect.py
|
||||
"""
|
||||
Effect executor: Apply effects from the registry or IPFS.
|
||||
|
||||
Primitives: EFFECT
|
||||
|
||||
Effects can be:
|
||||
1. Built-in (registered with @register_effect)
|
||||
2. Stored in IPFS (referenced by CID)
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
||||
|
||||
import requests
|
||||
|
||||
from ..executor import Executor, register_executor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Type alias for effect functions: (input_path, output_path, config) -> output_path
|
||||
EffectFn = Callable[[Path, Path, Dict[str, Any]], Path]
|
||||
|
||||
# Type variable for decorator
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
# IPFS API multiaddr - same as ipfs_client.py for consistency
|
||||
# Docker uses /dns/ipfs/tcp/5001, local dev uses /ip4/127.0.0.1/tcp/5001
|
||||
IPFS_API = os.environ.get("IPFS_API", "/ip4/127.0.0.1/tcp/5001")
|
||||
|
||||
# Connection timeout in seconds
|
||||
IPFS_TIMEOUT = int(os.environ.get("IPFS_TIMEOUT", "30"))
|
||||
|
||||
|
||||
def _get_ipfs_base_url() -> str:
|
||||
"""
|
||||
Convert IPFS multiaddr to HTTP URL.
|
||||
|
||||
Matches the conversion logic in ipfs_client.py for consistency.
|
||||
"""
|
||||
multiaddr = IPFS_API
|
||||
|
||||
# Handle /dns/hostname/tcp/port format (Docker)
|
||||
dns_match = re.match(r"/dns[46]?/([^/]+)/tcp/(\d+)", multiaddr)
|
||||
if dns_match:
|
||||
return f"http://{dns_match.group(1)}:{dns_match.group(2)}"
|
||||
|
||||
# Handle /ip4/address/tcp/port format (local)
|
||||
ip4_match = re.match(r"/ip4/([^/]+)/tcp/(\d+)", multiaddr)
|
||||
if ip4_match:
|
||||
return f"http://{ip4_match.group(1)}:{ip4_match.group(2)}"
|
||||
|
||||
# Fallback: assume it's already a URL or use default
|
||||
if multiaddr.startswith("http"):
|
||||
return multiaddr
|
||||
return "http://127.0.0.1:5001"
|
||||
|
||||
|
||||
def _get_effects_cache_dir() -> Optional[Path]:
|
||||
"""Get the effects cache directory from environment or default."""
|
||||
# Check both env var names (CACHE_DIR used by art-celery, ARTDAG_CACHE_DIR for standalone)
|
||||
for env_var in ["CACHE_DIR", "ARTDAG_CACHE_DIR"]:
|
||||
cache_dir = os.environ.get(env_var)
|
||||
if cache_dir:
|
||||
effects_dir = Path(cache_dir) / "_effects"
|
||||
if effects_dir.exists():
|
||||
return effects_dir
|
||||
|
||||
# Try default locations
|
||||
for base in [Path.home() / ".artdag" / "cache", Path("/var/cache/artdag")]:
|
||||
effects_dir = base / "_effects"
|
||||
if effects_dir.exists():
|
||||
return effects_dir
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_effect_from_ipfs(cid: str, effect_path: Path) -> bool:
|
||||
"""
|
||||
Fetch an effect from IPFS and cache locally.
|
||||
|
||||
Uses the IPFS API endpoint (/api/v0/cat) for consistency with ipfs_client.py.
|
||||
This works reliably in Docker where IPFS_API=/dns/ipfs/tcp/5001.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
try:
|
||||
# Use IPFS API (same as ipfs_client.py)
|
||||
base_url = _get_ipfs_base_url()
|
||||
url = f"{base_url}/api/v0/cat"
|
||||
params = {"arg": cid}
|
||||
|
||||
response = requests.post(url, params=params, timeout=IPFS_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
|
||||
# Cache locally
|
||||
effect_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
effect_path.write_bytes(response.content)
|
||||
logger.info(f"Fetched effect from IPFS: {cid[:16]}...")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch effect from IPFS {cid[:16]}...: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _parse_pep723_dependencies(source: str) -> List[str]:
|
||||
"""
|
||||
Parse PEP 723 dependencies from effect source code.
|
||||
|
||||
Returns list of package names (e.g., ["numpy", "opencv-python"]).
|
||||
"""
|
||||
match = re.search(r"# /// script\n(.*?)# ///", source, re.DOTALL)
|
||||
if not match:
|
||||
return []
|
||||
|
||||
block = match.group(1)
|
||||
deps_match = re.search(r'# dependencies = \[(.*?)\]', block, re.DOTALL)
|
||||
if not deps_match:
|
||||
return []
|
||||
|
||||
return re.findall(r'"([^"]+)"', deps_match.group(1))
|
||||
|
||||
|
||||
def _ensure_dependencies(dependencies: List[str], effect_cid: str) -> bool:
|
||||
"""
|
||||
Ensure effect dependencies are installed.
|
||||
|
||||
Installs missing packages using pip. Returns True on success.
|
||||
"""
|
||||
if not dependencies:
|
||||
return True
|
||||
|
||||
missing = []
|
||||
for dep in dependencies:
|
||||
# Extract package name (strip version specifiers)
|
||||
pkg_name = re.split(r'[<>=!]', dep)[0].strip()
|
||||
# Normalize name (pip uses underscores, imports use underscores or hyphens)
|
||||
pkg_name_normalized = pkg_name.replace('-', '_').lower()
|
||||
|
||||
try:
|
||||
__import__(pkg_name_normalized)
|
||||
except ImportError:
|
||||
# Some packages have different import names
|
||||
try:
|
||||
# Try original name with hyphens replaced
|
||||
__import__(pkg_name.replace('-', '_'))
|
||||
except ImportError:
|
||||
missing.append(dep)
|
||||
|
||||
if not missing:
|
||||
return True
|
||||
|
||||
logger.info(f"Installing effect dependencies for {effect_cid[:16]}...: {missing}")
|
||||
|
||||
try:
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--quiet"] + missing,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Failed to install dependencies: {result.stderr}")
|
||||
return False
|
||||
|
||||
logger.info(f"Installed dependencies: {missing}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error installing dependencies: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _load_cached_effect(effect_cid: str) -> Optional[EffectFn]:
|
||||
"""
|
||||
Load an effect by CID, fetching from IPFS if not cached locally.
|
||||
|
||||
Returns the effect function or None if not found.
|
||||
"""
|
||||
effects_dir = _get_effects_cache_dir()
|
||||
|
||||
# Create cache dir if needed
|
||||
if not effects_dir:
|
||||
# Try to create default cache dir
|
||||
for env_var in ["CACHE_DIR", "ARTDAG_CACHE_DIR"]:
|
||||
cache_dir = os.environ.get(env_var)
|
||||
if cache_dir:
|
||||
effects_dir = Path(cache_dir) / "_effects"
|
||||
effects_dir.mkdir(parents=True, exist_ok=True)
|
||||
break
|
||||
|
||||
if not effects_dir:
|
||||
effects_dir = Path.home() / ".artdag" / "cache" / "_effects"
|
||||
effects_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
effect_path = effects_dir / effect_cid / "effect.py"
|
||||
|
||||
# If not cached locally, fetch from IPFS
|
||||
if not effect_path.exists():
|
||||
if not _fetch_effect_from_ipfs(effect_cid, effect_path):
|
||||
logger.warning(f"Effect not found: {effect_cid[:16]}...")
|
||||
return None
|
||||
|
||||
# Parse and install dependencies before loading
|
||||
try:
|
||||
source = effect_path.read_text()
|
||||
dependencies = _parse_pep723_dependencies(source)
|
||||
if dependencies:
|
||||
logger.info(f"Effect {effect_cid[:16]}... requires: {dependencies}")
|
||||
if not _ensure_dependencies(dependencies, effect_cid):
|
||||
logger.error(f"Failed to install dependencies for effect {effect_cid[:16]}...")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing effect dependencies: {e}")
|
||||
# Continue anyway - the effect might work without the deps check
|
||||
|
||||
# Load the effect module
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("cached_effect", effect_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Check for frame-by-frame API
|
||||
if hasattr(module, "process_frame"):
|
||||
return _wrap_frame_effect(module, effect_path)
|
||||
|
||||
# Check for whole-video API
|
||||
if hasattr(module, "process"):
|
||||
return _wrap_video_effect(module)
|
||||
|
||||
# Check for old-style effect function
|
||||
if hasattr(module, "effect"):
|
||||
return module.effect
|
||||
|
||||
logger.warning(f"Effect has no recognized API: {effect_cid[:16]}...")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load effect {effect_cid[:16]}...: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _wrap_frame_effect(module: ModuleType, effect_path: Path) -> EffectFn:
|
||||
"""Wrap a frame-by-frame effect to work with the executor API."""
|
||||
|
||||
def wrapped_effect(input_path: Path, output_path: Path, config: Dict[str, Any]) -> Path:
|
||||
"""Run frame-by-frame effect through FFmpeg pipes."""
|
||||
try:
|
||||
from ..effects.frame_processor import process_video
|
||||
except ImportError:
|
||||
logger.error("Frame processor not available - falling back to copy")
|
||||
shutil.copy2(input_path, output_path)
|
||||
return output_path
|
||||
|
||||
# Extract params from config (excluding internal keys)
|
||||
params = {k: v for k, v in config.items()
|
||||
if k not in ("effect", "hash", "_binding")}
|
||||
|
||||
# Get bindings if present
|
||||
bindings = {}
|
||||
for key, value in config.items():
|
||||
if isinstance(value, dict) and value.get("_resolved_values"):
|
||||
bindings[key] = value["_resolved_values"]
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
actual_output = output_path.with_suffix(".mp4")
|
||||
|
||||
process_video(
|
||||
input_path=input_path,
|
||||
output_path=actual_output,
|
||||
process_frame=module.process_frame,
|
||||
params=params,
|
||||
bindings=bindings,
|
||||
)
|
||||
|
||||
return actual_output
|
||||
|
||||
return wrapped_effect
|
||||
|
||||
|
||||
def _wrap_video_effect(module: ModuleType) -> EffectFn:
|
||||
"""Wrap a whole-video effect to work with the executor API."""
|
||||
|
||||
def wrapped_effect(input_path: Path, output_path: Path, config: Dict[str, Any]) -> Path:
|
||||
"""Run whole-video effect."""
|
||||
from ..effects.meta import ExecutionContext
|
||||
|
||||
params = {k: v for k, v in config.items()
|
||||
if k not in ("effect", "hash", "_binding")}
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ctx = ExecutionContext(
|
||||
input_paths=[str(input_path)],
|
||||
output_path=str(output_path),
|
||||
params=params,
|
||||
seed=hash(str(input_path)) & 0xFFFFFFFF,
|
||||
)
|
||||
|
||||
module.process([input_path], output_path, params, ctx)
|
||||
return output_path
|
||||
|
||||
return wrapped_effect
|
||||
|
||||
|
||||
# Effect registry - maps effect names to implementations
|
||||
_EFFECTS: Dict[str, EffectFn] = {}
|
||||
|
||||
|
||||
def register_effect(name: str) -> Callable[[F], F]:
|
||||
"""Decorator to register an effect implementation."""
|
||||
def decorator(func: F) -> F:
|
||||
_EFFECTS[name] = func # type: ignore[assignment]
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
def get_effect(name: str) -> Optional[EffectFn]:
|
||||
"""Get an effect implementation by name."""
|
||||
return _EFFECTS.get(name)
|
||||
|
||||
|
||||
# Built-in effects
|
||||
|
||||
@register_effect("identity")
|
||||
def effect_identity(input_path: Path, output_path: Path, config: Dict[str, Any]) -> Path:
|
||||
"""
|
||||
Identity effect - returns input unchanged.
|
||||
|
||||
This is the foundational effect: identity(x) = x
|
||||
"""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Remove existing output if any
|
||||
if output_path.exists() or output_path.is_symlink():
|
||||
output_path.unlink()
|
||||
|
||||
# Preserve extension from input
|
||||
actual_output = output_path.with_suffix(input_path.suffix)
|
||||
if actual_output.exists() or actual_output.is_symlink():
|
||||
actual_output.unlink()
|
||||
|
||||
# Symlink to input (zero-copy identity)
|
||||
os.symlink(input_path.resolve(), actual_output)
|
||||
logger.debug(f"EFFECT identity: {input_path.name} -> {actual_output}")
|
||||
|
||||
return actual_output
|
||||
|
||||
|
||||
def _get_sexp_effect(effect_path: str, recipe_dir: Path = None) -> Optional[EffectFn]:
|
||||
"""
|
||||
Load a sexp effect from a .sexp file.
|
||||
|
||||
Args:
|
||||
effect_path: Relative path to the .sexp effect file
|
||||
recipe_dir: Base directory for resolving paths
|
||||
|
||||
Returns:
|
||||
Effect function or None if not a sexp effect
|
||||
"""
|
||||
if not effect_path or not effect_path.endswith(".sexp"):
|
||||
return None
|
||||
|
||||
try:
|
||||
from ..sexp.effect_loader import SexpEffectLoader
|
||||
except ImportError:
|
||||
logger.warning("Sexp effect loader not available")
|
||||
return None
|
||||
|
||||
try:
|
||||
loader = SexpEffectLoader(recipe_dir or Path.cwd())
|
||||
return loader.load_effect_path(effect_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load sexp effect from {effect_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_python_primitive_effect(effect_name: str) -> Optional[EffectFn]:
|
||||
"""
|
||||
Get a Python primitive frame processor effect.
|
||||
|
||||
Checks if the effect has a python_primitive in FFmpegCompiler.EFFECT_MAPPINGS
|
||||
and wraps it for the executor API.
|
||||
"""
|
||||
try:
|
||||
from ..sexp.ffmpeg_compiler import FFmpegCompiler
|
||||
from ..sexp.primitives import get_primitive
|
||||
from ..effects.frame_processor import process_video
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
compiler = FFmpegCompiler()
|
||||
primitive_name = compiler.has_python_primitive(effect_name)
|
||||
if not primitive_name:
|
||||
return None
|
||||
|
||||
primitive_fn = get_primitive(primitive_name)
|
||||
if not primitive_fn:
|
||||
logger.warning(f"Python primitive '{primitive_name}' not found for effect '{effect_name}'")
|
||||
return None
|
||||
|
||||
def wrapped_effect(input_path: Path, output_path: Path, config: Dict[str, Any]) -> Path:
|
||||
"""Run Python primitive effect via frame processor."""
|
||||
# Extract params (excluding internal keys)
|
||||
params = {k: v for k, v in config.items()
|
||||
if k not in ("effect", "cid", "hash", "effect_path", "_binding")}
|
||||
|
||||
# Get bindings if present
|
||||
bindings = {}
|
||||
for key, value in config.items():
|
||||
if isinstance(value, dict) and value.get("_resolved_values"):
|
||||
bindings[key] = value["_resolved_values"]
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
actual_output = output_path.with_suffix(".mp4")
|
||||
|
||||
# Wrap primitive to match frame processor signature
|
||||
def process_frame(frame, frame_params, state):
|
||||
# Call primitive with frame and params
|
||||
result = primitive_fn(frame, **frame_params)
|
||||
return result, state
|
||||
|
||||
process_video(
|
||||
input_path=input_path,
|
||||
output_path=actual_output,
|
||||
process_frame=process_frame,
|
||||
params=params,
|
||||
bindings=bindings,
|
||||
)
|
||||
|
||||
logger.info(f"Processed effect '{effect_name}' via Python primitive '{primitive_name}'")
|
||||
return actual_output
|
||||
|
||||
return wrapped_effect
|
||||
|
||||
|
||||
@register_executor("EFFECT")
|
||||
class EffectExecutor(Executor):
|
||||
"""
|
||||
Apply an effect from the registry or IPFS.
|
||||
|
||||
Config:
|
||||
effect: Name of the effect to apply
|
||||
cid: IPFS CID for the effect (fetched from IPFS if not cached)
|
||||
hash: Legacy alias for cid (backwards compatibility)
|
||||
params: Optional parameters for the effect
|
||||
|
||||
Inputs:
|
||||
Single input file to transform
|
||||
"""
|
||||
|
||||
def execute(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
inputs: List[Path],
|
||||
output_path: Path,
|
||||
) -> Path:
|
||||
effect_name = config.get("effect")
|
||||
# Support both "cid" (new) and "hash" (legacy)
|
||||
effect_cid = config.get("cid") or config.get("hash")
|
||||
|
||||
if not effect_name:
|
||||
raise ValueError("EFFECT requires 'effect' config")
|
||||
|
||||
if len(inputs) != 1:
|
||||
raise ValueError(f"EFFECT expects 1 input, got {len(inputs)}")
|
||||
|
||||
# Try IPFS effect first if CID provided
|
||||
effect_fn: Optional[EffectFn] = None
|
||||
if effect_cid:
|
||||
effect_fn = _load_cached_effect(effect_cid)
|
||||
if effect_fn:
|
||||
logger.info(f"Running effect '{effect_name}' (cid={effect_cid[:16]}...)")
|
||||
|
||||
# Try sexp effect from effect_path (.sexp file)
|
||||
if effect_fn is None:
|
||||
effect_path = config.get("effect_path")
|
||||
if effect_path and effect_path.endswith(".sexp"):
|
||||
effect_fn = _get_sexp_effect(effect_path)
|
||||
if effect_fn:
|
||||
logger.info(f"Running effect '{effect_name}' via sexp definition")
|
||||
|
||||
# Try Python primitive (from FFmpegCompiler.EFFECT_MAPPINGS)
|
||||
if effect_fn is None:
|
||||
effect_fn = _get_python_primitive_effect(effect_name)
|
||||
if effect_fn:
|
||||
logger.info(f"Running effect '{effect_name}' via Python primitive")
|
||||
|
||||
# Fall back to built-in effect
|
||||
if effect_fn is None:
|
||||
effect_fn = get_effect(effect_name)
|
||||
|
||||
if effect_fn is None:
|
||||
raise ValueError(f"Unknown effect: {effect_name}")
|
||||
|
||||
# Pass full config (effect can extract what it needs)
|
||||
return effect_fn(inputs[0], output_path, config)
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> List[str]:
|
||||
errors = []
|
||||
if "effect" not in config:
|
||||
errors.append("EFFECT requires 'effect' config")
|
||||
else:
|
||||
# If CID provided, we'll load from IPFS - skip built-in check
|
||||
has_cid = config.get("cid") or config.get("hash")
|
||||
if not has_cid and get_effect(config["effect"]) is None:
|
||||
errors.append(f"Unknown effect: {config['effect']}")
|
||||
return errors
|
||||
50
artdag/nodes/encoding.py
Normal file
50
artdag/nodes/encoding.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# artdag/nodes/encoding.py
|
||||
"""
|
||||
Web-optimized video encoding settings.
|
||||
|
||||
Provides common FFmpeg arguments for producing videos that:
|
||||
- Stream efficiently (faststart)
|
||||
- Play on all browsers (H.264 High profile)
|
||||
- Support seeking (regular keyframes)
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
# Standard web-optimized video encoding arguments
|
||||
WEB_VIDEO_ARGS: List[str] = [
|
||||
"-c:v", "libx264",
|
||||
"-preset", "fast",
|
||||
"-crf", "18",
|
||||
"-profile:v", "high",
|
||||
"-level", "4.1",
|
||||
"-pix_fmt", "yuv420p", # Ensure broad compatibility
|
||||
"-movflags", "+faststart", # Enable streaming before full download
|
||||
"-g", "48", # Keyframe every ~2 seconds at 24fps (for seeking)
|
||||
]
|
||||
|
||||
# Standard audio encoding arguments
|
||||
WEB_AUDIO_ARGS: List[str] = [
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k",
|
||||
]
|
||||
|
||||
|
||||
def get_web_encoding_args() -> List[str]:
|
||||
"""Get FFmpeg args for web-optimized video+audio encoding."""
|
||||
return WEB_VIDEO_ARGS + WEB_AUDIO_ARGS
|
||||
|
||||
|
||||
def get_web_video_args() -> List[str]:
|
||||
"""Get FFmpeg args for web-optimized video encoding only."""
|
||||
return WEB_VIDEO_ARGS.copy()
|
||||
|
||||
|
||||
def get_web_audio_args() -> List[str]:
|
||||
"""Get FFmpeg args for web-optimized audio encoding only."""
|
||||
return WEB_AUDIO_ARGS.copy()
|
||||
|
||||
|
||||
# For shell commands (string format)
|
||||
WEB_VIDEO_ARGS_STR = " ".join(WEB_VIDEO_ARGS)
|
||||
WEB_AUDIO_ARGS_STR = " ".join(WEB_AUDIO_ARGS)
|
||||
WEB_ENCODING_ARGS_STR = f"{WEB_VIDEO_ARGS_STR} {WEB_AUDIO_ARGS_STR}"
|
||||
62
artdag/nodes/source.py
Normal file
62
artdag/nodes/source.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# primitive/nodes/source.py
|
||||
"""
|
||||
Source executors: Load media from paths.
|
||||
|
||||
Primitives: SOURCE
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..dag import NodeType
|
||||
from ..executor import Executor, register_executor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_executor(NodeType.SOURCE)
|
||||
class SourceExecutor(Executor):
|
||||
"""
|
||||
Load source media from a path.
|
||||
|
||||
Config:
|
||||
path: Path to source file
|
||||
|
||||
Creates a symlink to the source file for zero-copy loading.
|
||||
"""
|
||||
|
||||
def execute(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
inputs: List[Path],
|
||||
output_path: Path,
|
||||
) -> Path:
|
||||
source_path = Path(config["path"])
|
||||
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"Source file not found: {source_path}")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Use symlink for zero-copy
|
||||
if output_path.exists() or output_path.is_symlink():
|
||||
output_path.unlink()
|
||||
|
||||
# Preserve extension from source
|
||||
actual_output = output_path.with_suffix(source_path.suffix)
|
||||
if actual_output.exists() or actual_output.is_symlink():
|
||||
actual_output.unlink()
|
||||
|
||||
os.symlink(source_path.resolve(), actual_output)
|
||||
logger.debug(f"SOURCE: {source_path.name} -> {actual_output}")
|
||||
|
||||
return actual_output
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> List[str]:
|
||||
errors = []
|
||||
if "path" not in config:
|
||||
errors.append("SOURCE requires 'path' config")
|
||||
return errors
|
||||
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