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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d88683357d
commit
d01ca239ce
+47
-6
@@ -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)."""
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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, ""))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user