"""Export a silent reaction with a fixed resting frame at both loop ends. python3 tools/art/build-statue-loop.py SOURCE.mp4 NEUTRAL.png OUTPUT.mp4 Needs FFmpeg and FFprobe. The original source remains unchanged. """ import argparse import json import subprocess from pathlib import Path def run(args, **options): return subprocess.run(args, check=True, **options) def main(): parser = argparse.ArgumentParser() parser.add_argument("source", type=Path) parser.add_argument("neutral", type=Path) parser.add_argument("output", type=Path) args = parser.parse_args() args.output.parent.mkdir(parents=True, exist_ok=True) width, height, rate, action_frames = 432, 768, 24, 120 # Blend only the first and last quarter-second. Review the raw acting first. filters = ( f"[0:v]fps={rate},scale={width}:{height}:flags=lanczos,setsar=1," f"trim=end_frame={action_frames},setpts=PTS-STARTPTS,format=rgba," "fade=t=in:st=0:d=0.25:alpha=1,fade=t=out:st=4.70:d=0.25:alpha=1[acting];" f"[1:v]fps={rate},scale={width}:{height}:flags=lanczos,setsar=1," "trim=duration=5,setpts=PTS-STARTPTS,format=rgba[rest];" "[rest][acting]overlay=shortest=1:format=auto,format=yuv420p," "tpad=start_mode=clone:start_duration=1:stop_mode=clone:stop_duration=1[out]" ) run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(args.source), "-loop", "1", "-i", str(args.neutral), "-filter_complex", filters, "-map", "[out]", "-an", "-c:v", "libx264", "-crf", "18", "-preset", "slow", "-movflags", "+faststart", "-frames:v", "168", str(args.output)]) run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(args.output), "-f", "null", "-"]) probe = json.loads(run(["ffprobe", "-v", "error", "-count_frames", "-show_streams", "-show_format", "-of", "json", str(args.output)], capture_output=True).stdout) video = next(s for s in probe["streams"] if s["codec_type"] == "video") assert int(video["nb_read_frames"]) == 168, "The loop has an unexpected frame count." assert not any(s["codec_type"] == "audio" for s in probe["streams"]), "The loop contains audio." endpoints = run(["ffmpeg", "-v", "error", "-i", str(args.output), "-vf", "select='eq(n,0)+eq(n,167)'", "-fps_mode", "passthrough", "-pix_fmt", "rgb24", "-f", "rawvideo", "-"], capture_output=True).stdout size = width * height * 3 assert len(endpoints) == size * 2, "The endpoint decode is incomplete." first, last = endpoints[:size], endpoints[size:] difference = sum(abs(a - b) for a, b in zip(first, last)) / size report = { "source": str(args.source), "neutral": str(args.neutral), "output": str(args.output), "width": width, "height": height, "frames": 168, "frameRate": rate, "durationSeconds": 7, "bytes": args.output.stat().st_size, "audioStreams": 0, "allFramesDecoded": True, "endpointMeanChannelDifference255": difference, "timing": "One-second rest, five-second source, one-second rest. Quarter-second endpoint blends.", "limit": "The video animates pixels from the mesh render. It does not add bones or actions to the GLB.", } args.output.with_suffix(".qa.json").write_text(json.dumps(report, indent=2) + "\n") print(json.dumps(report, indent=2)) main()