From d01ca239ceffccfca46ca95816809733b29ac845 Mon Sep 17 00:00:00 2001 From: alisson-anjos Date: Wed, 14 Oct 2026 16:54:06 +0000 Subject: [PATCH] Never leave the join's outputs empty in mp4-file mode With save_to = mp4 file, chosen or forced by the RAM guard, the images and comparison outputs were blocked, so the result nodes stayed empty and looked broken. They now carry the video read back from the written files, scaled to fit the free RAM (the files keep the full size), and the join shows the files on its node. Bump BFSNodes version to 1.105.1. Co-Authored-By: Claude Opus 5.5 --- bfs_shot_loop.py | 53 ++++++++++++++++++++++++++++++++----- pyproject.toml | 2 +- tests/test_bfs_shot_loop.py | 15 +++++++---- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/bfs_shot_loop.py b/bfs_shot_loop.py index a235191..3394fef 100644 --- a/bfs_shot_loop.py +++ b/bfs_shot_loop.py @@ -4030,9 +4030,9 @@ class BFSShotJoin: "save_to": (SAVE_TO, {"default": SAVE_TO[0], "tooltip": "images: output the video as IMAGE " "(connect Create Video / Save Video); the whole video is held in memory. mp4 file: the join " "writes the video (and the comparison) straight to MP4 files in the output folder, one shot " - "at a time, so memory stays at about one shot: use it for long videos. The images / audio / " - "comparison outputs are then blocked (nodes after them do not run) and video_path gives " - "the file."}), + "at a time, so memory stays at about one shot: use it for long videos. The images and comparison " + "outputs still carry the video, read back from the files at a size that fits the free RAM " + "(the files keep the full size), the node shows the files, and video_path gives the path."}), "filename_prefix": ("STRING", {"default": "bfs_shot_loop", "tooltip": "mp4 file: the file name in the output folder (a counter is added)."}), "mp4_crf": ("INT", {"default": 17, "min": 0, "max": 51, "tooltip": @@ -4101,12 +4101,26 @@ class BFSShotJoin: def _outputs(self, out): video, a, fps, comp = out if self._to_file: - from comfy_execution.graph_utils import ExecutionBlocker path, comp_path = self._files print(f"[BFS Shot Join] video written to {path}" + (f", comparison to {comp_path}" if comp_path else ""), flush=True) - blocker = ExecutionBlocker(None) - return (blocker, blocker, fps, blocker, path) + # the outputs still carry the video (and the comparison), read back from the files at a size that fits the + # free RAM, so the nodes after the join always show the result; the files keep the full quality + budget = 0.35 * _ram_available() + try: + video, sv = _read_video(path, budget * (0.6 if comp_path else 1.0)) + comp, sc = _read_video(comp_path, budget * 0.4) if comp_path else (video[:1], 1.0) + except Exception as e: # noqa: BLE001 - the files are written; only the preview failed + from comfy_execution.graph_utils import ExecutionBlocker + print(f"[BFS Shot Join] outputs not filled ({e}): see the files", flush=True) + blocker = ExecutionBlocker(None) + return (blocker, a, fps, blocker, path) + if sv < 1 or (comp_path and sc < 1): + print(f"[BFS Shot Join] outputs at {sv * 100:.0f}% size" + (f" (comparison {sc * 100:.0f}%)" if comp_path else "") + + " to fit the free RAM; the MP4 files keep the full size", flush=True) + files = [{"filename": os.path.basename(p_), "subfolder": os.path.relpath(os.path.dirname(p_), _output_root()), + "type": "output"} for p_ in (path, comp_path) if p_] + return {"ui": {"images": files, "animated": (True,)}, "result": (video, a, fps, comp, path)} return (video, a, fps, comp if comp is not None else video[:1], "") def _join_queue(self, images, shots, crossfade, audio, tl=None): @@ -4498,6 +4512,33 @@ def combine_stored(plan: dict, crossfade: int = 4, crf: int = 17, prefix: str = SAVE_TO = ["images (output)", "mp4 file (long videos, low memory)"] +def _output_root() -> str: + return getattr(folder_paths, "get_output_directory", folder_paths.get_temp_directory)() + + +def _read_video(path: str, budget: float) -> tuple[torch.Tensor, float]: + """A video file as IMAGE, scaled down (never up) so it takes at most `budget` bytes; streamed into one tensor.""" + info = json.loads(subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets", "-show_entries", + "stream=width,height,nb_read_packets", "-of", "json", path], + capture_output=True, check=True).stdout)["streams"][0] + w, h, n = int(info["width"]), int(info["height"]), int(info["nb_read_packets"]) + s = min(1.0, math.sqrt(max(budget, 1.0) / max(1, n * h * w * 3 * 4))) + W, H = max(16, int(w * s) // 2 * 2), max(16, int(h * s) // 2 * 2) + out = torch.empty((n, H, W, 3), dtype=torch.float32) + proc = subprocess.Popen(["ffmpeg", "-v", "error", "-i", path, "-vf", f"scale={W}:{H}:flags=area", "-f", "rawvideo", + "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE) + fb, i = W * H * 3, 0 + while i < n: + raw = proc.stdout.read(fb * 16) + if not raw: + break + k = len(raw) // fb + out[i:i + k] = torch.frombuffer(bytearray(raw[:k * fb]), dtype=torch.uint8).view(k, H, W, 3).float() / 255.0 + i += k + proc.stdout.close(); proc.wait() + return out[:max(1, i)], s + + def _join_bytes(frames: int, h: int, w: int, comparison: bool, comp_h: int) -> int: """RAM an images-output join needs: the video as float32, plus the comparison (three columns at its height, which is never above the video's, with the info / prompt bars).""" diff --git a/pyproject.toml b/pyproject.toml index 769c932..5deb1f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "bfsnodes" description = "Custom ComfyUI nodes for reference-guided video: BFS Sampler (guide-driven IC-LoRAs, auto crop, mask inpainting, temporal chunking), LTX Multiple Controls / Identity Transfer / Edit Anything, Head-Swap and Anime2Real conditioning, MiniMax-H3 latent guides, RoPE layouts and loaders, LoRA Surgery, and Shot Planner / Shot Loop (tabbed panel): split long videos into shots at camera cuts, face-tracked cast, SAM 3 mask per shot with full frame, paste, mask, crop and crop + mask generation modes, optional translucent Colored Mark overlay for manually selected regions, optional Qwen3-VL to suggest settings, describe references ({details}) and the person to replace ({target}), continuity between shots, H3 conditioning with an optional duet and prompt writer, join with the original timing, audio and a side-by-side comparison. H3 Duet: pin a source clip beside the video, training-free." -version = "1.105.0" +version = "1.105.1" license = {file = "LICENSE"} dependencies = [ "torch", diff --git a/tests/test_bfs_shot_loop.py b/tests/test_bfs_shot_loop.py index af6a388..6e055e1 100644 --- a/tests/test_bfs_shot_loop.py +++ b/tests/test_bfs_shot_loop.py @@ -1123,9 +1123,12 @@ class ShotStoreTest(unittest.TestCase): with patch.object(SL, "_next_output_path", lambda prefix: os.path.join(d, prefix + "_00001.mp4")): out = SL.BFSShotJoin().join(imgs, shots, [0], audio=[audio], save_to=[SL.SAVE_TO[1]], filename_prefix=["test"]) - self.assertIsInstance(out[0], _Blocker) - self.assertTrue(out[4].endswith("test_00001.mp4")) - cap = cv2.VideoCapture(out[4]); n = 0 + res = out["result"] + self.assertEqual(res[0].shape[0], 45) # the output still carries the video (read back) + self.assertAlmostEqual(float(res[0][30].mean()), 0.8, places=1) + self.assertTrue(res[4].endswith("test_00001.mp4")) + self.assertEqual(out["ui"]["images"][0]["filename"], "test_00001.mp4") # shown on the node + cap = cv2.VideoCapture(res[4]); n = 0 while cap.read()[0]: n += 1 self.assertEqual(n, 45) @@ -1139,8 +1142,10 @@ class JoinRamGuardTest(unittest.TestCase): with patch.object(SL, "_ram_available", lambda: 1000), \ patch.object(SL, "_next_output_path", lambda prefix: os.path.join(d, prefix + "_00001.mp4")): out = SL.BFSShotJoin().join(imgs, shots, [0], comparison=[True]) - self.assertIsInstance(out[0], _Blocker) # images / audio / comparison blocked - self.assertTrue(os.path.exists(out[4]) and os.path.exists(out[4][:-4] + "_comparison.mp4")) + res = out["result"] + self.assertTrue(os.path.exists(res[4]) and os.path.exists(res[4][:-4] + "_comparison.mp4")) + self.assertEqual(res[0].shape[0], 45) # never an empty output node + self.assertTrue(res[0].shape[1] < 32 and res[3].shape[0] == 45) # scaled down to the (tiny) free RAM normal = SL.BFSShotJoin().join(imgs, shots, [0]) # enough RAM: images as before self.assertEqual((normal[0].shape[0], normal[4]), (45, ""))