Shot loop: BFS Shot Repack to put edited pieces back into a shot (1.48.2)

Unpack -> any processing (e.g. remove the reference background) -> Repack. Unconnected
inputs keep the shot's values; replacement guide frames are fitted to the shot's
generation size and length, and timing, cuts and audio are unchanged so the join still
works. Runs once per shot like the rest of the loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alisson-anjos
2026-10-02 20:10:32 +00:00
co-authored by Claude Opus 5.5
parent ea129e03a1
commit e67b22a0ea
4 changed files with 66 additions and 1 deletions
+48
View File
@@ -710,6 +710,52 @@ class BFSShotUnpack:
shot["frames"][:1], audio, shot["width"], shot["height"], shot["index"])
class BFSShotRepack:
"""Put edited pieces back into a shot (runs once per shot), e.g. a reference with its background removed."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {"shot": ("BFS_SHOT",)},
"optional": {
"guide_frames": ("IMAGE", {"tooltip": "Replacement guide frames. Resized to the shot's generation "
"size; padded or trimmed to its length."}),
"ref_image": ("IMAGE", {"tooltip": "Replacement reference (e.g. background removed)."}),
"ref_image_2": ("IMAGE", {"tooltip": "Replacement second reference."}),
"prompt": ("STRING", {"forceInput": True, "tooltip": "Replacement prompt."}),
},
}
RETURN_TYPES = ("BFS_SHOT",)
RETURN_NAMES = ("shot",)
FUNCTION = "repack"
CATEGORY = "BFS/shot loop"
DESCRIPTION = ("Rebuild a shot after editing its pieces (Unpack -> any processing -> Repack). Inputs left "
"unconnected keep the shot's original values; timing, cuts and audio are unchanged.")
def repack(self, shot, guide_frames=None, ref_image=None, ref_image_2=None, prompt=None):
out = dict(shot)
if guide_frames is not None:
fr = guide_frames
H, W = shot["height"], shot["width"]
if tuple(fr.shape[1:3]) != (H, W):
arr = (fr.clamp(0, 1) * 255).round().to(torch.uint8).cpu().numpy()
fr = torch.from_numpy(np.stack([_fit(a, (W, H)) for a in arr]).astype(np.float32) / 255.0)
n = shot["gen_length"]
if fr.shape[0] != n:
print(f"[BFS Shot Repack] shot {shot['index'] + 1}: guide has {fr.shape[0]} frames, "
f"expected {n}; {'trimming' if fr.shape[0] > n else 'holding the last frame'}")
fr = fr[:n] if fr.shape[0] > n else torch.cat([fr, fr[-1:].expand(n - fr.shape[0], -1, -1, -1)], 0)
out["frames"] = fr
if ref_image is not None:
out["ref"] = ref_image[:1]
if ref_image_2 is not None:
out["ref2"] = ref_image_2[:1]
if prompt is not None:
out["prompt"] = prompt
return (out,)
class BFSShotH3Conditioning:
"""Native MiniMax H3 conditioning for one shot: references, prompt and the shot as a guide."""
@@ -930,12 +976,14 @@ BFSShotJoin._join_timeline = _join_timeline_impl
NODE_CLASS_MAPPINGS = {
"BFSShotPlanner": BFSShotPlanner,
"BFSShotUnpack": BFSShotUnpack,
"BFSShotRepack": BFSShotRepack,
"BFSShotH3Conditioning": BFSShotH3Conditioning,
"BFSShotJoin": BFSShotJoin,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BFSShotPlanner": "BFS Shot Planner",
"BFSShotUnpack": "BFS Shot Unpack",
"BFSShotRepack": "BFS Shot Repack",
"BFSShotH3Conditioning": "BFS Shot H3 Conditioning",
"BFSShotJoin": "BFS Shot Join",
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "bfsnodes"
description = "Custom ComfyUI nodes for reference-guided video: BFS Sampler (guide-driven IC-LoRAs with auto crop, mask inpainting and temporal chunking), LTX Multiple Controls and Identity Transfer, LTXV Edit Anything, Head-Swap/Anime2Real conditioning, AMV Guide Builder, MiniMax-H3 downscaled latent guides with training-matched geometry, overlap/sidecar source-phase RoPE, single-frame/video targets and VAE loaders, LoRA Surgery (scale or drop a LoRA's layers live to find a defect, save the result), and Shot Planner / Shot Loop (split long videos into model-sized shots at camera cuts with PySceneDetect, YOLO person/face filters, a reference and prompt per shot, run any workflow per shot, join with the original timing and audio)."
version = "1.48.1"
version = "1.48.2"
license = {file = "LICENSE"}
dependencies = [
"torch",
+16
View File
@@ -176,5 +176,21 @@ class TimelineJoinTest(unittest.TestCase):
self.assertAlmostEqual(float(video[30, 0, 0, 0]), 128 / 255, places=3)
class RepackTest(unittest.TestCase):
def test_replaces_only_connected_pieces_and_fits_the_guide(self):
shot = dict(_shot(0, 0, 20, gen=22), width=8, height=8, frames=torch.zeros(22, 8, 8, 3),
ref=torch.zeros(1, 4, 4, 3), ref2=None, prompt="old", audio=None)
ref = torch.ones(1, 6, 6, 3)
out = SL.BFSShotRepack().repack(shot, ref_image=ref)[0]
self.assertTrue(torch.equal(out["ref"], ref))
self.assertEqual(out["prompt"], "old")
self.assertIs(out["frames"], shot["frames"])
out = SL.BFSShotRepack().repack(shot, guide_frames=torch.ones(30, 16, 16, 3), prompt="new")[0]
self.assertEqual(tuple(out["frames"].shape), (22, 8, 8, 3))
self.assertEqual(out["prompt"], "new")
self.assertEqual(out["length"], 20)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -18,6 +18,7 @@ every shot inside that range.
|---|---|
| **BFS Shot Planner** | Pick a video, split it on a timeline, set a reference and prompt per shot. Outputs the shots as a ComfyUI **list**. |
| **BFS Shot Unpack** | Opens one shot into plain values: guide frames, reference, second reference, prompt, length, first frame, audio, size, index. Use it with any model. |
| **BFS Shot Repack** | Puts edited pieces back into a shot (Unpack -> e.g. remove the reference background -> Repack). Unconnected inputs keep their values; timing, cuts and audio are unchanged. |
| **BFS Shot H3 Conditioning** | Ready-made MiniMax H3 conditioning for one shot, built with the native nodes (Reference to Video + Add Guide). |
| **BFS Shot Join** | Concatenates the decoded shots in order, trims each to its true length, cross-fades soft joins and returns the soundtrack. |