Squashed 'core/' content from commit 4957443

git-subtree-dir: core
git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
This commit is contained in:
giles
2026-02-24 23:09:39 +00:00
commit cc2dcbddd4
80 changed files with 25711 additions and 0 deletions

11
artdag/nodes/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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