Add BIM-VFI Concat Videos node for joining segment outputs

Adds a new node that concatenates segment video files (produced by
VHS Video Combine) into a single video using ffmpeg's concat demuxer
with -c copy (no re-encoding). The model input acts as a sequencing
signal to ensure all segments finish before concatenation begins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 21:06:52 +01:00
parent 7cf7162143
commit 98c558b1b0
2 changed files with 134 additions and 7 deletions

View File

@@ -30,16 +30,18 @@ def _auto_install_deps():
_auto_install_deps() _auto_install_deps()
from .nodes import LoadBIMVFIModel, BIMVFIInterpolate, BIMVFISegmentInterpolate from .nodes import LoadBIMVFIModel, BIMVFIInterpolate, BIMVFISegmentInterpolate, BIMVFIConcatVideos
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"LoadBIMVFIModel": LoadBIMVFIModel, "LoadBIMVFIModel": LoadBIMVFIModel,
"BIMVFIInterpolate": BIMVFIInterpolate, "BIMVFIInterpolate": BIMVFIInterpolate,
"BIMVFISegmentInterpolate": BIMVFISegmentInterpolate, "BIMVFISegmentInterpolate": BIMVFISegmentInterpolate,
"BIMVFIConcatVideos": BIMVFIConcatVideos,
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"LoadBIMVFIModel": "Load BIM-VFI Model", "LoadBIMVFIModel": "Load BIM-VFI Model",
"BIMVFIInterpolate": "BIM-VFI Interpolate", "BIMVFIInterpolate": "BIM-VFI Interpolate",
"BIMVFISegmentInterpolate": "BIM-VFI Segment Interpolate", "BIMVFISegmentInterpolate": "BIM-VFI Segment Interpolate",
"BIMVFIConcatVideos": "BIM-VFI Concat Videos",
} }

137
nodes.py
View File

