Initial commit: video effects processing system
Add S-expression based video effects pipeline with modular effect definitions, constructs, and recipe files. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
223
analyze.py
Normal file
223
analyze.py
Normal file
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Run analyzers from a recipe and output analysis data as S-expressions.
|
||||
|
||||
Usage:
|
||||
analyze.py recipe.sexp [-o analysis.sexp]
|
||||
|
||||
Output format:
|
||||
(analysis
|
||||
(beats-data
|
||||
:tempo 120.5
|
||||
:times (0.0 0.5 1.0 1.5 ...)
|
||||
:duration 10.0)
|
||||
(bass-data
|
||||
:times (0.0 0.1 0.2 ...)
|
||||
:values (0.5 0.8 0.3 ...)))
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import subprocess
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
# Add artdag to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "artdag"))
|
||||
|
||||
from artdag.sexp import compile_string, parse
|
||||
from artdag.sexp.parser import Symbol, Keyword, serialize
|
||||
|
||||
|
||||
def load_analyzer(analyzer_path: Path):
|
||||
"""Load an analyzer module from file path."""
|
||||
spec = importlib.util.spec_from_file_location("analyzer", analyzer_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def run_analyzer(analyzer_path: Path, input_path: Path, params: dict) -> dict:
|
||||
"""Run an analyzer and return results."""
|
||||
analyzer = load_analyzer(analyzer_path)
|
||||
return analyzer.analyze(input_path, params)
|
||||
|
||||
|
||||
def pre_execute_segment(source_path: Path, start: float, duration: float, work_dir: Path) -> Path:
|
||||
"""Pre-execute a segment to get audio for analysis."""
|
||||
suffix = source_path.suffix.lower()
|
||||
is_audio = suffix in ('.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a')
|
||||
|
||||
output_ext = ".m4a" if is_audio else ".mp4"
|
||||
output_path = work_dir / f"segment{output_ext}"
|
||||
|
||||
cmd = ["ffmpeg", "-y", "-i", str(source_path)]
|
||||
if start:
|
||||
cmd.extend(["-ss", str(start)])
|
||||
if duration:
|
||||
cmd.extend(["-t", str(duration)])
|
||||
|
||||
if is_audio:
|
||||
cmd.extend(["-c:a", "aac", str(output_path)])
|
||||
else:
|
||||
cmd.extend(["-c:v", "libx264", "-preset", "fast", "-crf", "18",
|
||||
"-c:a", "aac", str(output_path)])
|
||||
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
return output_path
|
||||
|
||||
|
||||
def to_sexp(value, indent=0):
|
||||
"""Convert a Python value to S-expression string."""
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
return "()"
|
||||
items = []
|
||||
for k, v in value.items():
|
||||
key = k.replace('_', '-')
|
||||
items.append(f":{key} {to_sexp(v)}")
|
||||
return "(" + " ".join(items) + ")"
|
||||
elif isinstance(value, list):
|
||||
if not value:
|
||||
return "()"
|
||||
items = [to_sexp(v) for v in value]
|
||||
return "(" + " ".join(items) + ")"
|
||||
elif isinstance(value, str):
|
||||
return f'"{value}"'
|
||||
elif isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
elif value is None:
|
||||
return "nil"
|
||||
elif isinstance(value, float):
|
||||
return f"{value:.6g}"
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
|
||||
def analyze_recipe(recipe_path: Path, output_file: Path = None):
|
||||
"""Run all analyzers in a recipe and output S-expression analysis data."""
|
||||
|
||||
recipe_text = recipe_path.read_text()
|
||||
recipe_dir = recipe_path.parent
|
||||
|
||||
print(f"Compiling: {recipe_path}", file=sys.stderr)
|
||||
compiled = compile_string(recipe_text)
|
||||
print(f"Recipe: {compiled.name} v{compiled.version}", file=sys.stderr)
|
||||
|
||||
# Find all ANALYZE nodes and their dependencies
|
||||
nodes_by_id = {n["id"]: n for n in compiled.nodes}
|
||||
|
||||
# Track source paths and segment outputs
|
||||
source_paths = {}
|
||||
segment_outputs = {}
|
||||
analysis_results = {}
|
||||
|
||||
work_dir = Path(tempfile.mkdtemp(prefix="artdag_analyze_"))
|
||||
|
||||
# Process nodes in dependency order
|
||||
def get_input_path(node_id: str) -> Path:
|
||||
"""Resolve the input path for a node."""
|
||||
if node_id in segment_outputs:
|
||||
return segment_outputs[node_id]
|
||||
if node_id in source_paths:
|
||||
return source_paths[node_id]
|
||||
|
||||
node = nodes_by_id.get(node_id)
|
||||
if not node:
|
||||
return None
|
||||
|
||||
if node["type"] == "SOURCE":
|
||||
path = recipe_dir / node["config"].get("path", "")
|
||||
source_paths[node_id] = path.resolve()
|
||||
return source_paths[node_id]
|
||||
|
||||
if node["type"] == "SEGMENT":
|
||||
inputs = node.get("inputs", [])
|
||||
if inputs:
|
||||
input_path = get_input_path(inputs[0])
|
||||
if input_path:
|
||||
config = node.get("config", {})
|
||||
start = config.get("start", 0)
|
||||
duration = config.get("duration")
|
||||
output = pre_execute_segment(input_path, start, duration, work_dir)
|
||||
segment_outputs[node_id] = output
|
||||
return output
|
||||
|
||||
return None
|
||||
|
||||
# Find and run all analyzers
|
||||
for node in compiled.nodes:
|
||||
if node["type"] == "ANALYZE":
|
||||
config = node.get("config", {})
|
||||
analyzer_name = config.get("analyzer", "unknown")
|
||||
analyzer_path = config.get("analyzer_path")
|
||||
|
||||
if not analyzer_path:
|
||||
print(f" Skipping {analyzer_name}: no path", file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Get input
|
||||
inputs = node.get("inputs", [])
|
||||
if not inputs:
|
||||
print(f" Skipping {analyzer_name}: no inputs", file=sys.stderr)
|
||||
continue
|
||||
|
||||
input_path = get_input_path(inputs[0])
|
||||
if not input_path or not input_path.exists():
|
||||
print(f" Skipping {analyzer_name}: input not found", file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Run analyzer
|
||||
full_path = recipe_dir / analyzer_path
|
||||
params = {k: v for k, v in config.items()
|
||||
if k not in ("analyzer", "analyzer_path", "cid")}
|
||||
|
||||
print(f" Running analyzer: {analyzer_name}", file=sys.stderr)
|
||||
results = run_analyzer(full_path, input_path, params)
|
||||
|
||||
# Store by node ID for uniqueness (multiple analyzers may have same type)
|
||||
node_id = node.get("id")
|
||||
analysis_results[node_id] = results
|
||||
|
||||
times = results.get("times", [])
|
||||
print(f" {len(times)} times @ {results.get('tempo', 0):.1f} BPM", file=sys.stderr)
|
||||
|
||||
# Generate S-expression output
|
||||
lines = ["(analysis"]
|
||||
|
||||
for name, data in analysis_results.items():
|
||||
# Quote node IDs to prevent parser treating hex like "0e42..." as scientific notation
|
||||
lines.append(f' ("{name}"')
|
||||
for key, value in data.items():
|
||||
sexp_key = key.replace('_', '-')
|
||||
sexp_value = to_sexp(value)
|
||||
lines.append(f" :{sexp_key} {sexp_value}")
|
||||
lines.append(" )")
|
||||
|
||||
lines.append(")")
|
||||
|
||||
output = "\n".join(lines)
|
||||
|
||||
if output_file:
|
||||
output_file.write_text(output)
|
||||
print(f"\nAnalysis written to: {output_file}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
print(f"Debug: temp files in {work_dir}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run analyzers from recipe")
|
||||
parser.add_argument("recipe", type=Path, help="Recipe file (.sexp)")
|
||||
parser.add_argument("-o", "--output", type=Path, help="Output file (default: stdout)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.recipe.exists():
|
||||
print(f"Recipe not found: {args.recipe}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
analyze_recipe(args.recipe, args.output)
|
||||
Reference in New Issue
Block a user