Squashed 'core/' content from commit 4957443
git-subtree-dir: core git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
This commit is contained in:
292
artdag/sexp/external_tools.py
Normal file
292
artdag/sexp/external_tools.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
External tool runners for effects that can't be done in FFmpeg.
|
||||
|
||||
Supports:
|
||||
- datamosh: via ffglitch or datamoshing Python CLI
|
||||
- pixelsort: via Rust pixelsort or Python pixelsort-cli
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
def find_tool(tool_names: List[str]) -> Optional[str]:
|
||||
"""Find first available tool from a list of candidates."""
|
||||
for name in tool_names:
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def check_python_package(package: str) -> bool:
|
||||
"""Check if a Python package is installed."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3", "-c", f"import {package}"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Tool detection
|
||||
DATAMOSH_TOOLS = ["ffgac", "ffedit"] # ffglitch tools
|
||||
PIXELSORT_TOOLS = ["pixelsort"] # Rust CLI
|
||||
|
||||
|
||||
def get_available_tools() -> Dict[str, Optional[str]]:
|
||||
"""Get dictionary of available external tools."""
|
||||
return {
|
||||
"datamosh": find_tool(DATAMOSH_TOOLS),
|
||||
"pixelsort": find_tool(PIXELSORT_TOOLS),
|
||||
"datamosh_python": "datamoshing" if check_python_package("datamoshing") else None,
|
||||
"pixelsort_python": "pixelsort" if check_python_package("pixelsort") else None,
|
||||
}
|
||||
|
||||
|
||||
def run_datamosh(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
params: Dict[str, Any],
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Run datamosh effect using available tool.
|
||||
|
||||
Args:
|
||||
input_path: Input video file
|
||||
output_path: Output video file
|
||||
params: Effect parameters (corruption, block_size, etc.)
|
||||
|
||||
Returns:
|
||||
(success, error_message)
|
||||
"""
|
||||
tools = get_available_tools()
|
||||
|
||||
corruption = params.get("corruption", 0.3)
|
||||
|
||||
# Try ffglitch first
|
||||
if tools.get("datamosh"):
|
||||
ffgac = tools["datamosh"]
|
||||
try:
|
||||
# ffglitch approach: remove I-frames to create datamosh effect
|
||||
# This is a simplified version - full datamosh needs custom scripts
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) as f:
|
||||
# Write a simple ffglitch script that corrupts motion vectors
|
||||
f.write(f"""
|
||||
// Datamosh script - corrupt motion vectors
|
||||
let corruption = {corruption};
|
||||
|
||||
export function glitch_frame(frame, stream) {{
|
||||
if (frame.pict_type === 'P' && Math.random() < corruption) {{
|
||||
// Corrupt motion vectors
|
||||
let dominated = frame.mv?.forward?.overflow;
|
||||
if (dominated) {{
|
||||
for (let i = 0; i < dominated.length; i++) {{
|
||||
if (Math.random() < corruption) {{
|
||||
dominated[i] = [
|
||||
Math.floor(Math.random() * 64 - 32),
|
||||
Math.floor(Math.random() * 64 - 32)
|
||||
];
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
return frame;
|
||||
}}
|
||||
""")
|
||||
script_path = f.name
|
||||
|
||||
cmd = [
|
||||
ffgac,
|
||||
"-i", str(input_path),
|
||||
"-s", script_path,
|
||||
"-o", str(output_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
Path(script_path).unlink(missing_ok=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, ""
|
||||
return False, result.stderr[:500]
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Datamosh timeout"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
# Fall back to Python datamoshing package
|
||||
if tools.get("datamosh_python"):
|
||||
try:
|
||||
cmd = [
|
||||
"python3", "-m", "datamoshing",
|
||||
str(input_path),
|
||||
str(output_path),
|
||||
"--mode", "iframe_removal",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
if result.returncode == 0:
|
||||
return True, ""
|
||||
return False, result.stderr[:500]
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
return False, "No datamosh tool available. Install ffglitch or: pip install datamoshing"
|
||||
|
||||
|
||||
def run_pixelsort(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
params: Dict[str, Any],
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Run pixelsort effect using available tool.
|
||||
|
||||
Args:
|
||||
input_path: Input image/frame file
|
||||
output_path: Output image file
|
||||
params: Effect parameters (sort_by, threshold_low, threshold_high, angle)
|
||||
|
||||
Returns:
|
||||
(success, error_message)
|
||||
"""
|
||||
tools = get_available_tools()
|
||||
|
||||
sort_by = params.get("sort_by", "lightness")
|
||||
threshold_low = params.get("threshold_low", 50)
|
||||
threshold_high = params.get("threshold_high", 200)
|
||||
angle = params.get("angle", 0)
|
||||
|
||||
# Try Rust pixelsort first (faster)
|
||||
if tools.get("pixelsort"):
|
||||
try:
|
||||
cmd = [
|
||||
tools["pixelsort"],
|
||||
str(input_path),
|
||||
"-o", str(output_path),
|
||||
"--sort", sort_by,
|
||||
"-r", str(angle),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
if result.returncode == 0:
|
||||
return True, ""
|
||||
return False, result.stderr[:500]
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
# Fall back to Python pixelsort-cli
|
||||
if tools.get("pixelsort_python"):
|
||||
try:
|
||||
cmd = [
|
||||
"python3", "-m", "pixelsort",
|
||||
"--image_path", str(input_path),
|
||||
"--output", str(output_path),
|
||||
"--angle", str(angle),
|
||||
"--threshold", str(threshold_low / 255), # Normalize to 0-1
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
if result.returncode == 0:
|
||||
return True, ""
|
||||
return False, result.stderr[:500]
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
return False, "No pixelsort tool available. Install: cargo install pixelsort or pip install pixelsort-cli"
|
||||
|
||||
|
||||
def run_pixelsort_video(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
params: Dict[str, Any],
|
||||
fps: float = 30,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Run pixelsort on a video by processing each frame.
|
||||
|
||||
This extracts frames, processes them, then reassembles.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
frames_in = tmpdir / "frame_%04d.png"
|
||||
frames_out = tmpdir / "sorted_%04d.png"
|
||||
|
||||
# Extract frames
|
||||
extract_cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(input_path),
|
||||
"-vf", f"fps={fps}",
|
||||
str(frames_in),
|
||||
]
|
||||
result = subprocess.run(extract_cmd, capture_output=True, timeout=300)
|
||||
if result.returncode != 0:
|
||||
return False, "Failed to extract frames"
|
||||
|
||||
# Process each frame
|
||||
frame_files = sorted(tmpdir.glob("frame_*.png"))
|
||||
for i, frame in enumerate(frame_files):
|
||||
out_frame = tmpdir / f"sorted_{i:04d}.png"
|
||||
success, error = run_pixelsort(frame, out_frame, params)
|
||||
if not success:
|
||||
return False, f"Frame {i}: {error}"
|
||||
|
||||
# Reassemble
|
||||
# Get audio from original
|
||||
reassemble_cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-framerate", str(fps),
|
||||
"-i", str(tmpdir / "sorted_%04d.png"),
|
||||
"-i", str(input_path),
|
||||
"-map", "0:v", "-map", "1:a?",
|
||||
"-c:v", "libx264", "-preset", "fast",
|
||||
"-c:a", "copy",
|
||||
str(output_path),
|
||||
]
|
||||
result = subprocess.run(reassemble_cmd, capture_output=True, timeout=300)
|
||||
if result.returncode != 0:
|
||||
return False, "Failed to reassemble video"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def run_external_effect(
|
||||
effect_name: str,
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
params: Dict[str, Any],
|
||||
is_video: bool = True,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Run an external effect tool.
|
||||
|
||||
Args:
|
||||
effect_name: Name of effect (datamosh, pixelsort)
|
||||
input_path: Input file
|
||||
output_path: Output file
|
||||
params: Effect parameters
|
||||
is_video: Whether input is video (vs single image)
|
||||
|
||||
Returns:
|
||||
(success, error_message)
|
||||
"""
|
||||
if effect_name == "datamosh":
|
||||
return run_datamosh(input_path, output_path, params)
|
||||
elif effect_name == "pixelsort":
|
||||
if is_video:
|
||||
return run_pixelsort_video(input_path, output_path, params)
|
||||
else:
|
||||
return run_pixelsort(input_path, output_path, params)
|
||||
else:
|
||||
return False, f"Unknown external effect: {effect_name}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Print available tools
|
||||
print("Available external tools:")
|
||||
for name, path in get_available_tools().items():
|
||||
status = path if path else "NOT INSTALLED"
|
||||
print(f" {name}: {status}")
|
||||
Reference in New Issue
Block a user