Files
Alisson AnjosandClaude Opus 5.5 33fd830581 Add character swap guide nodes: Video Segment Picker, H3 Character Swap Conditioning (1.119.0)
- BFS Video Segment Picker (SAM 3): pick each subject on frames of a video, propagate
  shot by shot (camera cuts detected), optional Molmo2 relocation after cuts, per-shot
  and per-subject caches, status/log, whole-video scrubber with lost stretches, and a
  hybrid guide output (normal map, DWPose skeleton with face, texture-free styles).
  MoGe / DWPose / Molmo2 inputs are lazy (only on Propagate); models are unloaded
  before them and after the node.
- BFS H3 Character Swap Conditioning: keyframe pins, normal map guide or DWPose guide,
  Pictures 1-4 in color order, pictures list output.
- BFS Keyframe Planner, Keyframe Marks, Track From Frame, Workflow Loop, XY Sweep and
  Frame Loop helpers.
- Segment Picker: export Panel/styles for the video picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:07:56 +00:00

152 lines
8.2 KiB
Python

"""BFS Keyframe Marks: the tracked person's mask at every keyframe, and the keyframe frames marked for an image edit.
Pick the person once (BFS Segment Picker on a guide frame), track it over the whole guide (Run SAM3 Video Track ->
SAM3 Track to Mask), then this node takes the mask at each keyframe of a BFS Keyframe Planner keyframe_spec and tints
that frame (the Colored Mark convention: magenta at 30 % marks the person an image edit replaces). A keyframe whose mask is empty (the track lost the person, e.g. after a camera cut) is reported, so
it can be marked again by hand.
"""
import json
import torch
import torch.nn.functional as F
def parse_color(text):
s = str(text or "").strip().lstrip("#")
if len(s) == 3:
s = "".join(ch * 2 for ch in s)
try:
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
except (ValueError, IndexError):
return 255, 0, 255
def grow(mask, pixels):
"""[N,H,W] mask dilated (pixels > 0) or eroded (pixels < 0) by a square of that radius."""
if not pixels:
return mask
r = abs(int(pixels)); k = 2 * r + 1
m = mask[:, None].float()
if pixels > 0:
return F.max_pool2d(m, k, 1, r)[:, 0]
return -F.max_pool2d(-m, k, 1, r)[:, 0]
class BFSKeyframeMarks:
DESCRIPTION = __doc__.split("\n\n")[0]
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"guide_video": ("IMAGE", {"tooltip": "The guide frames the track was made on (same frames as the Planner's guide)."}),
"masks": ("MASK", {"tooltip": "The person to replace: one mask per guide frame, or one per keyframe (Segment Picker batch_masks on the Planner's source_frames)."}),
"keyframe_spec": ("STRING", {"forceInput": True, "tooltip": "BFS Keyframe Planner keyframe_spec: which frames are keyframes."}),
"mark_color": ("STRING", {"default": "#FF00FF", "tooltip": "Tint of the person in marked_frames (hex)."}),
"mark_opacity": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "How strongly the person is tinted (0.3 = the Colored Mark default)."}),
"grow_pixels": ("INT", {"default": 0, "min": -64, "max": 64,
"tooltip": "Grow (positive) or shrink (negative) each mask before tinting, e.g. to cover hair."}),
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, "tooltip": "Mask values above this count as the person."}),
}}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "STRING")
RETURN_NAMES = ("marked_frames", "masks", "source_frames", "report")
OUTPUT_TOOLTIPS = ("The keyframe frames with the person tinted, one per keyframe (in keyframe_spec order).",
"The person's mask at each keyframe.",
"The untouched keyframe frames.",
"JSON: per keyframe its frame, time, mask area and whether the mask is empty.")
FUNCTION = "mark"
CATEGORY = "BFS/MiniMax H3"
def mark(self, guide_video, masks, keyframe_spec, mark_color, mark_opacity, grow_pixels, threshold):
spec = json.loads(keyframe_spec or "{}")
frames = [int(f) for f in spec.get("frames", [])]
if not frames:
raise ValueError("keyframe_spec lists no keyframes: add them in BFS Keyframe Planner.")
n, h, w = guide_video.shape[:3]
if masks.ndim == 2:
masks = masks[None]
per_keyframe = masks.shape[0] == len(frames) and masks.shape[0] != n # masks made on the Planner's source_frames
if not per_keyframe and masks.shape[0] != n:
raise ValueError(f"{masks.shape[0]} masks: give one per guide frame ({n}) or one per keyframe ({len(frames)}).")
if masks.shape[1:] != (h, w):
masks = F.interpolate(masks[:, None].float(), size=(h, w), mode="bilinear", align_corners=False)[:, 0]
color = torch.tensor(parse_color(mark_color), dtype=guide_video.dtype) / 255.0
fps = float(spec.get("fps", 24.0))
src, out, kept, report = [], [], [], []
for i, f in enumerate(frames):
f = max(0, min(n - 1, f))
j = i if per_keyframe else f
m = grow((masks[j:j + 1] > threshold).float(), grow_pixels).clamp(0, 1)
img = guide_video[f:f + 1, ..., :3]
a = (m[0].to(img.dtype) * float(mark_opacity))[..., None]
out.append(img * (1 - a) + color * a)
src.append(img); kept.append(m)
area = float(m.mean())
report.append({"frame": f, "time": round(f / fps, 3), "area": round(area, 4), "empty": area < 1e-4})
lost = [r["frame"] for r in report if r["empty"]]
if len(lost) == len(report):
raise ValueError("No person selected: click the person to replace in the Video Segment Picker, then run again.")
if lost:
print(f"[BFS Keyframe Marks] no mask at keyframe frame(s) {lost}: mark the person there by hand", flush=True)
return torch.cat(out), torch.cat(kept), torch.cat(src), json.dumps({"keyframes": report, "lost": lost})
class BFSTrackFromFrame:
"""Track a person over the whole guide from the frame they were picked on (not only frame 0): SAM 3 tracks
forward from that frame and backward (the frames before it, reversed), and the two halves are joined. With
anchor_frame -1 the anchor is the first keyframe of keyframe_spec, so feed the Planner's source_frames to the
Segment Picker and its mask lines up."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"guide_video": ("IMAGE", {"tooltip": "The guide frames (the same the Planner gets)."}),
"sam3_model": ("MODEL", {"tooltip": "SAM 3.1 MODEL (Load Checkpoint with sam3.1_multiplex_fp16.safetensors)."}),
"mask": ("MASK", {"tooltip": "The person on the anchor frame (BFS Segment Picker mask)."}),
"anchor_frame": ("INT", {"default": -1, "min": -1, "max": 100000,
"tooltip": "Guide frame the mask was made on; -1 = the first keyframe of keyframe_spec."}),
}, "optional": {
"keyframe_spec": ("STRING", {"forceInput": True, "tooltip": "BFS Keyframe Planner keyframe_spec (for anchor_frame -1)."}),
}}
RETURN_TYPES = ("MASK", "INT")
RETURN_NAMES = ("masks", "anchor_frame")
OUTPUT_TOOLTIPS = ("One mask per guide frame.", "The anchor frame used.")
FUNCTION = "track"
CATEGORY = "BFS/MiniMax H3"
@staticmethod
def _run(frames, model, mask):
from comfy_extras.nodes_sam3 import SAM3_VideoTrack, SAM3_TrackToMask
data = SAM3_VideoTrack.execute(images=frames, model=model, initial_mask=mask, max_objects=1).args[0]
return SAM3_TrackToMask.execute(track_data=data, object_indices="").args[0]
def track(self, guide_video, sam3_model, mask, anchor_frame, keyframe_spec=None):
n, h, w = guide_video.shape[:3]
k = int(anchor_frame)
if k < 0:
frames = json.loads(keyframe_spec or "{}").get("frames", [])
if not frames:
raise ValueError("anchor_frame -1 needs a keyframe_spec with at least one keyframe.")
k = int(frames[0])
k = max(0, min(n - 1, k))
m = mask[:1] if mask.ndim == 3 else mask[None]
if m.shape[1:] != (h, w):
m = F.interpolate(m[:, None].float(), size=(h, w), mode="bilinear", align_corners=False)[:, 0]
m = (m > 0.5).float()
fwd = self._run(guide_video[k:], sam3_model, m) # frames k .. n-1
parts = [fwd]
if k > 0:
bwd = self._run(guide_video[:k + 1].flip(0), sam3_model, m) # frames k .. 0
parts = [bwd.flip(0)[:k], fwd]
out = torch.cat([p.to(guide_video.device, torch.float32) for p in parts])
if out.shape[1:] != (h, w):
out = F.interpolate(out[:, None], size=(h, w), mode="bilinear", align_corners=False)[:, 0]
print(f"[BFS Track From Frame] anchor frame {k}: tracked {k} frames back, {n - k} forward", flush=True)
return out, k
NODE_CLASS_MAPPINGS = {"BFSKeyframeMarks": BFSKeyframeMarks, "BFSTrackFromFrame": BFSTrackFromFrame}
NODE_DISPLAY_NAME_MAPPINGS = {"BFSKeyframeMarks": "BFS Keyframe Marks", "BFSTrackFromFrame": "BFS Track From Frame (SAM 3)"}