$ 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)