source_blur (SAM 3.1, keeps the mouth): blur the source person in the guide / native ref video / duet panel so the model reads pose, motion and lips but has no face to copy - Shot Conditioning, Shot Duet, Duet, Duet Conditioning

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alisson-anjos
2026-10-09 12:10:27 +00:00
co-authored by Claude Opus 5.5
parent 0bf03a8fb7
commit 84a953e8c5
5 changed files with 153 additions and 14 deletions
+35 -7
View File
@@ -712,6 +712,8 @@ if _io is not None:
input=_io.Audio.Input("ref_video_audio", tooltip="Soundtrack of the same-numbered reference video"), prefix="ref_video_audio_", min=0, max=3)),
_io.Autogrow.Input("ref_audios", optional=True, template=_io.Autogrow.TemplatePrefix(
input=_io.Audio.Input("ref_audio", tooltip="<Audio n> reference"), prefix="ref_audio_", min=0, max=3)),
_io.Combo.Input("source_blur", options=_blur_modes(), default="off", optional=True, tooltip=
"Blur the source person in what the model sees (the panel / the guide), keeping the mouth sharp (SAM 3.1): it reads the pose, motion and lips but has no face to copy. The output is never blurred. Works with or instead of panel_static."),
_io.Combo.Input("panel_static", options=PANEL_STATIC, default="off", optional=True, tooltip=
"TSC's fix for swaps in the same world: cover the face (or the whole person) of the pinned clip in TV "
"static, every frame (SAM 3), so the generated half cannot copy it and takes the identity from the "
@@ -736,12 +738,13 @@ if _io is not None:
def execute(cls, model, clip, vae, task, instruction, prompt, width, height, length, position, size, fit, gap,
panel_noise, hold, rope_mode, rope_gap, ref_image_size, steps, sampler_name, scheduler, seed,
decode_canvas, audio_vae=None, panel=None, guide=None, guide_frame_idx=0, ref_images=None,
ref_videos=None, ref_video_audios=None, ref_audios=None, panel_static="off", setting=None, vlm=None,
ref_videos=None, ref_video_audios=None, ref_audios=None, panel_static="off", setting=None, vlm=None, source_blur="off",
vlm_max_tokens=1024):
if panel is None and guide is None:
raise ValueError("BFS H3 Duet needs a panel, a guide, or both")
raw_panel = panel
panel = static_over_panel(panel, panel_static)
panel = static_over_panel(_blur(panel, source_blur, 1.0, "[BFS H3 Duet] panel"), panel_static)
guide = _blur(guide, source_blur, 1.0, "[BFS H3 Duet] guide")
if length <= 0:
src = guide if guide is not None else panel
length = src.shape[0] if src.shape[0] >= 5 else 124
@@ -775,6 +778,24 @@ def _shot_loop():
PANEL_STATIC = ["off", "face", "person"]
def _blur_modes():
try:
from .bfs_source_blur import SOURCE_BLUR
except ImportError:
from bfs_source_blur import SOURCE_BLUR
return SOURCE_BLUR
def _blur(frames, mode, strength=1.0, log=""):
if frames is None or not mode or mode == "off":
return frames
try:
from .bfs_source_blur import blur_source
except ImportError:
from bfs_source_blur import blur_source
return blur_source(frames, mode, float(strength), log)
def _small(frames: torch.Tensor, side: int = 640) -> torch.Tensor:
H, W = frames.shape[1:3]
s = side / max(H, W)
@@ -887,6 +908,8 @@ if _io is not None:
_io.Image.Input("keep_video", optional=True, tooltip="The video kept outside the mask (default: the panel clip)."),
_io.Autogrow.Input("ref_images", optional=True, template=_io.Autogrow.TemplatePrefix(
input=_io.Image.Input("ref_image", tooltip="<Picture n>, in order"), prefix="ref_image_", min=0, max=9)),
_io.Combo.Input("source_blur", options=_blur_modes(), default="off", optional=True, tooltip=
"Blur the source person in what the model sees (the panel / the guide), keeping the mouth sharp (SAM 3.1): it reads the pose, motion and lips but has no face to copy. The output is never blurred. Works with or instead of panel_static."),
_io.Combo.Input("panel_static", options=PANEL_STATIC, default="off", optional=True, tooltip=
"TSC's fix for swaps in the same world: cover the face (or the whole person) of the pinned clip in TV "
"static, every frame (SAM 3), so the generated half cannot copy it and takes the identity from the "
@@ -904,11 +927,12 @@ if _io is not None:
def execute(cls, clip, vae, panel, task, instruction, prompt, width, height, length, position, size, panel_noise,
rope_mode, fit, gap, hold, rope_gap, ref_image_size, vlm_max_tokens, vlm=None, model=None,
audio_vae=None, guide=None, ref_images=None, keep_mask=None, keep_video=None, panel_static="off",
setting=None):
setting=None, source_blur="off"):
from comfy_extras.nodes_minimax_h3 import MiniMaxH3ReferenceToVideo
refs = [v for v in (ref_images or {}).values() if v is not None]
raw_panel = panel
panel = static_over_panel(panel, panel_static)
panel = static_over_panel(_blur(panel, source_blur, 1.0, "[BFS H3 Duet Conditioning] panel"), panel_static)
guide = _blur(guide, source_blur, 1.0, "[BFS H3 Duet Conditioning] guide")
if length <= 0:
length = panel.shape[0] if panel.shape[0] >= 5 else 124
text = _shot_loop().duet_prompt(vlm, prompt, raw_panel, refs, task, instruction, int(vlm_max_tokens),
@@ -971,7 +995,9 @@ class BFSShotH3Duet:
},
"optional": {"audio_vae": ("VAE",),
"rope_mode": (ROPE_MODES, {"default": "canvas", "advanced": True}),
"rope_gap": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 256.0, "step": 1.0, "advanced": True})},
"rope_gap": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 256.0, "step": 1.0, "advanced": True}),
"source_blur": (_blur_modes(), {"default": "off", "tooltip": "Blur the source person in what the model sees (the panel / the guide), keeping the mouth sharp (SAM 3.1): it reads the pose, motion and lips but has no face to copy. The output is never blurred."}),
"source_blur_strength": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 1.0, "step": 0.05})},
}
RETURN_TYPES = ("IMAGE", "AUDIO", "IMAGE", "STRING", "STRING")
@@ -985,7 +1011,7 @@ class BFSShotH3Duet:
def render(self, shot, model, clip, vae, mode, task, instruction, use_ref_2, position, size, fit, gap,
panel_noise, ref_image_size, steps, sampler_name, scheduler, seed, decode_canvas, audio_vae=None,
rope_mode="canvas", rope_gap=0.0):
rope_mode="canvas", rope_gap=0.0, source_blur="off", source_blur_strength=1.0):
imgs = {}
if shot.get("ref") is not None:
imgs["ref_image_1"] = shot["ref"]
@@ -1001,9 +1027,11 @@ class BFSShotH3Duet:
imgs[f"ref_image_{len(imgs) + 1}"] = prev
text = _resolve_prompt(shot.get("prompt", ""), task, instruction, len(imgs), 0)
hist, hsrc = shot_history(shot) if shot_history else (None, None)
seen = _blur(shot["frames"], source_blur, source_blur_strength,
f"[BFS Shot H3 Duet] shot {int(shot.get('index', 0)) + 1}")
out = h3_render(model, clip, vae, audio_vae, text, {"ref_images": imgs}, shot["width"], shot["height"],
shot["gen_length"], steps, sampler_name, scheduler, seed + int(shot.get("index", 0)),
panel=shot["frames"] if duet else None, guide=shot["frames"] if guided else None,
panel=seen if duet else None, guide=seen if guided else None,
position=position, size=size, fit=fit, gap=gap, panel_noise=panel_noise,
ref_image_size=ref_image_size, decode_canvas=decode_canvas,
rope_mode=rope_mode, rope_gap=rope_gap,
+21 -6
View File
@@ -1957,6 +1957,10 @@ def duet_prompt(vlm, prompt: str, frames: torch.Tensor | None, refs: list, task:
# ---------------------------------------------------------------------------- continuity between shots
CHAIN_MODES = ("off", "reference", "first frame", "history")
try:
from .bfs_source_blur import SOURCE_BLUR, blur_source
except ImportError:
from bfs_source_blur import SOURCE_BLUR, blur_source
_LAST_RESULT: dict[str, Any] = {} # auto loop: the last shot a render node produced (index, count, frames)
@@ -2807,6 +2811,13 @@ class BFSShotH3Conditioning:
"video': enters as a native reference video (<Video n>, after the shot's own if guide_mode uses "
"it). The LoRAs were trained with one full guide, so test against off; it can also pull the old "
"subject's look."}),
"source_blur": (SOURCE_BLUR, {"default": "off", "tooltip":
"Blur the source person in what the model SEES of the source (aligned guide, native reference "
"video, duet panel), keeping the mouth sharp (SAM 3.1 masks). The model still reads the pose, "
"motion and lips but has no face to copy, so the identity comes from the reference. The kept "
"area of 'Mask only' and the output are never blurred. JalenBrunson's swap trick."}),
"source_blur_strength": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 1.0, "step": 0.05,
"tooltip": "How coarse the source blur is (1 = no detail left)."}),
"mask_guide_look": (MASK_GUIDE_LOOKS, {"default": MASK_GUIDE_LOOKS[0], "tooltip":
"What the mask guide shows of the subject. grey blurred: its volume, light and head direction "
"without colours or a face (default). colour: as it is (can make the model copy the old subject). "
@@ -2831,7 +2842,8 @@ class BFSShotH3Conditioning:
def condition(self, shot, clip, vae, guide_mode, use_ref_2, first_frame, ref_image_size,
audio_vae=None, with_audio=False, duet="off", model=None, panel_position="left", panel_size=1.0,
panel_noise=0.0, rope_gap=0.0, task="planner prompt", instruction="", vlm=None,
setting_ref="off", setting_mask=None, inpaint="off", mask_guide="off", mask_ref_size="1/2", mask_guide_look="grey blurred"):
setting_ref="off", setting_mask=None, inpaint="off", mask_guide="off", mask_ref_size="1/2", mask_guide_look="grey blurred",
source_blur="off", source_blur_strength=1.0):
from comfy_extras.nodes_minimax_h3 import MiniMaxH3AddGuide, MiniMaxH3ReferenceToVideo
try:
from .bfs_h3_side_panel import build_prompt, fill_layout, make_info
@@ -2888,6 +2900,9 @@ class BFSShotH3Conditioning:
audio_vae=audio_vae, ref_images=refs or None)
use_mask = inpaint == "only the mask" or (inpaint == INPAINT_MODES[0] and shot.get("inpaint"))
gtag = f"[BFS Shot H3 Conditioning] shot {shot.get('index', 0) + 1}/{shot.get('count', 1)}"
# what the model sees of the source; the kept area (keep_video) stays the original
seen = blur_source(shot["frames"], source_blur, float(source_blur_strength), gtag) \
if source_blur and source_blur != "off" else shot["frames"]
pg = mask_guide if mask_guide in MASK_GUIDES[1:] else None
if pg and (not use_mask or shot.get("crop") or on_canvas):
print(f"{gtag}: mask_guide ignored: it is for 'Mask only' shots (mask, no crop, no duet)", flush=True)
@@ -2900,7 +2915,7 @@ class BFSShotH3Conditioning:
if native or pg == MASK_GUIDES[3]:
vids = {}
if native:
vids["ref_video_1"] = shot["frames"]
vids["ref_video_1"] = seen
if audio is not None:
kwargs["ref_video_audios"] = {"ref_video_audio_1": audio}
if pg == MASK_GUIDES[3]:
@@ -2926,10 +2941,10 @@ class BFSShotH3Conditioning:
if aligned and (not on_canvas or audio is not None):
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae,
audio_vae=audio_vae if audio is not None else None,
image=shot["frames"], audio=audio).args[0]
image=seen, audio=audio).args[0]
if first_frame != "none":
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae,
image=shot["frames"][:1]).args[0]
image=seen[:1]).args[0]
prev = chain_image(shot)
if prev is not None and shot.get("chain") == "first frame":
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae,
@@ -2957,9 +2972,9 @@ class BFSShotH3Conditioning:
except ImportError:
from bfs_h3_side_panel import BFSH3SidePanel, patch_model_rope
# the guide goes straight onto the canvas (guides added above are moved onto it by the panel step)
guide = shot["frames"] if aligned and audio is None else None
guide = seen if aligned and audio is None else None
positive, latent, info, _, _ = BFSH3SidePanel().apply(
positive, latent, vae, shot["frames"], panel_position, panel_size, "contain", 0, "all frames",
positive, latent, vae, seen, panel_position, panel_size, "contain", 0, "all frames",
panel_noise, guide, 0, keep_video=keep_video, keep_mask=keep_mask)
shot["panel"] = info # BFS Shot Join crops the decoded canvas back to the video
if duet == "shifted RoPE":
+86
View File
@@ -0,0 +1,86 @@
"""Optional blur of the source person in what the model SEES of the source (aligned guide, native reference video,
duet panel), keeping the mouth sharp: the model still reads the pose, the motion and the lips, but has no face to copy,
so the identity comes from the reference. JalenBrunson's trick for reliable ref2v swaps (Sapiens2 there); here the
masks come from SAM 3.1, already used by the planner. The kept area (inpaint) and the output are never blurred.
"""
from __future__ import annotations
import torch
SOURCE_BLUR = ["off", "face (keep mouth)", "head + hands (keep mouth)", "person (keep mouth)"]
_PARTS = {SOURCE_BLUR[1]: ("face",), SOURCE_BLUR[2]: ("head", "hand"), SOURCE_BLUR[3]: ("person",)}
_CACHE: dict = {}
def _sl():
try:
from . import bfs_shot_loop as SL
except ImportError:
import bfs_shot_loop as SL
return SL
def _small(frames: torch.Tensor, side: int = 640) -> torch.Tensor:
H, W = frames.shape[1:3]
s = side / max(H, W)
x = frames[..., :3].float()
if s >= 1:
return x
return torch.nn.functional.interpolate(x.movedim(-1, 1), size=(max(32, int(H * s) // 2 * 2), max(32, int(W * s) // 2 * 2)),
mode="bilinear", align_corners=False).movedim(1, -1)
def _grow(m: torch.Tensor, px: int) -> torch.Tensor:
if px <= 0:
return m
return torch.nn.functional.max_pool2d(m[:, None], 2 * px + 1, stride=1, padding=px)[:, 0]
def blur_mask(frames: torch.Tensor, mode: str, log: str = "") -> torch.Tensor | None:
"""[N,h,w] float mask (working size) of what to blur: the parts for `mode` minus the (slightly grown) mouth."""
parts = _PARTS.get(mode)
if not parts:
return None
SL = _sl()
small = _small(frames)
key = (mode, tuple(small.shape), SL._image_key(frames[:1]), SL._image_key(frames[-1:]),
SL._image_key(frames[frames.shape[0] // 2:frames.shape[0] // 2 + 1]))
if key in _CACHE:
return _CACHE[key]
m = torch.zeros(small.shape[:3])
for p in parts:
m = torch.maximum(m, (SL.segment_frames(small, dict(SL.DEFAULT_MASK, text=p, max_objects=8, threshold=0.4)) > 0.5).float())
mouth = (SL.segment_frames(small, dict(SL.DEFAULT_MASK, text="mouth", max_objects=8, threshold=0.3)) > 0.5).float()
keep = _grow(mouth, max(1, int(min(small.shape[1:3]) * 0.012)))
out = (_grow(m, max(1, int(min(small.shape[1:3]) * 0.015))) * (1 - keep)).clamp(0, 1)
if log:
print(f"{log}: source blur '{mode}': {float(m.mean()) * 100:.1f}% of the frame, mouth kept "
f"({'found' if float(mouth.sum()) > 0 else 'not found: the whole part is blurred'})", flush=True)
if len(_CACHE) > 8:
_CACHE.pop(next(iter(_CACHE)))
_CACHE[key] = out
return out
def blur_source(frames: torch.Tensor | None, mode: str, strength: float = 1.0, log: str = "") -> torch.Tensor | None:
"""The frames with the source person blurred per `mode` (mouth kept). `strength` 0-1 sets how coarse the blur is."""
if frames is None or not mode or mode == "off" or strength <= 0:
return frames
m = blur_mask(frames, mode, log)
if m is None or float(m.sum()) == 0:
return frames
x = frames[..., :3].float()
N, H, W = x.shape[:3]
# a heavy blur: down to ~1/12-1/40 of the size and back, then a soft pass; blocky detail never survives it
f = max(4, int(min(H, W) * (0.025 + 0.06 * float(strength))))
low = torch.nn.functional.interpolate(x.movedim(-1, 1), size=(max(2, H // f), max(2, W // f)), mode="area")
blur = torch.nn.functional.interpolate(low, size=(H, W), mode="bilinear", align_corners=False)
blur = torch.nn.functional.avg_pool2d(blur, 5, stride=1, padding=2).movedim(1, -1)
mm = torch.nn.functional.interpolate(m[:, None], size=(H, W), mode="bilinear", align_corners=False)
mm = torch.nn.functional.avg_pool2d(mm, 9, stride=1, padding=4)[:, 0, ..., None].clamp(0, 1)
if mm.shape[0] != N: # one mask per frame; a shorter list holds its last one
idx = [min(i, mm.shape[0] - 1) for i in range(N)]
mm = mm[idx]
out = x * (1 - mm) + blur * mm
del low, blur, mm
return out
+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, 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 four generation modes (full frame, mask only, crop, crop + mask), 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.90.0"
version = "1.91.0"
license = {file = "LICENSE"}
dependencies = [
"torch",
+10
View File
@@ -283,6 +283,16 @@ Every setting in the panel has a hover tooltip (ⓘ). The main ones:
| Mask: threshold / max objects / paste back | SAM 3 text matching, objects tracked, paste only the mask or the whole box |
| VLM: frames per shot / max tokens | how much the VLM sees and how long it may answer |
## Source blur (keep the mouth) (1.91.0)
`source_blur` on **BFS Shot H3 Conditioning**, **BFS Shot H3 Duet**, **BFS H3 Duet** and **BFS H3 Duet Conditioning**
blurs the source person in what the model *sees* of the source (aligned guide, native reference video, duet panel),
keeping the **mouth** sharp: the model still reads the pose, the motion and the lips but has no face to copy, so the
identity comes from the reference. Masks from SAM 3.1 (the part, minus a slightly grown "mouth" mask), cached per shot.
`face (keep mouth)`, `head + hands (keep mouth)` (also hides hair and hands), `person (keep mouth)` (the whole body:
only for big changes, the pose then comes from the blurred shape). The kept area of *Mask only* and the output are
never blurred. Trick shared by JalenBrunson (Sapiens2 there) for reliable ref2v swaps.
## Big videos (upload)
ComfyUI refuses any single request bigger than `--max-upload-size` (100 MB by default), which is why big videos fail