diff --git a/scripts/append_signals.py b/scripts/append_signals.py new file mode 100644 index 0000000..d05767a --- /dev/null +++ b/scripts/append_signals.py @@ -0,0 +1,248 @@ +#!/usr/bin/env -S uv run +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// + +""" +Concatenate a sequence of frames into a video using ffmpeg. +Scales to 1920 width and allows control of video speed via fps. +""" + +import argparse +import subprocess +import sys +import time +from pathlib import Path +from typing import List + + +def find_frames(directory: Path, pattern: str = "*.jpeg") -> List[Path]: + """Find all frame files matching the pattern, sorted by name.""" + frames = sorted(directory.glob(pattern)) + if not frames: + frames = sorted(directory.glob("*.jpg")) + if not frames: + frames = sorted(directory.glob("*.png")) + return frames + + +def create_video_ffmpeg( + input_dir: Path, + output_file: Path, + fps: int = 30, + width: int = 1920, + crf: int = 18, + pattern: str = "*.jpeg", + max_frames: int = None +) -> None: + """ + Create video from image sequence using ffmpeg. + + Args: + input_dir: Directory containing the frames + output_file: Output video file path + fps: Frames per second (controls playback speed) + width: Output video width in pixels (height auto-scales) + crf: Constant Rate Factor for quality (lower = better, 18-23 recommended) + pattern: File pattern to match frame files + """ + frames = find_frames(input_dir, pattern) + + if not frames: + print(f"Error: No frames found in {input_dir} matching pattern {pattern}") + sys.exit(1) + + total_frames = len(frames) + + # Limit frames if max_frames is specified + if max_frames is not None and max_frames > 0: + frames = frames[:max_frames] + print(f"Found {total_frames} frames in {input_dir}, processing first {len(frames)} frames") + else: + print(f"Found {len(frames)} frames in {input_dir}") + print(f"First frame: {frames[0].name}") + print(f"Last frame: {frames[-1].name}") + + # Check if frames are sequential (no gaps) + first_frame = frames[0] + is_sequential = True + + # Extract frame numbers and check for gaps + name_parts = first_frame.stem.rsplit('_', 1) + if len(name_parts) == 2 and name_parts[1].isdigit(): + frame_numbers = [] + for frame in frames: + parts = frame.stem.rsplit('_', 1) + if len(parts) == 2 and parts[1].isdigit(): + frame_numbers.append(int(parts[1])) + else: + is_sequential = False + break + + # Check if there are gaps in the sequence + if is_sequential and frame_numbers: + expected_count = frame_numbers[-1] - frame_numbers[0] + 1 + if len(frame_numbers) != expected_count: + is_sequential = False + print(f"Warning: Detected gaps in frame sequence (have {len(frame_numbers)} frames, expected {expected_count})") + else: + is_sequential = False + + # Use concat method for reliability (works with gaps, non-sequential naming, etc.) + # Create concat file + concat_file = input_dir / "concat_list.txt" + with open(concat_file, 'w') as f: + for frame in frames: + # Use absolute paths to avoid issues + f.write(f"file '{frame.absolute()}'\n") + + cmd = [ + "ffmpeg", + "-y", + "-f", "concat", + "-safe", "0", + "-i", str(concat_file), + "-vf", f"scale={width}:-2,fps={fps}", + "-c:v", "libx264", + "-crf", str(crf), + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + str(output_file) + ] + + print(f"\nCreating video with:") + print(f" FPS: {fps}") + print(f" Width: {width}px") + print(f" CRF (quality): {crf}") + print(f" Output: {output_file}") + print(f"\nRunning: {' '.join(cmd)}\n") + + try: + result = subprocess.run(cmd, check=True) + print(f"\n✓ Video created successfully: {output_file}") + + # Show file size + size_mb = output_file.stat().st_size / (1024 * 1024) + print(f" File size: {size_mb:.2f} MB") + + except subprocess.CalledProcessError as e: + print(f"Error: ffmpeg command failed with return code {e.returncode}") + sys.exit(1) + except FileNotFoundError: + print("Error: ffmpeg not found. Please install ffmpeg and ensure it's in your PATH.") + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser( + description="Concatenate frame sequences into a video using ffmpeg", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Basic usage with default settings (30 fps, 1920 width) + python append_signals.py C:\\dev\\gst-plugins-vision\\results\\20251122\\bumpy-filter + + # Slow motion (10 fps) + python append_signals.py C:\\dev\\gst-plugins-vision\\results\\20251122\\bumpy-filter --fps 10 + + # Fast playback (60 fps) with custom output name + python append_signals.py results/20251122/bumpy-filter --fps 60 -o timelapse.mp4 + + # Higher quality (lower CRF) + python append_signals.py results/20251122/bumpy-filter --crf 15 + + # Custom width + python append_signals.py results/20251122/bumpy-filter --width 3840 + + # Process only first 100 frames for testing + python append_signals.py results/20251122/bumpy-filter --max-frames 100 + """ + ) + + parser.add_argument( + "input_dir", + type=Path, + help="Directory containing the frame sequence" + ) + + parser.add_argument( + "-o", "--output", + type=Path, + help="Output video file (default: output.mp4 in input directory)" + ) + + parser.add_argument( + "--fps", + type=int, + default=30, + help="Output video framerate/playback speed (default: 30)" + ) + + parser.add_argument( + "--width", + type=int, + default=1920, + help="Output video width in pixels (default: 1920)" + ) + + parser.add_argument( + "--crf", + type=int, + default=18, + help="Constant Rate Factor for quality, lower=better (default: 18, range: 0-51)" + ) + + parser.add_argument( + "--pattern", + type=str, + default="*.jpeg", + help="File pattern to match frames (default: *.jpeg)" + ) + + parser.add_argument( + "--max-frames", + type=int, + default=None, + help="Maximum number of frames to process (default: all frames)" + ) + + args = parser.parse_args() + + # Validate input directory + if not args.input_dir.exists(): + print(f"Error: Input directory does not exist: {args.input_dir}") + sys.exit(1) + + if not args.input_dir.is_dir(): + print(f"Error: Input path is not a directory: {args.input_dir}") + sys.exit(1) + + # Set output file + if args.output is None: + # Generate filename: input_folder_name_unixtime.mp4 + unix_time = int(time.time()) + folder_name = args.input_dir.name + output_filename = f"{folder_name}_{unix_time}.mp4" + output_file = args.input_dir / output_filename + else: + output_file = args.output + + # Validate CRF range + if not 0 <= args.crf <= 51: + print("Error: CRF must be between 0 and 51") + sys.exit(1) + + create_video_ffmpeg( + input_dir=args.input_dir, + output_file=output_file, + fps=args.fps, + width=args.width, + crf=args.crf, + pattern=args.pattern, + max_frames=args.max_frames + ) + + +if __name__ == "__main__": + main() \ No newline at end of file