diff --git a/bfs_shot_loop.py b/bfs_shot_loop.py index 7cdfa24..b3c7073 100644 --- a/bfs_shot_loop.py +++ b/bfs_shot_loop.py @@ -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", } diff --git a/pyproject.toml b/pyproject.toml index 9b439a8..f8edb15 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 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", diff --git a/tests/test_bfs_shot_loop.py b/tests/test_bfs_shot_loop.py index a1c205b..07e7d1a 100644 --- a/tests/test_bfs_shot_loop.py +++ b/tests/test_bfs_shot_loop.py @@ -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() diff --git a/web/docs/BFSShotPlanner.md b/web/docs/BFSShotPlanner.md index 8cd00c5..46ed7a0 100644 --- a/web/docs/BFSShotPlanner.md +++ b/web/docs/BFSShotPlanner.md @@ -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. |