@@ -1,5 +1,9 @@
import os import os
import glob
import logging import logging
import shutil
import subprocess
import tempfile
import torch import torch
import folder_paths import folder_paths
from comfy.utils import ProgressBar from comfy.utils import ProgressBar
@@ -125,7 +129,7 @@ class BIMVFIInterpolate:
}), }),
"chunk_size": ("INT", { "chunk_size": ("INT", {
"default": 0, "min": 0, "max": 10000, "step": 1, "default": 0, "min": 0, "max": 10000, "step": 1,
"tooltip": "Process input frames in chunks of this size (0=disabled). Each chunk runs all interpolation passes independently then results are stitched seamlessly. Use for very long videos (1000+ frames) to bound memory. Result is identical to processing all at once.", "tooltip": "Process input frames in chunks of this size (0=disabled). Bounds VRAM usage during processing but the full output is still assembled in RAM. To bound RAM, use the Segment Interpolate node instead. Result is identical to processing all at once.",
}), }),
} }
} }
@@ -276,14 +280,14 @@ class BIMVFISegmentInterpolate(BIMVFIInterpolate):
base = BIMVFIInterpolate.INPUT_TYPES() base = BIMVFIInterpolate.INPUT_TYPES()
base["required"]["segment_index"] = ("INT", { base["required"]["segment_index"] = ("INT", {
"default": 0, "min": 0, "max": 10000, "step": 1, "default": 0, "min": 0, "max": 10000, "step": 1,
"tooltip": "Which segment to process (0-based). " "tooltip": "Which segment to process (0-based). Bounds RAM by only producing this segment's output frames, "
"Segments overlap by 1 frame for seamless stitching. " "unlike chunk_size which bounds VRAM but still assembles the full output in RAM. "
"Connect the model output to the next Segment Interpolate's model input to chain execution.", "Chain the model output to the next Segment Interpolate to force sequential execution.",
}) })
base["required"]["segment_size"] = ("INT", { base["required"]["segment_size"] = ("INT", {
"default": 500, "min": 2, "max": 10000, "step": 1, "default": 500, "min": 2, "max": 10000, "step": 1,
"tooltip": "Number of input frames per segment. Adjacent segments overlap by 1 frame. " "tooltip": "Number of input frames per segment. Adjacent segments overlap by 1 frame for seamless stitching. "
"Output is identical to processing all frames at once with BIM-VFI Interpolate.", "Smaller = less peak RAM per segment. Save each segment's output to disk before the next runs.",
}) })
return base return base
@@ -318,3 +322,124 @@ class BIMVFISegmentInterpolate(BIMVFIInterpolate):
result = result[1:] # skip duplicate boundary frame result = result[1:] # skip duplicate boundary frame
return (result, model) return (result, model)
class BIMVFIConcatVideos:
"""Concatenate segment video files into a single video using ffmpeg.
Connect the model output from the last Segment Interpolate node to ensure
this runs only after all segments have been saved to disk.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("BIM_VFI_MODEL", {
"tooltip": "Connect from the last Segment Interpolate's model output. "
"This ensures concatenation runs only after all segments are saved.",
}),
"output_directory": ("STRING", {
"default": "",
"tooltip": "Directory containing the segment video files. "
"Leave empty to use ComfyUI's default output directory.",
}),
"filename_prefix": ("STRING", {
"default": "segment",
"tooltip": "Filename prefix used when saving segments with VHS Video Combine. "
"Matches files like segment_00001.mp4, segment_00002.mp4, etc.",
}),
"output_filename": ("STRING", {
"default": "final_video.mp4",
"tooltip": "Name of the concatenated output file. Saved in the same directory.",
}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("video_path",)
OUTPUT_NODE = True
FUNCTION = "concat"
CATEGORY = "video/BIM-VFI"
@staticmethod
def _find_ffmpeg():
ffmpeg_path = shutil.which("ffmpeg")
if ffmpeg_path is None:
try:
from imageio_ffmpeg import get_ffmpeg_exe
ffmpeg_path = get_ffmpeg_exe()
except ImportError:
pass
if ffmpeg_path is None:
raise RuntimeError(
"ffmpeg not found. Install ffmpeg or pip install imageio-ffmpeg."
)
return ffmpeg_path
def concat(self, model, output_directory, filename_prefix, output_filename):
# Resolve output directory
out_dir = output_directory.strip()
if not out_dir:
out_dir = folder_paths.get_output_directory()
if not os.path.isdir(out_dir):
raise ValueError(f"Output directory does not exist: {out_dir}")
# Find segment files matching the prefix
safe_prefix = glob.escape(filename_prefix)
segments = []
for ext in ("mp4", "webm", "mkv"):
segments.extend(
glob.glob(os.path.join(out_dir, f"{safe_prefix}_*.{ext}"))
)
segments.sort()
if not segments:
raise FileNotFoundError(
f"No segment files found matching '{filename_prefix}_*' "
f"in {out_dir}"
)
logger.info(f"Found {len(segments)} segment(s) to concatenate")
# Write ffmpeg concat list to a temp file
fd, concat_list_path = tempfile.mkstemp(suffix=".txt", prefix="bimvfi_concat_")
try:
with os.fdopen(fd, "w") as f:
f.write("ffconcat version 1.0\n")
for seg in segments:
# ffconcat escaping: \ -> \\, ' -> \'
escaped = os.path.abspath(seg).replace("\\", "\\\\").replace("'", "\\'")
f.write(f"file '{escaped}'\n")
output_path = os.path.join(out_dir, output_filename)
ffmpeg = self._find_ffmpeg()
cmd = [
ffmpeg,
"-y",
"-f", "concat",
"-safe", "0",
"-i", concat_list_path,
"-c", "copy",
output_path,
]
logger.info(f"Running: {' '.join(cmd)}")
result = subprocess.run(
cmd, capture_output=True, text=True, check=False
)
if result.returncode != 0:
raise RuntimeError(
f"ffmpeg concat failed (exit {result.returncode}):\n"
f"{result.stderr}"
)
logger.info(f"Concatenated video saved to {output_path}")
finally:
if os.path.exists(concat_list_path):
os.remove(concat_list_path)
return (output_path,)