$ cat node-template.py

Video Concatenation

// Concatenates multiple video files into a single video using FFmpeg. Normalizes all inputs to a common resolution, framerate, and codec before concatenation. Connect Video outputs directly to the 'Videos' port (Video[]) to ensure files are downloaded. Optionally connect a Make Array to 'Video Order' to control concatenation order. Outputs the concatenated video file.

Process
Video
template.py
1import os2import sys3import json4import subprocess5import traceback67INPUT_DIR = "/data/input"8OUTPUT_DIR = "/data/output"9FFMPEG_IMAGE = "jrottenberg/ffmpeg:7-ubuntu"101112def parse_resolution(res_str):13    """Parse 'WxH' string to (int, int)."""14    w, h = res_str.split("x")15    return int(w), int(h)161718def build_vf_filter(w, h, fit_mode):19    """Return the -vf filter string for the given resolution and fit mode."""20    if fit_mode == "pad":21        return (22            f"scale={w}:{h}:force_original_aspect_ratio=decrease,"23            f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:black,setsar=1"24        )25    # Default: crop26    return (27        f"scale={w}:{h}:force_original_aspect_ratio=increase,"28        f"crop={w}:{h},setsar=1"29    )303132def apply_video_order(videos, video_order):33    """Apply ordering from Make Array to the video list."""34    if video_order and isinstance(video_order, list):35        base_to_downloaded = {}36        for v in videos:37            parts = v.split("_", 1)38            base = parts[1] if len(parts) == 2 and parts[0].isdigit() else v39            base_to_downloaded[base] = v4041        ordered = []42        for item in video_order:43            name = str(item).strip()44            if name in base_to_downloaded:45                ordered.append(base_to_downloaded.pop(name))46        ordered.extend(base_to_downloaded.values())47        return ordered4849    return sorted(50        videos,51        key=lambda x: int(x.split("_")[0]) if x.split("_")[0].isdigit() else float("inf"),52    )535455def build_shell_script(ordered_videos, w, h, fps, fit_mode):56    """Generate the full sh script for normalize + concat."""57    vf = build_vf_filter(w, h, fit_mode)58    lines = ["set -e", ""]5960    # Phase 1: Normalize each video61    concat_entries = []62    for i, video in enumerate(ordered_videos):63        src = f'/data/input/{video}'64        norm = f'/data/output/norm_{i}.mp4'65        concat_entries.append(norm)6667        # Detect audio68        lines.append(f"HAS_AUDIO_{i}=$(ffprobe -v quiet -select_streams a -show_entries stream=codec_type -of csv=p=0 '{src}' | head -1)")69        lines.append(f'if [ -n "$HAS_AUDIO_{i}" ]; then')70        # Has audio: straightforward normalize71        lines.append(72            f"  ffmpeg -i '{src}' -vf '{vf}' -r {fps} "73            f"-c:v libx264 -preset medium -crf 23 -pix_fmt yuv420p "74            f"-c:a aac -ar 44100 -ac 2 -b:a 192k "75            f"-movflags +faststart -y '{norm}'"76        )77        lines.append("else")78        # No audio: inject silent track79        lines.append(80            f"  ffmpeg -i '{src}' -f lavfi -i anullsrc=r=44100:cl=stereo "81            f"-vf '{vf}' -r {fps} "82            f"-c:v libx264 -preset medium -crf 23 -pix_fmt yuv420p "83            f"-c:a aac -ar 44100 -ac 2 -b:a 192k "84            f"-map 0:v:0 -map 1:a:0 -shortest "85            f"-movflags +faststart -y '{norm}'"86        )87        lines.append("fi")88        lines.append("")8990    # Phase 2: Build concat list and concatenate91    lines.append("cat > /data/output/concat_list.txt << 'CONCATEOF'")92    for entry in concat_entries:93        lines.append(f"file '{entry}'")94    lines.append("CONCATEOF")95    lines.append("")96    lines.append(97        "ffmpeg -f concat -safe 0 -i /data/output/concat_list.txt "98        "-c copy -movflags +faststart -y /data/output/concatenated.mp4"99    )100    lines.append("")101102    # Phase 3: Cleanup103    lines.append("rm -f /data/output/norm_*.mp4 /data/output/concat_list.txt")104105    return "\n".join(lines)106107108def main():109    execution_input = json.loads(sys.stdin.read())110    inputs = execution_input.get("inputs", {})111112    videos = inputs.get("videos", [])113    if isinstance(videos, str):114        videos = [videos]115116    video_order = inputs.get("video_order", None)117    if isinstance(video_order, str):118        video_order = [video_order]119120    if not videos:121        raise ValueError("Videos input is required - connect at least one Video output")122123    resolution = inputs.get("resolution", "1920x1080")124    fit_mode = inputs.get("fit_mode", "crop")125    fps = int(inputs.get("framerate", 25))126127    w, h = parse_resolution(resolution)128    ordered_videos = apply_video_order(videos, video_order)129130    # Validate all input files exist131    for v in ordered_videos:132        path = os.path.join(INPUT_DIR, v)133        if not os.path.exists(path):134            raise FileNotFoundError(f"Input video not found: {path}")135136    os.makedirs(OUTPUT_DIR, exist_ok=True)137138    shell_script = build_shell_script(ordered_videos, w, h, fps, fit_mode)139140    host_input = os.environ.get("HOST_STAGING_INPUT", INPUT_DIR)141    host_output = os.environ.get("HOST_STAGING_OUTPUT", OUTPUT_DIR)142143    cmd = [144        "docker", "run", "--rm",145        "--network", "none",146        "--memory", "4g",147        "--cpus", "4.0",148        "-v", f"{host_input}:/data/input:ro",149        "-v", f"{host_output}:/data/output:rw",150        "--entrypoint", "sh",151        FFMPEG_IMAGE,152        "-c", shell_script,153    ]154155    print(f"Launching ffmpeg container: videos={len(ordered_videos)}, resolution={w}x{h}, fps={fps}, fit={fit_mode}", file=sys.stderr)156    result = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)157    if result.returncode != 0:158        raise RuntimeError(f"ffmpeg failed (exit {result.returncode}): {result.stderr[-2000:]}")159160    print(json.dumps({"video": "concatenated.mp4"}, indent=2))161162163if __name__ == "__main__":164    try:165        main()166    except Exception as e:167        print(json.dumps({168            "error": str(e),169            "errorType": type(e).__name__,170            "traceback": traceback.format_exc(),171        }), file=sys.stderr)172        sys.exit(1)