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:
co-authored by
Claude Opus 5.5
parent
0bf03a8fb7
commit
84a953e8c5
+35
-7
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user