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, ""))