Compare commits
42
Commits
@@ -46,6 +46,11 @@ cd ComfyUI-BFSNodes
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Optional: `pip install insightface` with `onnxruntime` or `onnxruntime-gpu`, only for LTX Identity. The Shot Planner's
|
||||
*Find people* / Cast no longer needs it (1.93.0): it uses OpenCV's YuNet + SFace (Apache-2.0, downloaded once), and uses
|
||||
InsightFace only when it is installed. They are not in the requirements because insightface pins packages
|
||||
that conflict with many environments; without them everything else works and those features say what to install.
|
||||
|
||||
Restart ComfyUI after installation.
|
||||
|
||||
## LoRA Placement
|
||||
@@ -156,7 +161,8 @@ Training-free split-screen generation for MiniMax H3 (TSC's latent pin): a panel
|
||||
or a reference) is pinned beside the video with a noise mask, the video is generated in sync with
|
||||
it, and the panel is cropped off before decoding. **BFS H3 Duet** does it all in one node, **BFS Shot H3 Duet** renders each shot of the Shot Loop that way (or with an aligned guide for body-swap LoRAs);
|
||||
**BFS H3 Side Panel** + **BFS H3 Side Panel Crop** are the building blocks. Optional aligned latent
|
||||
guide. See [`web/js/docs/BFSH3Duet.md`](web/js/docs/BFSH3Duet.md).
|
||||
guide. 1.84.0: `panel_static` (TV static over the face / person of the pinned clip, SAM 3) and a `setting`
|
||||
picture (**BFS Setting Picture**) for same-world swaps. See [`web/js/docs/BFSH3Duet.md`](web/js/docs/BFSH3Duet.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -86,6 +86,12 @@ try:
|
||||
except Exception as _e: # noqa
|
||||
print(f"[BFSNodes] Shot loop nodes not loaded: {_e!r}")
|
||||
SHOT_NODE_CLASS_MAPPINGS, SHOT_NODE_DISPLAY_NAME_MAPPINGS = {}, {}
|
||||
try:
|
||||
from .bfs_shot_noise import NODE_CLASS_MAPPINGS as SNOISE_NODE_CLASS_MAPPINGS
|
||||
from .bfs_shot_noise import NODE_DISPLAY_NAME_MAPPINGS as SNOISE_NODE_DISPLAY_NAME_MAPPINGS
|
||||
except Exception as _e: # noqa
|
||||
print(f"[BFSNodes] BFS Shot Noise node not loaded: {_e!r}")
|
||||
SNOISE_NODE_CLASS_MAPPINGS, SNOISE_NODE_DISPLAY_NAME_MAPPINGS = {}, {}
|
||||
try:
|
||||
from .minimax_h3_downscaled_guide import NODE_CLASS_MAPPINGS as H3GUIDE_NODE_CLASS_MAPPINGS
|
||||
from .minimax_h3_downscaled_guide import NODE_DISPLAY_NAME_MAPPINGS as H3GUIDE_NODE_DISPLAY_NAME_MAPPINGS
|
||||
@@ -127,6 +133,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
**MSP_NODE_CLASS_MAPPINGS,
|
||||
**MMVAE_NODE_CLASS_MAPPINGS,
|
||||
**SHARP_NODE_CLASS_MAPPINGS,
|
||||
**SNOISE_NODE_CLASS_MAPPINGS,
|
||||
**MMDD_NODE_CLASS_MAPPINGS,
|
||||
**SVH3_NODE_CLASS_MAPPINGS,
|
||||
**EPF_NODE_CLASS_MAPPINGS,
|
||||
@@ -154,6 +161,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
**MSP_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
**MMVAE_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
**SHARP_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
**SNOISE_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
**MMDD_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
**SVH3_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
**EPF_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Sliding history for MiniMax H3: the end of the previous shot's result as clean frames placed just BEFORE the video
|
||||
on H3's timeline, so a long continuous take is generated window by window with motion (and look) carried across.
|
||||
|
||||
Idea from Akatz's H3 Relay windowed edit and Ethanfel's ComfyUI-MiniMaxH3-Context-Loop (both GPL-3.0); this is an
|
||||
independent implementation. A guide keyframe marked ``anchor = "history"`` is packed like any H3 guide, then its rows
|
||||
are kept at the target's origin and everything else on the target timeline (the video, its audio, the other guides)
|
||||
moves forward by the history's span. Text and reference rows are untouched.
|
||||
|
||||
The layout is patched in memory only (ComfyUI files are never edited), once, and only changes layouts that carry a
|
||||
history keyframe; when ComfyUI's own H3 layout already understands history anchors the patch stands down.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import torch
|
||||
|
||||
LOG = logging.getLogger("bfs.h3_history")
|
||||
HISTORY_FRAMES = (17, 34, 51) # whole H3 VAE groups: 1+4+4+4+4 frames per 5 latent steps
|
||||
_PATCH = "_bfs_history_patch"
|
||||
|
||||
|
||||
def history_span(latent_t: int) -> float:
|
||||
"""Timeline span of `latent_t` video latent steps (FRAME_RESCALE per pixel frame)."""
|
||||
import comfy.ldm.minimax.model as mm
|
||||
return float(sum(mm._video_t_spans(int(latent_t))))
|
||||
|
||||
|
||||
def relocate(layout, keyframes) -> bool:
|
||||
"""Moves the target timeline past the history keyframes. Returns whether anything changed."""
|
||||
hist = [i for i, kf in enumerate(keyframes or ()) if kf.get("anchor") == "history"]
|
||||
if not hist:
|
||||
return False
|
||||
# keyframe -> its packed segments: every guide with a video latent owns the next "cond" segment, every guide
|
||||
# with audio the next "cond_audio" one, in keyframe order (the order PackedLayout packs them)
|
||||
cond = [s for s in layout.segments if s[2] == "cond"]
|
||||
cond_a = [s for s in layout.segments if s[2] == "cond_audio"]
|
||||
own, ci, ai = {}, 0, 0
|
||||
for i, kf in enumerate(keyframes):
|
||||
segs = []
|
||||
if kf.get("latent") is not None and ci < len(cond):
|
||||
segs.append(cond[ci]); ci += 1
|
||||
if kf.get("audio_latent") is not None and ai < len(cond_a):
|
||||
segs.append(cond_a[ai]); ai += 1
|
||||
own[i] = segs
|
||||
span = sum(history_span(keyframes[i]["latent"].shape[2]) for i in hist if keyframes[i].get("latent") is not None)
|
||||
if span <= 0:
|
||||
return False
|
||||
keep = torch.zeros(layout.position_ids.shape[0], dtype=torch.bool)
|
||||
for i in hist:
|
||||
for a, b, _ in own[i]:
|
||||
keep[a:b] = True
|
||||
vid = next(((a, b) for a, b, k in layout.segments if k == "video"), None)
|
||||
if vid is not None and keep.any():
|
||||
# another history implementation (e.g. H3 Relay's, or a future ComfyUI) already moved the target: skip
|
||||
if float(layout.position_ids[vid[0]:vid[1], 0].min()) - float(layout.position_ids[keep, 0].min()) >= span - 1e-6:
|
||||
return False
|
||||
move = torch.zeros_like(keep)
|
||||
for a, b, kind in layout.segments:
|
||||
if kind in ("video", "audio", "cond", "cond_audio"):
|
||||
move[a:b] = True
|
||||
move &= ~keep
|
||||
pos = layout.position_ids.clone()
|
||||
pos[move, 0] += span
|
||||
layout.position_ids = pos
|
||||
LOG.info("H3 history: %d rows before the video, target timeline moved by %.2f", int(keep.sum()), span)
|
||||
return True
|
||||
|
||||
|
||||
def install() -> bool:
|
||||
"""Wraps PackedLayout.__init__ once (process-local). False when ComfyUI is missing or already supports it."""
|
||||
try:
|
||||
import comfy.ldm.minimax.model as mm
|
||||
except ImportError:
|
||||
return False
|
||||
cls = mm.PackedLayout
|
||||
if getattr(cls.__init__, _PATCH, False):
|
||||
return True
|
||||
import inspect
|
||||
if "history" in inspect.getsource(cls): # native history anchors: nothing to do
|
||||
return False
|
||||
orig = cls.__init__
|
||||
|
||||
def __init__(self, text_len, latent_t, latent_h, latent_w, audio_t, keyframes=None, refs=None):
|
||||
orig(self, text_len, latent_t, latent_h, latent_w, audio_t, keyframes=keyframes, refs=refs)
|
||||
relocate(self, keyframes)
|
||||
|
||||
setattr(__init__, _PATCH, True)
|
||||
cls.__init__ = __init__
|
||||
return True
|
||||
|
||||
|
||||
def add_history(positive, vae, frames: torch.Tensor):
|
||||
"""Appends `frames` ([N,H,W,3] at the video's size, N = 17k: the end of the previous result) as a history keyframe.
|
||||
Returns (positive, frames used)."""
|
||||
import node_helpers
|
||||
n = (int(frames.shape[0]) // 17) * 17
|
||||
if n < 17:
|
||||
return positive, 0
|
||||
install()
|
||||
# encoded from CPU frames; the latent comes back on ComfyUI's intermediate device like any H3 guide, and the VAE
|
||||
# is loaded / freed by ComfyUI's model management (nothing here keeps GPU memory)
|
||||
with torch.no_grad():
|
||||
lat = vae.encode(frames[-n:, ..., :3].float().cpu())
|
||||
kfs = list(positive[0][1].get("minimax_keyframes", []))
|
||||
kfs.append({"resolved_frame_index": 0, "latent": lat, "anchor": "history"})
|
||||
return node_helpers.conditioning_set_values(positive, {"minimax_keyframes": kfs}), n
|
||||
+330
-96
@@ -26,30 +26,31 @@ PATCH_PX = 32 # one DiT patch (2x2 latent cells of 16 px)
|
||||
FRAME_PER_TOKEN = (1, 4, 4, 4, 4) # H3 video latent: frames per latent step, k % 5
|
||||
|
||||
PANEL_PROMPT_HINT = (
|
||||
"How to refer to the side panel in the prompt: the panel has NO tag (it is not <Picture n> or <Video n>; "
|
||||
"the text encoder never sees it). Name it by its place: 'the LEFT half is the kept footage' (write {layout} "
|
||||
"to insert that sentence for the current size and side). Describe only the generated part: never describe "
|
||||
"the panel's performer, clothes or room, even to contrast them; what you leave undescribed is copied from "
|
||||
"the panel. Restate the new identity in every shot ('her face from <Picture 1>' plus two or three face, hair "
|
||||
"or outfit words). Give exact times for cuts ('[Shot 2] At 00:03.708, both halves cut together to ...').")
|
||||
"How to write a duet prompt (TSC, newer strategy): H3 has no idea what is pinned and what is noised, it reads one "
|
||||
"description of one video. Describe the finished split screen as plain footage: never write 'kept footage', 'kept "
|
||||
"exactly', 'generated', 'preserved', 'in sync with', 'seamlessly'. The pinned clip gets no tag and no retention "
|
||||
"line: name its region by where it is plus what is seen there, in one clause ('the top quarter shows a man dancing "
|
||||
"in a small bedroom'; {panel_region} / {video_region} give the region names for the current layout, {layout} the "
|
||||
"whole split-screen sentence). Write the other region as its own scene in a real place with concrete details. "
|
||||
"Spell out the motion beat by beat, with times for the big moments (cuts, strikes, jumps, a line), on the video's "
|
||||
"clock. The soundscape is the sounds of the new scene. Cuts: '[Shot 2] At MM:SS.mmm, both halves cut together to ...'.")
|
||||
|
||||
DUET_PROMPT_TEMPLATE = """subject_definitions:
|
||||
<Subject 1> is the woman whose appearance comes from <Picture 1>: {skin, face shape, eyes, brows, nose, lips}, {hair colour, length and cut}, wearing {the outfit, piece by piece}.
|
||||
<Subject 1> is {name or plain description} whose appearance comes from <Picture 1>: {concrete look words}, wearing {outfit piece by piece}.
|
||||
|
||||
summary:
|
||||
[reference generation] The target video is a split screen: the kept footage beside <Subject 1>, who moves in sync with it, in the same room.
|
||||
[reference generation] The target video is a split screen: {panel_region} shows {what is seen in the source, one clause}, and {video_region} shows <Subject 1> doing the same movements at the same moments, in {place}.
|
||||
|
||||
retention_analysis:
|
||||
<Subject 1> (appears in [Shot 1]): fully_preserved - {her key look words} are retained.
|
||||
The kept footage: fully_preserved - the panel is kept exactly.
|
||||
<Subject 1> (appears in [Shot 1]): fully_preserved - {key look words} are retained.
|
||||
|
||||
detailed_description:
|
||||
The target video is in a realistic style, as {the medium, e.g. handheld vertical smartphone footage under soft daylight}. {layout}
|
||||
The target video is in a {realistic / 3D CG / anime} style, as {the medium of the source clip}. A split screen divided by a thin straight {vertical / horizontal} line: {panel_region} shows {what is seen there, one clause}; {video_region} shows <Subject 1> performing the same movements at the same moments, in {place with concrete details}.
|
||||
|
||||
[Shot 1] {Camera distance, angle and movement.} <Subject 1>, her face from <Picture 1> with {two face words}, {two hair or outfit words}, {what she does, one sentence per real action}.
|
||||
[Shot 1] {Camera distance, angle and movement as the source shows it}. <Subject 1>, the face from <Picture 1>, {what they do, one sentence per real action}. At {00:01.500} {big beat}.
|
||||
|
||||
overall_soundscape:
|
||||
{The sounds of the scene and when they happen.}
|
||||
{The sounds of the new scene and when they happen.}
|
||||
|
||||
non_diegetic_music:
|
||||
None."""
|
||||
@@ -63,27 +64,26 @@ CREDIT = ("Credits: the initial idea for this H3 node came from TSC's latent-pin
|
||||
"New here: the shifted RoPE layout (the video keeps its own grid and the panel sits past its edge, with an "
|
||||
"optional gap), dynamic references, task prompts and the shot-loop node.")
|
||||
|
||||
PROMPT_EXAMPLE = """Example (character swap, source clip pinned on the left, one face picture and one full-body picture):
|
||||
PROMPT_EXAMPLE = """Example (character swap, source clip pinned on the left half, one face picture and one full-body picture):
|
||||
|
||||
subject_definitions:
|
||||
<Subject 1> is the woman whose appearance comes from <Picture 1> and <Picture 2>: fair skin, a narrow oval face, grey-green eyes, light brown hair in a low ponytail, wearing a white long-sleeved top under a navy denim apron.
|
||||
<Subject 1> is a young woman whose appearance comes from <Picture 1> and <Picture 2>: fair skin, a narrow oval face, grey-green eyes, light brown hair in a low ponytail, wearing a white long-sleeved top under a navy denim apron.
|
||||
|
||||
summary:
|
||||
[reference generation] The target video is a split screen: the kept footage beside <Subject 1>, who moves in sync with it, in the same bedroom.
|
||||
[reference generation] The target video is a split screen: the left half shows a man talking to a phone camera in a bedroom, and the right half shows <Subject 1> doing the same movements at the same moments, in a sunny kitchen with white tiles.
|
||||
|
||||
retention_analysis:
|
||||
<Subject 1> (appears in [Shot 1], [Shot 2]): fully_preserved - her face, ponytail, white top and navy apron are retained.
|
||||
The kept footage: fully_preserved - the panel is kept exactly.
|
||||
|
||||
detailed_description:
|
||||
The target video is in a realistic style, as handheld vertical smartphone footage under soft daylight. {layout}
|
||||
The target video is in a realistic style, as handheld vertical smartphone footage under soft daylight. A split screen divided by a thin straight vertical line: the left half shows a man talking to a phone camera in a bedroom; the right half shows <Subject 1> performing the same movements at the same moments, in a sunny kitchen with white tiles and a window behind her.
|
||||
|
||||
[Shot 1] A medium close-up at chest height, the phone steady at eye level. <Subject 1>, her face from <Picture 1> with grey-green eyes and soft pink lips, her light brown ponytail and navy apron, talks to the camera with small nods.
|
||||
[Shot 1] A medium close-up at chest height, the phone steady at eye level. <Subject 1>, her face from <Picture 1> with grey-green eyes and soft pink lips, talks to the camera with small nods. At 00:01.800 she laughs and tilts her head back.
|
||||
|
||||
[Shot 2] At 00:03.708, both halves cut together to a closer framing. <Subject 1>, her face from <Picture 1>, the white sleeves and apron straps visible, raises one open hand beside her mouth and smiles.
|
||||
|
||||
overall_soundscape:
|
||||
Her voice speaking to the camera in a quiet bedroom.
|
||||
Her voice speaking to the camera, a kettle starting to hiss in the kitchen at 00:02.500.
|
||||
|
||||
non_diegetic_music:
|
||||
None."""
|
||||
@@ -205,30 +205,66 @@ def panel_mask(info: dict, latent_t: int, hold: str, target_mask: torch.Tensor |
|
||||
return m
|
||||
|
||||
|
||||
def layout_text(info: dict, rope_mode: str = "canvas") -> str:
|
||||
"""How the kept region is named in a prompt (TSC's wording: 42-58% of the canvas is 'the LEFT half').
|
||||
shifted RoPE: the video sits on its own grid, so a split-screen sentence would make the model split the video
|
||||
itself; the sentence then only says that the whole frame follows the kept footage."""
|
||||
if rope_mode == "shifted":
|
||||
return "The whole frame shows the generated video, moving in sync with the kept footage frame by frame."
|
||||
_SHARES = ((1 / 2, "half"), (1 / 3, "third"), (2 / 3, "two thirds"), (1 / 4, "quarter"), (3 / 4, "three quarters"),
|
||||
(1 / 5, "fifth"), (4 / 5, "four fifths"))
|
||||
|
||||
|
||||
def _share_name(side: str, share: float) -> str:
|
||||
for v, w in _SHARES:
|
||||
if abs(share - v) <= 0.035:
|
||||
return f"the {side} {w}"
|
||||
return f"the {side} {round(share * 100)}% of the frame"
|
||||
|
||||
|
||||
def region_names(info: dict) -> tuple[str, str, str]:
|
||||
"""(pinned region, generated region, divider) as plain words: ('the top quarter', 'the bottom three quarters',
|
||||
'horizontal'). TSC: H3 does not know what is pinned, so the regions are named by where they are."""
|
||||
pos = info["position"]
|
||||
side = {"left": "LEFT", "right": "RIGHT", "top": "TOP", "bottom": "BOTTOM"}[pos]
|
||||
other = {"left": "RIGHT", "right": "LEFT", "top": "BOTTOM", "bottom": "TOP"}[pos]
|
||||
other = {"left": "right", "right": "left", "top": "bottom", "bottom": "top"}[pos]
|
||||
horizontal = pos in ("left", "right")
|
||||
strip = info["strip_w"] if horizontal else info["strip_h"]
|
||||
share = strip / max(1, strip + (info["w"] if horizontal else info["h"]))
|
||||
return _share_name(pos, share), _share_name(other, 1 - share), "vertical" if horizontal else "horizontal"
|
||||
|
||||
|
||||
def _verb(region: str) -> str:
|
||||
return "show" if region.endswith(("thirds", "quarters", "fifths")) else "shows"
|
||||
|
||||
|
||||
def layout_text(info: dict, rope_mode: str = "canvas", seen: str = "", who: str = "", place: str = "") -> str:
|
||||
"""The split-screen sentence of detailed_description, in TSC's newer wording: the finished frame described as plain
|
||||
footage, each region by where it is and what is seen there (no 'kept footage', 'generated', 'in sync').
|
||||
shifted RoPE: the video sits on its own grid, so there is no split screen to describe."""
|
||||
if rope_mode == "shifted":
|
||||
return ""
|
||||
pin, gen, line = region_names(info)
|
||||
seen = seen or "the original performance"
|
||||
horizontal = info["position"] in ("left", "right")
|
||||
strip = info["strip_w"] if horizontal else info["strip_h"]
|
||||
total = strip + (info["w"] if horizontal else info["h"])
|
||||
share = strip / total
|
||||
word = "half" if 0.42 <= share <= 0.58 else None
|
||||
kept = f"the {side} half" if word else f"the {side} {round(share * 100)}% of the frame (a narrow strip)"
|
||||
gen = f"the {other} half" if word else f"the {other} {round((1 - share) * 100)}% of the frame"
|
||||
line = "vertical" if horizontal else "horizontal"
|
||||
text = (f"A split screen divided by a thin straight {line} line: {kept} is the kept footage; "
|
||||
f"{gen} is generated and moves in sync with it.")
|
||||
if (1 - share) > 1.3 * share:
|
||||
text += (f" The generated area is LARGER than the kept panel ({round((1 - share) * 100)}% of the frame against "
|
||||
f"{round(share * 100)}%): restage the scene at that larger size, the same shots, framing proportions and "
|
||||
"timing, a bigger picture, not a pixel-for-pixel mirror.")
|
||||
return text
|
||||
wider = ", a wider view of the same action" if (total - strip) > 1.3 * strip else ""
|
||||
doing = f"{who} performing the same movements" if who else "the same movements"
|
||||
return (f"A split screen divided by a thin straight {line} line: {pin} {_verb(pin)} {seen}; {gen} {_verb(gen)} "
|
||||
f"{doing} at the same moments{', in ' + place if place else ''}{wider}.")
|
||||
|
||||
|
||||
def fill_layout(text: str, info: dict | None, rope_mode: str = "canvas") -> str:
|
||||
"""{layout}, {panel_region}, {video_region}, {split_line} and {wider} of a prompt, from the canvas layout."""
|
||||
if info is None or rope_mode == "shifted":
|
||||
for k in ("{layout}", "{panel_region}", "{split_line}", "{wider}"):
|
||||
text = text.replace(k, "")
|
||||
return text.replace("{video_region} shows", "the frame shows").replace("{video_region}", "the frame").replace(" ", " ")
|
||||
pin, gen, line = region_names(info)
|
||||
horizontal = info["position"] in ("left", "right")
|
||||
strip = info["strip_w"] if horizontal else info["strip_h"]
|
||||
total = strip + (info["w"] if horizontal else info["h"])
|
||||
wider = ", a wider view of the same action" if (total - strip) > 1.3 * strip else ""
|
||||
who = "<Subject 1>" if "<Subject 1>" in text else ""
|
||||
text = text.replace("{layout}", layout_text(info, rope_mode, who=who))
|
||||
text = text.replace("{split_line}", line).replace("{wider}", wider)
|
||||
for k, v in (("{panel_region}", pin), ("{video_region}", gen)):
|
||||
text = text.replace(k + " shows", f"{v} {_verb(v)}").replace(k, v)
|
||||
return text.replace(" ", " ")
|
||||
|
||||
|
||||
ROPE_MODES = ["canvas", "shifted"]
|
||||
@@ -455,56 +491,59 @@ class BFSH3SidePanelCrop:
|
||||
|
||||
|
||||
def build_prompt(task: str, instruction: str, n_pictures: int, n_videos: int, rope_mode: str = "canvas") -> str:
|
||||
"""A six-section REF2VA prompt for a duet task. `{layout}` stays in it and is filled at render time.
|
||||
shifted: no split-screen wording (it makes the model split the video itself)."""
|
||||
"""A six-section REF2VA duet prompt without a VLM, in TSC's newer wording: the finished split screen described as
|
||||
plain footage ({panel_region} / {video_region} / {layout} are filled from the canvas at render time). Without a VLM
|
||||
the motion cannot be spelled out: write it into `instruction`, or connect a VLM."""
|
||||
inst = instruction.strip().rstrip(".")
|
||||
pics = [f"<Picture {i}>" for i in range(1, n_pictures + 1)]
|
||||
pics_txt = " and ".join(pics) if len(pics) <= 2 else ", ".join(pics[:-1]) + " and " + pics[-1]
|
||||
seen = f" as shown in {pics_txt}" if pics else ""
|
||||
sync = "every movement, gesture and expression in sync with the kept footage"
|
||||
split = rope_mode != "shifted"
|
||||
if task == "character swap":
|
||||
look = f"whose appearance comes from {pics_txt}" if pics else "the new person"
|
||||
defs = f"<Subject 1> is the person {look}" + (f": {inst}." if inst else ".")
|
||||
summary = ("[reference generation] The target video is a split screen: the kept footage beside <Subject 1>, "
|
||||
"who moves in sync with it, in the same place.")
|
||||
summary = ("[reference generation] The target video is a split screen: {panel_region} shows the original "
|
||||
"performance, and {video_region} shows <Subject 1> doing the same movements at the same moments, in "
|
||||
"the same place." if split else
|
||||
"[reference generation] The target video shows <Subject 1> doing the movements of the original "
|
||||
"performance at the same moments, in the same place.")
|
||||
keep = (f"<Subject 1> (appears in [Shot 1]): fully_preserved - the face, hair and outfit"
|
||||
f"{' from ' + pics[0] if pics else ''} are retained.")
|
||||
face = f", the face from {pics[0]}" if pics else ""
|
||||
shot = (f"[Shot 1] The same framing and camera movement as the kept footage. <Subject 1>{face}"
|
||||
f"{', ' + inst if inst else ''}, performs {sync}.")
|
||||
style = "The target video keeps the medium and the light of the kept footage."
|
||||
shot = (f"[Shot 1] The same framing and camera movement. <Subject 1>{face}{', ' + inst if inst else ''}, "
|
||||
"performs every movement, gesture and expression of the original performance at the same moments.")
|
||||
style = "The target video is in a realistic style, as the same footage."
|
||||
sound = "The sounds <Subject 1>'s movements make in the place, at the same moments."
|
||||
else:
|
||||
what = {"style": f"restyled as {inst or 'the style'}{seen}",
|
||||
"setting": f"moved to {inst or 'the place'}{seen}",
|
||||
"appearance": f"with the performer now {inst or 'changed'}{seen}",
|
||||
"lighting / weather": f"under {inst or 'the new light'}{seen}"}[task]
|
||||
defs = (f"<Subject 1> is {inst or 'the reference'}{seen}." if pics else "")
|
||||
summary = (f"[reference generation] The target video is a split screen: the kept footage beside the same "
|
||||
f"performance, {what}.")
|
||||
keep = f"The performance and camera: fully_preserved - {sync}."
|
||||
shot = f"[Shot 1] The same framing, people and camera movement as the kept footage, {what}, {sync}."
|
||||
summary = (f"[reference generation] The target video is a split screen: {{panel_region}} shows the original "
|
||||
f"performance, and {{video_region}} shows the same performance, {what}, the same movements at the "
|
||||
"same moments." if split else
|
||||
f"[reference generation] The target video shows the original performance, {what}.")
|
||||
keep = f"The performance: fully_preserved - every movement and expression, at the same moments."
|
||||
shot = f"[Shot 1] The same framing, people and camera movement, {what}, every movement at the same moments."
|
||||
style = (f"The target video is in {inst}{'' if 'style' in inst.lower() else ' style'}." if task == "style" and inst
|
||||
else "The target video keeps the medium of the kept footage.")
|
||||
else "The target video is in a realistic style, as the same footage.")
|
||||
sound = "The sounds of the scene, at the same moments."
|
||||
sections = [
|
||||
"subject_definitions:\n" + (defs or "The kept footage is the motion reference of the split screen."),
|
||||
"subject_definitions:\n" + (defs or "<Subject 1> is the performer of the original performance."),
|
||||
"summary:\n" + summary,
|
||||
"retention_analysis:\n" + keep + "\nThe kept footage: fully_preserved - the panel is kept exactly.",
|
||||
"detailed_description:\n" + style + " {layout}\n\n" + shot,
|
||||
"overall_soundscape:\nThe sounds of the kept footage, in sync.",
|
||||
"retention_analysis:\n" + keep,
|
||||
"detailed_description:\n" + style + (" {layout}" if split else "") + "\n\n" + shot,
|
||||
"overall_soundscape:\n" + sound,
|
||||
"non_diegetic_music:\nNone.",
|
||||
]
|
||||
text = "\n\n".join(sections)
|
||||
if rope_mode == "shifted":
|
||||
text = (text.replace("The target video is a split screen: the kept footage beside ", "The target video shows ")
|
||||
.replace("who moves in sync with it", "who moves in sync with the kept footage")
|
||||
.replace("the motion reference of the split screen", "the motion reference"))
|
||||
return text
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def h3_render(model, clip, vae, audio_vae, prompt, refs, width, height, length, steps, sampler_name, scheduler,
|
||||
seed, panel=None, guide=None, guide_frame_idx=0, position="left", size=1.0, fit="contain", gap=0,
|
||||
panel_noise=0.0, hold="all frames", ref_image_size="match", decode_canvas=False,
|
||||
rope_mode="canvas", rope_gap=0.0, first_frame=None):
|
||||
rope_mode="canvas", rope_gap=0.0, first_frame=None, history=None, history_src=None):
|
||||
"""Reference to Video -> optional pinned panel -> optional aligned guide -> sample -> crop -> decode.
|
||||
|
||||
`refs` holds the Reference to Video inputs ({"ref_images": {...}, "ref_videos": {...}, ...}). Without a
|
||||
@@ -514,12 +553,22 @@ def h3_render(model, clip, vae, audio_vae, prompt, refs, width, height, length,
|
||||
from comfy_extras.nodes_minimax_h3 import MiniMaxH3AddGuide, MiniMaxH3ReferenceToVideo
|
||||
from comfy_extras.nodes_custom_sampler import Guider_Basic, Noise_RandomNoise, SamplerCustomAdvanced
|
||||
|
||||
text = layout_text(make_info(width, height, position, size, gap * PATCH_PX), rope_mode) if panel is not None else ""
|
||||
prompt = prompt.replace("{layout}", text)
|
||||
linfo = make_info(width, height, position, size, gap * PATCH_PX) if panel is not None else None
|
||||
text = layout_text(linfo, rope_mode) if linfo is not None else ""
|
||||
prompt = fill_layout(prompt, linfo, rope_mode)
|
||||
refs = {k: v for k, v in (refs or {}).items() if v}
|
||||
# with a panel the refs are sized for the whole canvas, as in TSC's duet (Reference to Video at the canvas size):
|
||||
# 'match' scales refs to the generation's area, and sizing them for the video half alone shrinks them a lot
|
||||
rw, rh = width, height
|
||||
if panel is not None:
|
||||
ci = make_info(width, height, position, size, gap * PATCH_PX)
|
||||
rw, rh = width + ci["strip_w"] * 16, height + ci["strip_h"] * 16
|
||||
positive, latent = MiniMaxH3ReferenceToVideo.execute(
|
||||
clip=clip, prompt=prompt, width=width, height=height, length=length, ref_image_size=ref_image_size,
|
||||
clip=clip, prompt=prompt, width=rw, height=rh, length=length, ref_image_size=ref_image_size,
|
||||
vae=vae, audio_vae=audio_vae, **refs).args[:2]
|
||||
if (rw, rh) != (width, height):
|
||||
from comfy_extras.nodes_minimax_h3 import _empty_av_latent
|
||||
latent = _empty_av_latent(width, height, length)[0]
|
||||
if first_frame is not None: # anchored at frame 0; the panel step moves it onto the canvas
|
||||
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae,
|
||||
image=first_frame[:1]).args[0]
|
||||
@@ -531,6 +580,28 @@ def h3_render(model, clip, vae, audio_vae, prompt, refs, width, height, length,
|
||||
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=guide_frame_idx,
|
||||
vae=vae, image=guide).args[0]
|
||||
|
||||
if history is not None: # continuity 'history': the previous result's end just before frame 0 (added last)
|
||||
if info is not None and rope_mode == "shifted":
|
||||
print("[BFS H3 Duet] history is not used with rope_mode 'shifted' (use canvas)", flush=True)
|
||||
else:
|
||||
hist = history[..., :3].float().cpu()
|
||||
if tuple(hist.shape[1:3]) != (height, width):
|
||||
hist = torch.nn.functional.interpolate(hist.movedim(-1, 1), size=(height, width), mode="bilinear",
|
||||
align_corners=False).movedim(1, -1)
|
||||
if info is not None: # the canvas: the source frames under the history go into the panel strip
|
||||
under = history_src if history_src is not None and history_src.shape[0] >= hist.shape[0] else hist
|
||||
strip = strip_frames(under[-hist.shape[0]:], list(range(hist.shape[0])), width, height, position,
|
||||
size, gap * PATCH_PX, fit)
|
||||
hist = compose(hist, strip, position)
|
||||
del strip
|
||||
try:
|
||||
from .bfs_h3_history import add_history
|
||||
except ImportError:
|
||||
from bfs_h3_history import add_history
|
||||
positive, n = add_history(positive, vae, hist)
|
||||
del hist
|
||||
if n:
|
||||
print(f"[BFS H3 Duet] history: the previous {n} frames go before frame 0 on H3's timeline", flush=True)
|
||||
if info is not None and rope_mode == "shifted":
|
||||
model = patch_model_rope(model, info, rope_gap)
|
||||
guider = Guider_Basic(model)
|
||||
@@ -641,32 +712,144 @@ 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 "
|
||||
"reference. The output is the generated half only, so the static never shows."),
|
||||
_io.Image.Input("setting", optional=True, tooltip=
|
||||
"The place picture (BFS Setting Picture: a frame of the clip with the person in static). It becomes "
|
||||
"the last <Picture n> and the prompt says it shows the setting (write {setting} to place its tag)."),
|
||||
_io.Clip.Input("vlm", optional=True, tooltip=
|
||||
"Optional VLM (CLIPLoader with a Qwen3-VL text encoder) for the prompt. Empty prompt: it writes one "
|
||||
"from the task + instruction (TSC's rules). Prompt with {...} fields (TSC's template): it fills them "
|
||||
"from the clip and the pictures. Prompt without fields: used as written."),
|
||||
_io.Int.Input("vlm_max_tokens", default=1024, min=128, max=4096, optional=True, advanced=True),
|
||||
],
|
||||
outputs=[_io.Image.Output(display_name="images"), _io.Audio.Output(display_name="audio"),
|
||||
_io.Image.Output(display_name="canvas"), _io.String.Output(display_name="layout_text"),
|
||||
_io.Latent.Output(display_name="latent"), _io.String.Output(display_name="prompt")],
|
||||
_io.Latent.Output(display_name="latent"), _io.String.Output(display_name="prompt"),
|
||||
_io.Image.Output(display_name="side_by_side", tooltip="Comparison like BFS Shot Join's: "
|
||||
"original | references | result, the info on top and the prompt below.")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
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):
|
||||
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(_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
|
||||
ref_images = {k: v for k, v in (ref_images or {}).items() if v is not None}
|
||||
ref_videos = {k: v for k, v in (ref_videos or {}).items() if v is not None}
|
||||
text = _resolve_prompt(prompt, task, instruction, len(ref_images), len(ref_videos))
|
||||
text = _shot_loop().duet_prompt(vlm, prompt, guide if guide is not None else raw_panel, list(ref_images.values()),
|
||||
task, instruction, int(vlm_max_tokens), rope_mode, setting)
|
||||
text = _resolve_prompt(text, task, instruction, len(ref_images), len(ref_videos))
|
||||
if setting is not None: # the place picture is the last <Picture n>
|
||||
ref_images = {f"ref_image_{i}": v for i, v in enumerate([*ref_images.values(), setting])}
|
||||
text = with_setting(text, len(ref_images), task == "character swap" or "<Subject 1>" in text)
|
||||
refs = {"ref_images": ref_images, "ref_videos": ref_videos,
|
||||
"ref_video_audios": {k: v for k, v in (ref_video_audios or {}).items() if v is not None},
|
||||
"ref_audios": {k: v for k, v in (ref_audios or {}).items() if v is not None}}
|
||||
return _io.NodeOutput(*h3_render(model, clip, vae, audio_vae, text, refs, width, height, length, steps,
|
||||
sampler_name, scheduler, seed, panel, guide, guide_frame_idx, position,
|
||||
size, fit, gap, panel_noise, hold, ref_image_size, decode_canvas,
|
||||
rope_mode, rope_gap))
|
||||
out = h3_render(model, clip, vae, audio_vae, text, refs, width, height, length, steps,
|
||||
sampler_name, scheduler, seed, panel, guide, guide_frame_idx, position,
|
||||
size, fit, gap, panel_noise, hold, ref_image_size, decode_canvas,
|
||||
rope_mode, rope_gap)
|
||||
return _io.NodeOutput(*out, side_by_side(guide if guide is not None else raw_panel, out[0],
|
||||
list(ref_images.values()), out[5]))
|
||||
|
||||
|
||||
def _shot_loop():
|
||||
try:
|
||||
from . import bfs_shot_loop as SL
|
||||
except ImportError:
|
||||
import bfs_shot_loop as SL
|
||||
return SL
|
||||
|
||||
|
||||
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)
|
||||
if s >= 1:
|
||||
return frames[..., :3].float()
|
||||
return torch.nn.functional.interpolate(frames[..., :3].movedim(-1, 1).float(), size=(max(32, int(H * s) // 2 * 2),
|
||||
max(32, int(W * s) // 2 * 2)), mode="bilinear", align_corners=False).movedim(1, -1)
|
||||
|
||||
|
||||
def static_over_panel(frames: torch.Tensor | None, what: str) -> torch.Tensor | None:
|
||||
"""TSC's fix for same-world swaps: the face (or the whole person) of the pinned clip covered in TV static, every
|
||||
frame, so the generated half cannot copy it and takes the identity from the reference. Tracked by SAM 3."""
|
||||
if frames is None or not what or what == "off":
|
||||
return frames
|
||||
SL = _shot_loop()
|
||||
spec = dict(SL.DEFAULT_MASK, text=what, max_objects=4, threshold=0.4)
|
||||
masks = SL.segment_frames(_small(frames), spec)
|
||||
out = torch.cat([SL.tv_static(frames[i:i + 1, ..., :3].float(), masks[min(i, masks.shape[0] - 1)],
|
||||
grow=0.05 if what == "face" else 0.03, seed=i) for i in range(frames.shape[0])], 0)
|
||||
cov = float((masks > 0.5).float().mean())
|
||||
print(f"[BFS H3 Duet] panel_static '{what}': covered {cov * 100:.1f}% of the panel (SAM 3, {frames.shape[0]} frames)",
|
||||
flush=True)
|
||||
return out
|
||||
|
||||
|
||||
def setting_from_clip(frames: torch.Tensor, position: float = 0.5, subject: str = "person") -> torch.Tensor:
|
||||
"""TSC's setting picture: one frame of the clip with the subject covered in TV static, so the generated half sees the
|
||||
place (light, framing, background) in full detail and not the person."""
|
||||
SL = _shot_loop()
|
||||
k = int(round((frames.shape[0] - 1) * min(1.0, max(0.0, position))))
|
||||
img = frames[k:k + 1, ..., :3].float()
|
||||
m = SL.segment_frames(_small(img), dict(SL.DEFAULT_MASK, text=subject or "person", max_objects=8))[0]
|
||||
return SL.tv_static(img, m)
|
||||
|
||||
|
||||
def with_setting(text: str, k: int, swap: bool) -> str:
|
||||
"""{setting} becomes <Picture k>; otherwise TSC's sentence goes at the end of subject_definitions."""
|
||||
return _shot_loop().add_setting(text, k, swap)
|
||||
|
||||
|
||||
def side_by_side(source: torch.Tensor | None, result: torch.Tensor, refs: list | None = None, prompt: str = "",
|
||||
label: str = "BFS H3 Duet") -> torch.Tensor:
|
||||
"""The same comparison as BFS Shot Join's: original | references | result, the info on top and the prompt below."""
|
||||
try:
|
||||
from .bfs_shot_loop import comparison_video
|
||||
except ImportError:
|
||||
from bfs_shot_loop import comparison_video
|
||||
res = result[..., :3].float().cpu()
|
||||
L = res.shape[0]
|
||||
refs = [r for r in (refs or []) if r is not None]
|
||||
shot = {"fps": 24.0, "start": 0, "end": L, "gen_length": L, "prompt": prompt,
|
||||
"ref": refs[0] if refs else None, "ref2": refs[1] if len(refs) > 1 else None}
|
||||
orig = source[..., :3].float().cpu() if source is not None else None
|
||||
return comparison_video(res, [(shot, L, orig)], label, height=min(720, res.shape[1]), show_mask=False)
|
||||
|
||||
|
||||
if _io is not None:
|
||||
@@ -712,7 +895,9 @@ if _io is not None:
|
||||
_io.Float.Input("rope_gap", default=0.0, min=0.0, max=256.0, step=1.0, advanced=True),
|
||||
_io.Combo.Input("ref_image_size", options=["match", "max"], default="match", advanced=True),
|
||||
_io.Int.Input("vlm_max_tokens", default=1024, min=128, max=4096, advanced=True),
|
||||
_io.Clip.Input("vlm", optional=True, tooltip="Optional VLM (CLIPLoader with a Qwen3-VL text encoder) that writes the prompt."),
|
||||
_io.Clip.Input("vlm", optional=True, tooltip="Optional VLM (CLIPLoader with a Qwen3-VL text encoder). Empty prompt: "
|
||||
"it writes one from the task + instruction. Prompt with {...} fields: it fills them from the "
|
||||
"clip and the pictures. Prompt without fields: used as written."),
|
||||
_io.Model.Input("model", optional=True, tooltip="Needed for rope_mode = shifted."),
|
||||
_io.Vae.Input("audio_vae", optional=True),
|
||||
_io.Image.Input("guide", optional=True, tooltip="Optional aligned latent guide in the video area."),
|
||||
@@ -723,6 +908,15 @@ 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 "
|
||||
"reference. The output is the generated half only, so the static never shows."),
|
||||
_io.Image.Input("setting", optional=True, tooltip=
|
||||
"The place picture (BFS Setting Picture: a frame of the clip with the person in static). It becomes "
|
||||
"the last <Picture n> and the prompt says it shows the setting (write {setting} to place its tag)."),
|
||||
],
|
||||
outputs=[_io.Conditioning.Output(display_name="positive"), _io.Latent.Output(display_name="latent"),
|
||||
_io.Model.Output(display_name="model"), PANEL_INFO_T.Output(display_name="panel_info"),
|
||||
@@ -732,28 +926,30 @@ if _io is not None:
|
||||
@classmethod
|
||||
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):
|
||||
audio_vae=None, guide=None, ref_images=None, keep_mask=None, keep_video=None, panel_static="off",
|
||||
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(_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 = prompt if prompt and prompt.strip() else ""
|
||||
if not text and vlm is not None:
|
||||
try:
|
||||
from .bfs_shot_loop import write_duet_prompt
|
||||
except ImportError:
|
||||
write_duet_prompt = None
|
||||
if write_duet_prompt:
|
||||
idx = sorted(set(int(round(x)) for x in torch.linspace(0, panel.shape[0] - 1, min(4, panel.shape[0])).tolist()))
|
||||
text = write_duet_prompt(vlm, [panel[i:i + 1] for i in idx], refs, task, instruction, int(vlm_max_tokens),
|
||||
rope_mode)
|
||||
text = _shot_loop().duet_prompt(vlm, prompt, raw_panel, refs, task, instruction, int(vlm_max_tokens),
|
||||
rope_mode, setting)
|
||||
if not text:
|
||||
text = build_prompt(task if task != "custom" else "appearance", instruction, len(refs), 0, rope_mode)
|
||||
if setting is not None: # the place picture is the last <Picture n>
|
||||
refs = refs + [setting]
|
||||
text = with_setting(text, len(refs), task == "character swap" or "<Subject 1>" in text)
|
||||
info = make_info(width, height, position, size, gap * PATCH_PX)
|
||||
text = text.replace("{layout}", layout_text(info, rope_mode))
|
||||
positive, latent = MiniMaxH3ReferenceToVideo.execute(
|
||||
clip=clip, prompt=text, width=width, height=height, length=length, ref_image_size=ref_image_size, vae=vae,
|
||||
audio_vae=audio_vae, ref_images={f"ref_image_{i}": r for i, r in enumerate(refs)} or None).args[:2]
|
||||
text = fill_layout(text, info, rope_mode)
|
||||
rw, rh = width + info["strip_w"] * 16, height + info["strip_h"] * 16 # refs sized for the canvas
|
||||
positive = MiniMaxH3ReferenceToVideo.execute(
|
||||
clip=clip, prompt=text, width=rw, height=rh, length=length, ref_image_size=ref_image_size, vae=vae,
|
||||
audio_vae=audio_vae, ref_images={f"ref_image_{i}": r for i, r in enumerate(refs)} or None).args[0]
|
||||
from comfy_extras.nodes_minimax_h3 import _empty_av_latent
|
||||
latent = _empty_av_latent(width, height, length)[0]
|
||||
positive, latent, info, preview, _ = BFSH3SidePanel().apply(
|
||||
positive, latent, vae, panel, position, size, fit, gap, hold, panel_noise, guide, 0,
|
||||
keep_video=keep_video, keep_mask=keep_mask)
|
||||
@@ -799,7 +995,11 @@ 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}),
|
||||
"vlm": ("CLIP", {"tooltip": "Optional VLM (Qwen3-VL CLIPLoader): fills {details} in the shot's "
|
||||
"prompt from its references when the planner left it."})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "AUDIO", "IMAGE", "STRING", "STRING")
|
||||
@@ -813,7 +1013,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, vlm=None):
|
||||
imgs = {}
|
||||
if shot.get("ref") is not None:
|
||||
imgs["ref_image_1"] = shot["ref"]
|
||||
@@ -821,31 +1021,65 @@ class BFSShotH3Duet:
|
||||
imgs[f"ref_image_{len(imgs) + 1}"] = shot["ref2"]
|
||||
duet, guided = mode != self.MODES[1], mode != self.MODES[0]
|
||||
try:
|
||||
from .bfs_shot_loop import chain_image, remember_result
|
||||
from .bfs_shot_loop import chain_image, remember_result, shot_history
|
||||
except ImportError:
|
||||
chain_image = remember_result = None
|
||||
chain_image = remember_result = shot_history = None
|
||||
prev = chain_image(shot) if chain_image else None
|
||||
if prev is not None and shot.get("chain") == "reference":
|
||||
imgs[f"ref_image_{len(imgs) + 1}"] = prev
|
||||
text = _resolve_prompt(shot.get("prompt", ""), task, instruction, len(imgs), 0)
|
||||
if "{details}" in text:
|
||||
text = _shot_loop().fill_details_now(text, vlm, [shot.get("ref"), shot.get("ref2")],
|
||||
f"[BFS Shot H3 Duet] shot {int(shot.get('index', 0)) + 1}")
|
||||
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,
|
||||
first_frame=prev if prev is not None and shot.get("chain") == "first frame" else None)
|
||||
first_frame=prev if prev is not None and shot.get("chain") == "first frame" else None,
|
||||
history=hist, history_src=hsrc)
|
||||
del hist, hsrc
|
||||
if remember_result:
|
||||
remember_result(shot, out[0]) # the next shot can continue from it (auto loop)
|
||||
return out[:4] + (out[5],)
|
||||
|
||||
|
||||
class BFSSettingPicture:
|
||||
"""TSC's setting picture: a frame of the clip with the person covered in TV static (the place, not the person)."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {
|
||||
"images": ("IMAGE", {"tooltip": "The source clip (or one frame)."}),
|
||||
"position": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05,
|
||||
"tooltip": "Which frame: 0 = first, 0.5 = middle, 1 = last."}),
|
||||
"subject": ("STRING", {"default": "person", "tooltip": "What SAM 3 covers with static (e.g. person, man, dog)."}),
|
||||
}}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("setting",)
|
||||
FUNCTION = "make"
|
||||
CATEGORY = "BFS/MiniMax H3"
|
||||
DESCRIPTION = ("TSC's setting picture for a duet: one frame of the clip with the person covered in TV static. Give it "
|
||||
"to BFS H3 Duet's `setting` input: the generated half then takes the place, light and framing from "
|
||||
"it instead of from the character picture.")
|
||||
|
||||
def make(self, images, position, subject):
|
||||
return (setting_from_clip(images, position, subject),)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"BFSSettingPicture": BFSSettingPicture,
|
||||
"BFSShotH3Duet": BFSShotH3Duet,
|
||||
"BFSH3SidePanel": BFSH3SidePanel,
|
||||
"BFSH3SidePanelCrop": BFSH3SidePanelCrop,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"BFSSettingPicture": "BFS Setting Picture (place, person in static)",
|
||||
"BFSShotH3Duet": "BFS Shot H3 Duet (render one shot)",
|
||||
"BFSH3SidePanel": "BFS H3 Side Panel (virtual reference panel)",
|
||||
"BFSH3SidePanelCrop": "BFS H3 Side Panel Crop",
|
||||
|
||||
+1474
-175
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
"""BFS Shot Noise: the sampler's starting noise, with optional structure. Every option is off at 0, and with all of
|
||||
them off the output is exactly ComfyUI's RandomNoise for the same seed.
|
||||
|
||||
What can be shaped (the video part of the latent only; the audio noise is always plain):
|
||||
seed per shot same for every shot / increment / random per shot / overrides (as BFS Shot Seed)
|
||||
shared_base the COARSE pattern of the noise (block averages on a fixed grid) comes from a base seed instead
|
||||
of the shot's seed; the fine detail stays the shot's own. The coarse pattern decides layout and
|
||||
who the subject becomes in the first steps, so shots of one video (or re-rolls of one clip)
|
||||
agree on it. The grid is defined in blocks, not in latent cells, so the same base seed gives the
|
||||
same coarse pattern at any resolution, and along the timeline it is one long tape: a shot reads
|
||||
the part at its own position, whatever its length.
|
||||
distribution gaussian (default) / laplacian / student-t / uniform, all at unit variance
|
||||
spectrum_tilt 1/f^alpha spectrum: more (or less) energy in the large blobs ("fractal" noise)
|
||||
pyramid coarser layers of noise added on top
|
||||
static_share a part of the noise that is the same in every frame (temporally correlated noise)
|
||||
guide_structure outside the mask, a little of the guide video's own latent is mixed into the noise, so the
|
||||
background starts closer to the guide; inside the mask the noise stays free (no pull toward
|
||||
the original person)
|
||||
ref_tint inside the mask, the noise leans toward the reference picture's average latent (its colours)
|
||||
|
||||
All mixes keep the noise at unit variance (a*signal + sqrt(1-a^2)*noise, block means replaced by block means of the same
|
||||
variance). The model was trained on plain noise: small values, and compare with 0."""
|
||||
import hashlib
|
||||
import math
|
||||
|
||||
import torch
|
||||
|
||||
import comfy.nested_tensor
|
||||
import comfy.sample
|
||||
|
||||
try:
|
||||
from .bfs_shot_loop import shot_seed, BFSShotSeed
|
||||
from .bfs_h3_side_panel import latent_mask
|
||||
except ImportError: # run as a script (tests)
|
||||
from bfs_shot_loop import shot_seed, BFSShotSeed
|
||||
from bfs_h3_side_panel import latent_mask
|
||||
|
||||
|
||||
_pick_seed = shot_seed # BFSShotNoise.make has an input of the same name
|
||||
|
||||
|
||||
def _gen(*key) -> torch.Generator:
|
||||
return torch.Generator().manual_seed(int.from_bytes(hashlib.sha256("/".join(str(k) for k in key).encode()).digest()[:8], "big") >> 1)
|
||||
|
||||
|
||||
def _edges(n: int, blocks: int) -> torch.Tensor:
|
||||
"""Block id of each of n cells for `blocks` near-equal blocks (blocks is capped at n)."""
|
||||
blocks = max(1, min(int(blocks), n))
|
||||
return torch.clamp((torch.arange(n) * blocks) // n, max=blocks - 1)
|
||||
|
||||
|
||||
def replace_coarse(x: torch.Tensor, base_seed: int, amount: float, grid: int = 6, time_block: int = 2, t0: int = 0) -> torch.Tensor:
|
||||
"""x [C,T,h,w] unit noise. The mean of every (time block, y block, x block) cell is replaced (amount 1) or mixed
|
||||
with a mean drawn from `base_seed`; the result is unit noise again. Blocks: `grid` along the short side, the long
|
||||
side in proportion; `time_block` latent frames, counted from timeline position t0."""
|
||||
if amount <= 0:
|
||||
return x
|
||||
C, T, h, w = x.shape
|
||||
hb, wb = (grid, max(1, round(grid * w / h))) if h <= w else (max(1, round(grid * h / w)), grid)
|
||||
yid, xid = _edges(h, hb), _edges(w, wb)
|
||||
hb, wb = int(yid.max()) + 1, int(xid.max()) + 1
|
||||
tid_abs = (t0 + torch.arange(T)) // max(1, int(time_block))
|
||||
tids = torch.unique(tid_abs)
|
||||
tloc = torch.searchsorted(tids, tid_abs)
|
||||
nb = len(tids) * hb * wb
|
||||
ids = ((tloc[:, None, None] * hb + yid[None, :, None]) * wb + xid[None, None, :]).reshape(-1)
|
||||
flat = x.reshape(C, -1).double()
|
||||
cnt = torch.zeros(nb, dtype=torch.float64).index_add_(0, ids, torch.ones_like(ids, dtype=torch.float64))
|
||||
mean = torch.zeros(C, nb, dtype=torch.float64).index_add_(1, ids, flat) / cnt
|
||||
z = torch.stack([torch.randn(C, hb, wb, generator=_gen("coarse", base_seed, int(t), hb, wb), dtype=torch.float64) for t in tids], 1).reshape(C, nb)
|
||||
a = float(min(1.0, amount))
|
||||
new = math.sqrt(1.0 - a * a) * mean + a * z / cnt.sqrt()
|
||||
return (flat + (new - mean)[:, ids]).reshape(C, T, h, w).to(x.dtype)
|
||||
|
||||
|
||||
DISTRIBUTIONS = ["gaussian", "laplacian", "student-t", "uniform"]
|
||||
|
||||
|
||||
def draw(shape, kind: str, seed: int) -> torch.Tensor:
|
||||
"""Unit-variance noise of another distribution (heavier or lighter tails than the gaussian the model was trained on)."""
|
||||
g = _gen("dist", kind, seed)
|
||||
if kind == "laplacian":
|
||||
u = torch.rand(shape, generator=g, dtype=torch.float64) - 0.5
|
||||
return (-torch.sign(u) * torch.log1p(-2 * u.abs().clamp(max=0.5 - 1e-12)) / math.sqrt(2.0)).float()
|
||||
if kind == "student-t": # 5 degrees of freedom
|
||||
n = torch.randn(shape, generator=g, dtype=torch.float64)
|
||||
chi = torch.randn((5,) + tuple(shape), generator=g, dtype=torch.float64).pow(2).sum(0)
|
||||
return (n / torch.sqrt(chi / 5.0) * math.sqrt(3.0 / 5.0)).float()
|
||||
if kind == "uniform":
|
||||
return ((torch.rand(shape, generator=g, dtype=torch.float64) - 0.5) * math.sqrt(12.0)).float()
|
||||
return torch.randn(shape, generator=g)
|
||||
|
||||
|
||||
def tilt_spectrum(x: torch.Tensor, alpha: float) -> torch.Tensor:
|
||||
"""x [C,T,h,w]: power spectrum multiplied by 1/f^alpha over time and space (alpha > 0 = more energy in the large
|
||||
blobs, 'fractal' noise; alpha < 0 = more in the fine grain), then back to zero mean and unit variance per channel."""
|
||||
if alpha == 0:
|
||||
return x
|
||||
C, T, h, w = x.shape
|
||||
f = torch.sqrt(torch.fft.fftfreq(T)[:, None, None] ** 2 + torch.fft.fftfreq(h)[None, :, None] ** 2 + torch.fft.rfftfreq(w)[None, None, :] ** 2)
|
||||
f[0, 0, 0] = f[f > 0].min()
|
||||
y = torch.fft.irfftn(torch.fft.rfftn(x.double(), dim=(1, 2, 3)) * f.pow(-alpha / 2.0), s=(T, h, w), dim=(1, 2, 3))
|
||||
y = y - y.mean(dim=(1, 2, 3), keepdim=True)
|
||||
return (y / y.std(dim=(1, 2, 3), keepdim=True).clamp_min(1e-8)).to(x.dtype)
|
||||
|
||||
|
||||
def add_pyramid(x: torch.Tensor, amount: float, seed: int, levels: int = 4) -> torch.Tensor:
|
||||
"""x [C,T,h,w] plus coarser copies of fresh noise (each level half the size, weight amount^level), unit variance."""
|
||||
if amount <= 0:
|
||||
return x
|
||||
C, T, h, w = x.shape
|
||||
y = x.double()
|
||||
for i in range(1, levels + 1):
|
||||
hh, ww = max(1, h >> i), max(1, w >> i)
|
||||
if hh == 1 and ww == 1:
|
||||
break
|
||||
n = torch.randn(C, T, hh, ww, generator=_gen("pyramid", seed, i), dtype=torch.float64)
|
||||
y = y + (amount ** i) * torch.nn.functional.interpolate(n, size=(h, w), mode="bilinear", align_corners=False)
|
||||
y = y - y.mean(dim=(1, 2, 3), keepdim=True)
|
||||
return (y / y.std(dim=(1, 2, 3), keepdim=True).clamp_min(1e-8)).to(x.dtype)
|
||||
|
||||
|
||||
def mix(x: torch.Tensor, signal: torch.Tensor, weight: torch.Tensor) -> torch.Tensor:
|
||||
weight = weight.clamp(0, 1)
|
||||
return torch.sqrt(1.0 - weight ** 2) * x + weight * signal
|
||||
|
||||
|
||||
class ShotNoise:
|
||||
def __init__(self, seed, base_seed, opts, guide=None, tint=None, mask=None, t0=0):
|
||||
self.seed, self.base_seed, self.o, self.guide, self.tint, self.mask, self.t0 = int(seed), int(base_seed), opts, guide, tint, mask, int(t0)
|
||||
|
||||
def generate_noise(self, input_latent):
|
||||
samples = input_latent["samples"]
|
||||
inds = input_latent["batch_index"] if "batch_index" in input_latent else None
|
||||
noise = comfy.sample.prepare_noise(samples, self.seed, inds) # exactly RandomNoise
|
||||
o = self.o
|
||||
if not any(o[k] > 0 for k in ("shared_base", "static_share", "guide_structure", "ref_tint", "pyramid")) \
|
||||
and o["spectrum_tilt"] == 0 and o["distribution"] == "gaussian":
|
||||
return noise
|
||||
nested = getattr(noise, "is_nested", False)
|
||||
parts = list(noise.unbind()) if nested else [noise]
|
||||
v = parts[0]
|
||||
if v.ndim != 5:
|
||||
return noise
|
||||
out = []
|
||||
for b in range(v.shape[0]):
|
||||
x = v[b].float()
|
||||
C, T, h, w = x.shape
|
||||
if o["distribution"] != "gaussian":
|
||||
x = draw((C, T, h, w), o["distribution"], self.seed + b)
|
||||
x = add_pyramid(tilt_spectrum(x, o["spectrum_tilt"]), o["pyramid"], self.seed + b)
|
||||
if o["static_share"] > 0:
|
||||
s = torch.randn(C, 1, h, w, generator=_gen("static", self.base_seed, h, w))
|
||||
x = mix(x, s.expand_as(x), torch.tensor(float(o["static_share"])))
|
||||
x = replace_coarse(x, self.base_seed, o["shared_base"], o["base_grid"], o["time_block"], self.t0)
|
||||
m = None
|
||||
if self.mask is not None:
|
||||
m = latent_mask(self.mask, T, h, w, grow=1)[0].to(x) # [1,T,h,w], 1 = the person
|
||||
if o["guide_structure"] > 0 and self.guide is not None:
|
||||
g = self.guide[min(b, self.guide.shape[0] - 1)].float().cpu()
|
||||
if tuple(g.shape[1:]) != (T, h, w):
|
||||
g = torch.nn.functional.interpolate(g[None], size=(T, h, w), mode="trilinear", align_corners=False)[0]
|
||||
g = (g - g.mean(dim=(1, 2, 3), keepdim=True)) / g.std(dim=(1, 2, 3), keepdim=True).clamp_min(1e-6)
|
||||
x = mix(x, g, float(o["guide_structure"]) * ((1.0 - m) if m is not None else torch.ones(1, T, h, w)))
|
||||
if o["ref_tint"] > 0 and self.tint is not None:
|
||||
x = mix(x, self.tint.view(C, 1, 1, 1).expand_as(x), float(o["ref_tint"]) * (m if m is not None else torch.ones(1, T, h, w)))
|
||||
out.append(x.to(v.dtype))
|
||||
parts[0] = torch.stack(out)
|
||||
if nested:
|
||||
return comfy.nested_tensor.NestedTensor(parts)
|
||||
return parts[0]
|
||||
|
||||
|
||||
class BFSShotNoise:
|
||||
"""Starting noise for the sampler with optional structure (see the node description)."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
f = lambda tip, mx=1.0: ("FLOAT", {"default": 0.0, "min": 0.0, "max": mx, "step": 0.01, "tooltip": tip})
|
||||
return {
|
||||
"required": {
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True,
|
||||
"tooltip": "The seed (the base seed when a shot is connected)."}),
|
||||
"shot_seed": (BFSShotSeed.MODES, {"default": BFSShotSeed.MODES[0], "tooltip":
|
||||
"Only with a shot connected: the seed of each shot (same / counting up / unrelated but repeatable)."}),
|
||||
"shared_base": f("0 = off (plain noise). The coarse pattern of the noise comes from base_seed instead of "
|
||||
"the shot's seed: every shot, any length or size, agrees on it; the fine detail stays "
|
||||
"per shot. 1 = fully shared. Try 0.5 - 1."),
|
||||
"static_share": f("0 = off. A part of the noise that is identical in every frame (temporally "
|
||||
"correlated noise). Try 0.1 - 0.3; too much freezes the motion.", 0.9),
|
||||
"guide_structure": f("0 = off. Needs guide_frames + vae. Outside the mask the guide's latent is mixed "
|
||||
"into the noise, so the background starts closer to the guide (without a mask: "
|
||||
"everywhere, which also pulls toward the original person). Try 0.05 - 0.2.", 0.6),
|
||||
"distribution": (DISTRIBUTIONS, {"default": "gaussian", "tooltip":
|
||||
"gaussian = what the model was trained on. laplacian / student-t: more extreme values (stronger, "
|
||||
"sparser kicks). uniform: none. All at unit variance. Like AdvancedNoise's types, but for H3's "
|
||||
"video + audio latent (the audio noise always stays gaussian)."}),
|
||||
"spectrum_tilt": ("FLOAT", {"default": 0.0, "min": -2.0, "max": 2.0, "step": 0.05, "tooltip":
|
||||
"0 = off (white noise). > 0: more energy in the large blobs (fractal / pink noise, AdvancedNoise's "
|
||||
"'fractal' alpha): the coarse layout, where the swap is decided, gets a harder push. < 0: more "
|
||||
"fine grain. Try 0.2 - 0.6; large values burn colours."}),
|
||||
"pyramid": f("0 = off. Adds coarser layers of noise (each half the size, weight = this value per "
|
||||
"level): another way to put more energy in the large scales. Try 0.3 - 0.6.", 0.95),
|
||||
"ref_tint": f("0 = off. Needs ref_image + vae. Inside the mask (everywhere without one) the noise "
|
||||
"leans toward the reference's average latent, i.e. its colours. Try 0.05 - 0.15.", 0.6),
|
||||
},
|
||||
"optional": {
|
||||
"shot": ("BFS_SHOT", {"tooltip": "From BFS Shot Planner / Unpack: per-shot seeds, and the shared base is "
|
||||
"read at the shot's position on the timeline."}),
|
||||
"base_seed": ("INT", {"default": -1, "min": -1, "max": 0xffffffffffffffff, "tooltip":
|
||||
"Seed of the shared coarse pattern and of static_share. -1 = the seed above. Put here the seed of "
|
||||
"a result that worked to keep its coarse noise while the detail changes with the seed."}),
|
||||
"overrides": ("STRING", {"default": "", "tooltip": "Seeds for single shots: 'shot:seed, shot:seed' (shots count from 1)."}),
|
||||
"base_grid": ("INT", {"default": 6, "min": 1, "max": 32, "advanced": True, "tooltip":
|
||||
"Blocks of the coarse pattern along the short side (the long side in proportion). Fewer = only the "
|
||||
"broadest layout is shared."}),
|
||||
"time_block": ("INT", {"default": 2, "min": 1, "max": 32, "advanced": True, "tooltip": "Latent frames per block of the coarse pattern."}),
|
||||
"vae": ("VAE", {"tooltip": "The video VAE, for guide_structure and ref_tint."}),
|
||||
"guide_frames": ("IMAGE", {"tooltip": "The guide video frames (guide_structure)."}),
|
||||
"ref_image": ("IMAGE", {"tooltip": "The reference picture (ref_tint)."}),
|
||||
"mask": ("MASK", {"tooltip": "The person, one mask per frame (white = the person): guide_structure stays "
|
||||
"outside it, ref_tint inside it."}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("NOISE", "INT")
|
||||
RETURN_NAMES = ("noise", "seed")
|
||||
OUTPUT_TOOLTIPS = ("For SamplerCustomAdvanced.", "The seed this shot used.")
|
||||
FUNCTION = "make"
|
||||
CATEGORY = "BFS/shot loop"
|
||||
DESCRIPTION = ("Optional structured starting noise. With every amount at 0 it is exactly RandomNoise. shared_base: "
|
||||
"the coarse noise pattern is the same for every shot / size / length (from base_seed), the detail "
|
||||
"per shot. static_share: a part identical in all frames. guide_structure: the guide's latent mixed "
|
||||
"in outside the mask. ref_tint: lean toward the reference's colours inside the mask. Experimental: "
|
||||
"the model was trained on plain noise, so use small amounts and compare with 0.")
|
||||
|
||||
def make(self, seed, shot_seed, shared_base, static_share, guide_structure, ref_tint, distribution="gaussian", spectrum_tilt=0.0,
|
||||
pyramid=0.0, shot=None, base_seed=-1, overrides="",
|
||||
base_grid=6, time_block=2, vae=None, guide_frames=None, ref_image=None, mask=None):
|
||||
index = int(shot.get("index", 0)) if shot else 0
|
||||
s = _pick_seed(int(seed), index, shot_seed, overrides) if shot else int(seed)
|
||||
base = int(seed) if int(base_seed) < 0 else int(base_seed)
|
||||
guide = tint = None
|
||||
if guide_structure > 0:
|
||||
if vae is None or guide_frames is None:
|
||||
raise ValueError("BFS Shot Noise: guide_structure needs vae and guide_frames")
|
||||
guide = vae.encode(guide_frames[..., :3])
|
||||
guide = guide.tensors[0] if getattr(guide, "is_nested", False) else guide
|
||||
if ref_tint > 0:
|
||||
if vae is None or ref_image is None:
|
||||
raise ValueError("BFS Shot Noise: ref_tint needs vae and ref_image")
|
||||
r = vae.encode(ref_image[:1, ..., :3])
|
||||
r = (r.tensors[0] if getattr(r, "is_nested", False) else r).float().cpu()
|
||||
tint = (r.mean(dim=(0, 2, 3, 4)) / r.std(dim=(0, 2, 3, 4)).clamp_min(1e-6)).clamp(-2, 2)
|
||||
t0 = int(round(int(shot.get("start", 0)) / 4)) if shot else 0 # timeline position in latent frames
|
||||
opts = {"shared_base": float(shared_base), "static_share": float(static_share), "guide_structure": float(guide_structure),
|
||||
"ref_tint": float(ref_tint), "base_grid": int(base_grid), "time_block": int(time_block),
|
||||
"distribution": distribution, "spectrum_tilt": float(spectrum_tilt), "pyramid": float(pyramid)}
|
||||
return (ShotNoise(s, base, opts, guide, tint, mask, t0), s)
|
||||
|
||||
|
||||
def sigmas_chart(sigmas: torch.Tensor, other: torch.Tensor | None = None, w: int = 640, h: int = 360):
|
||||
"""(image [1,h,w,3], text): the noise level at the start of every step, with the zones where the steps are spent."""
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
s = [float(v) for v in sigmas.flatten().tolist()]
|
||||
top = max(1e-6, s[0])
|
||||
img = Image.new("RGB", (w, h), (24, 24, 28)); d = ImageDraw.Draw(img)
|
||||
L, R, T, B = 52, w - 16, 34, h - 40
|
||||
y = lambda v: B - (v / top) * (B - T)
|
||||
zones = ((0.85, 1.01, (60, 40, 40), "layout / identity"), (0.5, 0.85, (48, 46, 36), "shapes"), (-0.01, 0.5, (36, 44, 52), "detail"))
|
||||
for lo, hi, col, name in zones:
|
||||
d.rectangle([L, y(min(hi, 1.0) * top), R, y(max(lo, 0.0) * top)], fill=col)
|
||||
d.text((R - 6 - 6 * len(name), y(min(hi, 1.0) * top) + 3), name, fill=(150, 150, 150))
|
||||
for v in (0.0, 0.25, 0.5, 0.75, 1.0):
|
||||
d.line([L, y(v * top), R, y(v * top)], fill=(70, 70, 76)); d.text((8, y(v * top) - 6), f"{v * top:.2f}", fill=(170, 170, 170))
|
||||
|
||||
def line(vals, col, labels):
|
||||
n = len(vals) - 1
|
||||
pts = [(L + (R - L) * i / max(1, n), y(v)) for i, v in enumerate(vals)]
|
||||
d.line(pts, fill=col, width=2)
|
||||
for i, (px, py) in enumerate(pts):
|
||||
d.ellipse([px - 3, py - 3, px + 3, py + 3], fill=col)
|
||||
if labels and i < n:
|
||||
d.text((px - 10, py - 16 if i % 2 == 0 else py + 6), f"{vals[i]:.2f}", fill=(235, 235, 235))
|
||||
d.text((px - 3, B + 8), str(i + 1), fill=(170, 170, 170))
|
||||
|
||||
if other is not None:
|
||||
line([float(v) for v in other.flatten().tolist()], (120, 120, 200), False)
|
||||
line(s, (255, 190, 70), True)
|
||||
steps = s[:-1]
|
||||
cnt = [sum(lo * top < v <= hi * top or (hi > 1 and v >= lo * top) for v in steps) for lo, hi, _, _ in zones]
|
||||
d.text((L, 10), f"{len(steps)} steps: {cnt[0]} on layout / identity (> {0.85 * top:.2f}), {cnt[1]} on shapes, {cnt[2]} on detail (< {0.5 * top:.2f})"
|
||||
f" last jump {steps[-1]:.2f} -> {s[-1]:.2f}", fill=(235, 235, 235))
|
||||
d.text((R - 24, B + 8), "step", fill=(170, 170, 170))
|
||||
text = (f"{len(steps)} steps | layout/identity {cnt[0]}, shapes {cnt[1]}, detail {cnt[2]} | sigmas: " + ", ".join(f"{v:.3f}" for v in s))
|
||||
return torch.from_numpy(np.asarray(img).astype("float32") / 255.0)[None], text
|
||||
|
||||
|
||||
class BFSSigmasPreview:
|
||||
"""Draws a sigma schedule: where the sampler's steps are spent (pass-through)."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"sigmas": ("SIGMAS", {"tooltip": "The schedule that goes to the sampler (after the scheduler and the shift)."})},
|
||||
"optional": {"compare": ("SIGMAS", {"tooltip": "Another schedule drawn behind it, e.g. the same steps with another shift."})}}
|
||||
|
||||
RETURN_TYPES = ("SIGMAS", "IMAGE", "STRING")
|
||||
RETURN_NAMES = ("sigmas", "chart", "text")
|
||||
OUTPUT_TOOLTIPS = ("The same sigmas, untouched.", "The chart: connect a Preview Image.", "The values and the step count per zone.")
|
||||
FUNCTION = "show"
|
||||
CATEGORY = "BFS/shot loop"
|
||||
DESCRIPTION = ("Shows a sigma schedule: the noise level at the start of each step and how many steps fall on layout / "
|
||||
"identity (high noise), shapes and detail (low noise). With H3's video shift of 12 most steps sit at "
|
||||
"the top; lowering the shift moves steps toward detail. Changes nothing: sigmas pass through.")
|
||||
|
||||
def show(self, sigmas, compare=None):
|
||||
chart, text = sigmas_chart(sigmas, compare)
|
||||
return (sigmas, chart, text)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"BFSShotNoise": BFSShotNoise, "BFSSigmasPreview": BFSSigmasPreview}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"BFSShotNoise": "BFS Shot Noise", "BFSSigmasPreview": "BFS Sigmas Preview"}
|
||||
@@ -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
|
||||
@@ -67,7 +67,11 @@ def _dbg(*a):
|
||||
def _get_face_app():
|
||||
global _FACE_APP
|
||||
if _FACE_APP is None:
|
||||
from insightface.app import FaceAnalysis
|
||||
try:
|
||||
from insightface.app import FaceAnalysis
|
||||
except ImportError as e:
|
||||
raise RuntimeError("This node needs the optional insightface package: pip install insightface (with onnxruntime "
|
||||
"or onnxruntime-gpu), then restart ComfyUI.") from e
|
||||
providers = (["CUDAExecutionProvider", "CPUExecutionProvider"] if _USE_GPU else ["CPUExecutionProvider"])
|
||||
app = FaceAnalysis(name="buffalo_l", providers=providers)
|
||||
app.prepare(ctx_id=0 if _USE_GPU else -1, det_size=(640, 640))
|
||||
|
||||
+2
-4
@@ -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: split long videos into shots at camera cuts, face-tracked cast, SAM 3 mask and crop per shot (text or click points, one selection for the whole video), 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.69.0"
|
||||
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 full frame, paste, mask, crop and crop + mask generation modes, optional translucent Color Guide for one or two manually selected regions with cast identity color reuse, 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.97.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"torch",
|
||||
@@ -11,8 +11,6 @@ dependencies = [
|
||||
"librosa",
|
||||
"opencv-python",
|
||||
"scenedetect",
|
||||
"insightface==0.7.3",
|
||||
"onnxruntime",
|
||||
"transformers>=4.55.0",
|
||||
"scipy",
|
||||
]
|
||||
|
||||
@@ -4,6 +4,4 @@ Pillow
|
||||
safetensors
|
||||
librosa
|
||||
opencv-python
|
||||
insightface==0.7.3
|
||||
onnxruntime
|
||||
scenedetect
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import importlib.util
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("bfs_h3_history", ROOT / "bfs_h3_history.py")
|
||||
HI = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(HI)
|
||||
|
||||
|
||||
class Layout:
|
||||
"""text 4 rows | guide cond 6 rows | history cond 4 rows | audio 3 | video 6, all at the target origin 4.0"""
|
||||
|
||||
def __init__(self):
|
||||
self.segments = [(0, 4, "text"), (4, 10, "cond"), (10, 14, "cond"), (14, 17, "audio"), (17, 23, "video")]
|
||||
p = torch.zeros(23, 3)
|
||||
p[:4, 0] = torch.arange(4.)
|
||||
p[4:, 0] = 4.0
|
||||
self.position_ids = p
|
||||
|
||||
|
||||
class HistoryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
HI.history_span = lambda t: 10.0 * t
|
||||
|
||||
def test_target_moves_past_the_history_only(self):
|
||||
lay = Layout()
|
||||
kfs = [{"latent": torch.zeros(1, 24, 3, 2, 2)}, {"latent": torch.zeros(1, 24, 2, 2, 2), "anchor": "history"}]
|
||||
self.assertTrue(HI.relocate(lay, kfs))
|
||||
t = lay.position_ids[:, 0]
|
||||
self.assertTrue(torch.equal(t[:4], torch.arange(4.))) # text untouched
|
||||
self.assertTrue(bool((t[10:14] == 4.0).all())) # history stays at the origin
|
||||
self.assertTrue(bool((t[4:10] == 24.0).all())) # the other guide moves with the video
|
||||
self.assertTrue(bool((t[14:] == 24.0).all())) # audio and video move by the span (2 steps)
|
||||
self.assertFalse(HI.relocate(lay, kfs)) # already moved: no double shift
|
||||
|
||||
def test_no_history_no_change(self):
|
||||
lay = Layout()
|
||||
before = lay.position_ids.clone()
|
||||
self.assertFalse(HI.relocate(lay, [{"latent": torch.zeros(1, 24, 3, 2, 2)}]))
|
||||
self.assertTrue(torch.equal(before, lay.position_ids))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -72,10 +72,18 @@ class SidePanelTest(unittest.TestCase):
|
||||
|
||||
def test_layout_text_follows_the_share(self):
|
||||
half = SP.layout_text({"position": "left", "h": 64, "w": 36, "strip_h": 0, "strip_w": 36})
|
||||
self.assertIn("the LEFT half is the kept footage", half)
|
||||
self.assertIn("the left half shows the original performance; the right half shows", half)
|
||||
self.assertNotIn("kept", half)
|
||||
narrow = SP.layout_text({"position": "left", "h": 64, "w": 72, "strip_h": 0, "strip_w": 36})
|
||||
self.assertIn("narrow strip", narrow)
|
||||
self.assertIn("LARGER", narrow)
|
||||
self.assertIn("the left third shows", narrow)
|
||||
self.assertIn("the right two thirds show", narrow)
|
||||
self.assertIn("a wider view of the same action", narrow)
|
||||
top = {"position": "top", "h": 48, "w": 36, "strip_h": 16, "strip_w": 0}
|
||||
txt = SP.fill_layout("{panel_region} shows a man dancing, and {video_region} shows <Subject 1>.", top)
|
||||
self.assertEqual(txt, "the top quarter shows a man dancing, and the bottom three quarters show <Subject 1>.")
|
||||
p = SP.build_prompt("character swap", "", 1, 0)
|
||||
for w in ("kept footage", "kept exactly", "in sync", "generated"):
|
||||
self.assertNotIn(w, SP.fill_layout(p, top))
|
||||
|
||||
def test_apply_and_crop_round_trip(self):
|
||||
lat = _latent()
|
||||
@@ -121,6 +129,35 @@ class KeepMaskTest(unittest.TestCase):
|
||||
self.assertEqual(float(m[0, 0, 3, 30, 36 + 18]), 1.0) # inside the mask: generated
|
||||
self.assertEqual(float(m[0, 0, 3, 30, 5]), 0.0) # panel: pinned
|
||||
|
||||
def test_panel_static_covers_the_subject_every_frame(self):
|
||||
seen = {}
|
||||
|
||||
def seg(imgs, spec):
|
||||
seen["spec"] = spec
|
||||
m = torch.zeros(imgs.shape[0], *imgs.shape[1:3]); m[:, 10:20, 10:20] = 1
|
||||
return m
|
||||
|
||||
def tv(img, mask, grow=0.03, seed=0):
|
||||
out = img.clone(); m = torch.nn.functional.interpolate(mask[None, None].float(), size=img.shape[1:3])[0, 0]
|
||||
out[0][m > 0.5] = -1.0
|
||||
return out
|
||||
|
||||
fake = types.SimpleNamespace(DEFAULT_MASK={"text": "", "threshold": 0.5}, segment_frames=seg, tv_static=tv,
|
||||
add_setting=lambda text, k, swap: text + f" <Picture {k}> setting")
|
||||
old, SP._shot_loop = SP._shot_loop, lambda: fake
|
||||
try:
|
||||
fr = torch.full((5, 64, 48, 3), 0.5)
|
||||
self.assertIs(SP.static_over_panel(fr, "off"), fr)
|
||||
out = SP.static_over_panel(fr, "face")
|
||||
self.assertEqual(seen["spec"]["text"], "face")
|
||||
self.assertEqual(tuple(out.shape), (5, 64, 48, 3))
|
||||
self.assertTrue(bool((out[:, 15, 15] == -1).all()) and float(out[0, 40, 40, 0]) == 0.5)
|
||||
st = SP.setting_from_clip(fr, 1.0, "dog")
|
||||
self.assertEqual(tuple(st.shape), (1, 64, 48, 3)); self.assertEqual(seen["spec"]["text"], "dog")
|
||||
self.assertTrue(SP.with_setting("x", 2, True).endswith("<Picture 2> setting"))
|
||||
finally:
|
||||
SP._shot_loop = old
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+375
-2
@@ -249,7 +249,7 @@ class ContinuityTest(unittest.TestCase):
|
||||
plan = dict(SL.DEFAULT_PLAN, mode="fixed", max_s=2, bounds=[50],
|
||||
segs=[{}, {"chain": "first frame", "chain_frame": "middle"}])
|
||||
segs = SL.resolve_plan(plan, a)
|
||||
self.assertEqual((segs[0]["chain"], segs[1]["chain"], segs[1]["chain_frame"]), ("off", "first frame", "middle"))
|
||||
self.assertEqual((segs[0]["chain"], segs[1]["chain"], segs[1]["chain_frame"]), ("history", "first frame", "middle")) # history is the default
|
||||
|
||||
|
||||
class MaskCropTest(unittest.TestCase):
|
||||
@@ -369,7 +369,7 @@ class ConditioningWriterTest(unittest.TestCase):
|
||||
out = self._run()
|
||||
self.assertEqual(out[3], "my own prompt")
|
||||
out = self._run(duet="canvas")
|
||||
self.assertIn("LEFT half is the kept footage", out[3])
|
||||
self.assertIn("the left half shows the original performance", out[3])
|
||||
self.assertEqual(self.seen["prompt"], out[3])
|
||||
|
||||
def test_task_template_follows_the_duet_mode(self):
|
||||
@@ -484,6 +484,10 @@ class InpaintInCropTest(unittest.TestCase):
|
||||
self.assertEqual(float(vm[0, 0, 3, 0, 0]), 0.0) # the rest of the crop: kept
|
||||
self.assertEqual(float(vm[0, 0, 3, 2, 4]), 1.0) # grown by one latent cell
|
||||
self.assertTrue(bool((am == 1).all())) # audio fully generated
|
||||
_, out2 = SL.inpaint_latent(lat, [], VAE(), frames, mask, strength=0.8)
|
||||
vm2 = out2["noise_mask"].tensors[0]
|
||||
self.assertAlmostEqual(float(vm2[0, 0, 3, 4, 4]), 0.8, places=5) # partly regenerated inside the mask
|
||||
self.assertEqual(float(vm2[0, 0, 3, 0, 0]), 0.0) # still kept outside
|
||||
|
||||
|
||||
def test_generation_mask_with_and_without_crop(self):
|
||||
@@ -523,5 +527,374 @@ class InpaintInCropTest(unittest.TestCase):
|
||||
self.assertAlmostEqual(float(o[0, 2, 2, 0]), 0.5) # untouched outside
|
||||
|
||||
|
||||
class ExternalMaskTest(unittest.TestCase):
|
||||
def _video(self, name, n=48, fps=24.0, w=64, h=36, box=(10, 5, 30, 25)):
|
||||
import cv2
|
||||
import numpy as np
|
||||
path = str(Path(_TMP) / name)
|
||||
vw = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
|
||||
for i in range(n):
|
||||
f = np.zeros((h, w, 3), np.uint8)
|
||||
x0, y0, x1, y1 = box
|
||||
f[y0:y1, x0 + i % 4:x1 + i % 4] = 255
|
||||
vw.write(f)
|
||||
vw.release()
|
||||
return path
|
||||
|
||||
def setUp(self):
|
||||
self.an = {"fps": 24.0, "n": 48, "n_src": 48, "fps_src": 24.0, "width": 64, "height": 36}
|
||||
|
||||
def test_precedence(self):
|
||||
p = {"mask_video": "global.mp4"}
|
||||
self.assertEqual(SL.plan_spec(p, {"video": "shot.mp4", "text": "man"})["ext"]["scope"], "shot")
|
||||
self.assertNotIn("ext", SL.plan_spec(p, {"text": "man"})) # the shot's own SAM 3 text wins
|
||||
self.assertEqual(SL.plan_spec(p, {})["ext"], {"file": "global.mp4", "scope": "video"})
|
||||
SL._EXT_MASK["v.mp4"] = torch.ones(48, 36, 64)
|
||||
try:
|
||||
self.assertEqual(SL.plan_spec(p, {}, "v.mp4")["ext"], {"tensor": "v.mp4"}) # the node input beats the panel file
|
||||
finally:
|
||||
SL._EXT_MASK.pop("v.mp4")
|
||||
self.assertFalse(SL.spec_has_mask(SL.plan_spec({}, {})))
|
||||
|
||||
def test_file_and_tensor_masks_replace_sam(self):
|
||||
path = self._video("roto_whole.mp4")
|
||||
self._video("roto_shot.mp4", n=12)
|
||||
orig = SL.segment_frames
|
||||
SL.segment_frames = lambda *a, **k: self.fail("SAM 3 must not run with an external mask")
|
||||
try:
|
||||
r = SL.shot_mask(path, self.an, 24, 12, SL.plan_spec({"mask_video": "roto_whole.mp4"}, {}))
|
||||
self.assertEqual(r["masks"].shape[0], 12)
|
||||
self.assertTrue(r["box"] is not None)
|
||||
cov = float(r["masks"].float().mean())
|
||||
self.assertTrue(0.1 < cov < 0.4, cov)
|
||||
r2 = SL.shot_mask(path, self.an, 24, 12, SL.plan_spec({}, {"video": "roto_shot.mp4"}))
|
||||
self.assertEqual(r2["masks"].shape[0], 12)
|
||||
t = torch.zeros(48, 36, 64); t[:, 0:18, :] = 1 # top half
|
||||
SL._EXT_MASK[path] = t
|
||||
r3 = SL.shot_mask(path, self.an, 0, 10, SL.plan_spec({}, {}, path))
|
||||
m = r3["masks"].float()
|
||||
self.assertGreater(float(m[:, :8].mean()), 0.9)
|
||||
self.assertLess(float(m[:, -8:].mean()), 0.1)
|
||||
finally:
|
||||
SL.segment_frames = orig
|
||||
SL._EXT_MASK.pop(path, None)
|
||||
|
||||
|
||||
class FramePasteTest(unittest.TestCase):
|
||||
def test_paste_back_keeps_the_background_and_takes_the_new_outline(self):
|
||||
base = torch.full((5, 64, 64, 3), 0.2) # the original frames
|
||||
result = torch.full((5, 64, 64, 3), 0.9) # generated: everything changed
|
||||
old = torch.zeros(5, 64, 64); old[:, 20:44, 20:36] = 1 # the original person
|
||||
new = torch.zeros(5, 64, 64); new[:, 16:48, 18:44] = 1 # the new one is bigger
|
||||
ms = {"path": "", "analysis": {}, "start": 0, "length": 5, "spec": dict(SL.DEFAULT_MASK, expand=0, feather=0)}
|
||||
shot = {"index": 0, "frames": base, "mask_src": ms, "paste": True}
|
||||
o1, o2 = SL.shot_mask, SL.segment_frames
|
||||
SL.shot_mask = lambda *a: {"masks": old.to(torch.uint8)}
|
||||
SL.segment_frames = lambda imgs, sp: torch.nn.functional.interpolate(new[:, None], size=imgs.shape[1:3])[:, 0]
|
||||
try:
|
||||
out = SL.paste_back(result, shot, True)
|
||||
only_old = SL.paste_back(result, shot, False)
|
||||
finally:
|
||||
SL.shot_mask, SL.segment_frames = o1, o2
|
||||
self.assertAlmostEqual(float(out[0, 2, 2, 0]), 0.2, places=4) # background: the original
|
||||
self.assertAlmostEqual(float(out[0, 30, 28, 0]), 0.9, places=4) # the person: generated
|
||||
self.assertAlmostEqual(float(out[0, 30, 41, 0]), 0.9, places=4) # the bigger new outline is pasted too
|
||||
self.assertAlmostEqual(float(only_old[0, 30, 41, 0]), 0.2, places=4)
|
||||
|
||||
|
||||
class MaskGuideTest(unittest.TestCase):
|
||||
def test_masked_only_greys_everything_outside_the_mask(self):
|
||||
fr = torch.full((4, 32, 32, 3), 0.9)
|
||||
m = torch.zeros(4, 16, 16); m[:, 4:12, 4:12] = 1
|
||||
g = SL.masked_only(fr, m)
|
||||
self.assertEqual(tuple(g.shape), (4, 32, 32, 3))
|
||||
self.assertAlmostEqual(float(g[0, 16, 16, 0]), 0.9) # the masked region
|
||||
self.assertAlmostEqual(float(g[0, 2, 2, 0]), 0.5) # grey elsewhere
|
||||
self.assertEqual(SL.MASK_GUIDES[0], "off")
|
||||
fr2 = torch.rand(3, 64, 64, 3)
|
||||
m2 = torch.zeros(3, 64, 64); m2[:, 10:50, 10:50] = 1
|
||||
old_det = dict(SL._DET); SL._DET["pose"] = None # no YOLO pose model: a clear error
|
||||
try:
|
||||
with self.assertRaisesRegex(ValueError, "YOLO pose model"):
|
||||
SL.masked_only(fr2, m2, look="pose (people)")
|
||||
finally:
|
||||
SL._DET.clear(); SL._DET.update(old_det)
|
||||
for look in [x for x in SL.MASK_GUIDE_LOOKS if not x.startswith("pose")]:
|
||||
g2 = SL.masked_only(fr2, m2, look=look)
|
||||
self.assertEqual(tuple(g2.shape), (3, 64, 64, 3), look)
|
||||
self.assertAlmostEqual(float(g2[0, 2, 2, 0]), 0.5, msg=look) # grey outside, every look
|
||||
if look != "colour": # no colour inside
|
||||
self.assertLess(float((g2[..., 0] - g2[..., 1]).abs().max()), 1e-5, look)
|
||||
t = SL.add_mask_video("x subject_definitions: <Subject 1> is ...", 2)
|
||||
self.assertIn("<Video 2> shows only the region", t)
|
||||
self.assertEqual(SL.add_mask_video("follow {mask_video}", 1), "follow <Video 1>")
|
||||
|
||||
|
||||
class ConditionFlowTest(unittest.TestCase):
|
||||
"""BFS Shot H3 Conditioning with the native H3 nodes replaced by recorders."""
|
||||
|
||||
def setUp(self):
|
||||
sys.path.insert(0, str(ROOT))
|
||||
calls = self.calls = {"r2v": [], "guides": []}
|
||||
|
||||
class Out:
|
||||
def __init__(self, *a):
|
||||
self.args = a
|
||||
|
||||
class R2V:
|
||||
@staticmethod
|
||||
def execute(**kw):
|
||||
calls["r2v"].append(kw)
|
||||
N = sys.modules["comfy.nested_tensor"].NestedTensor
|
||||
return Out([[torch.zeros(1), {}]], {"samples": N((torch.zeros(1, 24, 2, 4, 4), torch.zeros(1, 32, 2, 37)))})
|
||||
|
||||
class Guide:
|
||||
@staticmethod
|
||||
def execute(positive, latent, frame_idx, vae, image=None, **kw):
|
||||
calls["guides"].append(image)
|
||||
return Out(positive)
|
||||
|
||||
mod = types.ModuleType("comfy_extras.nodes_minimax_h3")
|
||||
mod.MiniMaxH3ReferenceToVideo, mod.MiniMaxH3AddGuide = R2V, Guide
|
||||
sys.modules.setdefault("comfy_extras", types.ModuleType("comfy_extras"))
|
||||
self._old = sys.modules.get("comfy_extras.nodes_minimax_h3")
|
||||
sys.modules["comfy_extras.nodes_minimax_h3"] = mod
|
||||
if "comfy.nested_tensor" not in sys.modules:
|
||||
class _N:
|
||||
is_nested = True
|
||||
|
||||
def __init__(self, t):
|
||||
self.tensors = list(t)
|
||||
comfy = sys.modules.setdefault("comfy", types.ModuleType("comfy"))
|
||||
nt = types.ModuleType("comfy.nested_tensor"); nt.NestedTensor = _N
|
||||
comfy.nested_tensor = nt; sys.modules["comfy.nested_tensor"] = nt
|
||||
|
||||
def tearDown(self):
|
||||
if self._old is not None:
|
||||
sys.modules["comfy_extras.nodes_minimax_h3"] = self._old
|
||||
else:
|
||||
sys.modules.pop("comfy_extras.nodes_minimax_h3", None)
|
||||
|
||||
def _shot(self, **kw):
|
||||
m = torch.zeros(22, 64, 64); m[:, 16:48, 16:40] = 1
|
||||
shot = {"index": 0, "count": 1, "frames": torch.full((22, 64, 64, 3), 0.7), "ref": torch.zeros(1, 32, 32, 3),
|
||||
"ref2": None, "prompt": "subject_definitions: <Subject 1> is ...", "width": 64, "height": 64,
|
||||
"gen_length": 22, "audio": None, "crop": {"crop_mask": m, "expand": 0} if kw.get("crop") else None,
|
||||
"inpaint": kw.get("inpaint", True)}
|
||||
if not kw.get("crop"):
|
||||
shot["mask_src"] = {"path": __file__, "analysis": {"fps": 24.0}, "start": 0, "length": 22, "spec": dict(SL.DEFAULT_MASK, expand=0)}
|
||||
return shot, m
|
||||
|
||||
def run_cond(self, shot, m, **kw):
|
||||
class VAE:
|
||||
def encode(self, px):
|
||||
return torch.zeros(1, 24, 2, px.shape[1] // 16, px.shape[2] // 16)
|
||||
orig = SL.shot_mask
|
||||
SL.shot_mask = lambda *a: {"masks": m.to(torch.uint8)}
|
||||
try:
|
||||
return SL.BFSShotH3Conditioning().condition(shot, None, VAE(), SL.BFSShotH3Conditioning.GUIDE_MODES[0], False,
|
||||
"none", "match", **kw)
|
||||
finally:
|
||||
SL.shot_mask = orig
|
||||
|
||||
def test_reference_video_of_the_masked_region(self):
|
||||
shot, m = self._shot()
|
||||
self.run_cond(shot, m, inpaint=SL.INPAINT_MODES[0], mask_guide=SL.MASK_GUIDES[3], mask_ref_size="full",
|
||||
mask_guide_look="colour")
|
||||
kw = self.calls["r2v"][0]
|
||||
v = kw["ref_videos"]["ref_video_1"]
|
||||
self.assertAlmostEqual(float(v[0, 2, 2, 0]), 0.5) # grey outside the mask
|
||||
self.assertAlmostEqual(float(v[0, 30, 30, 0]), 0.7) # the subject
|
||||
self.assertIn("<Video 1> shows only the region", kw["prompt"])
|
||||
self.assertEqual(len(self.calls["guides"]), 1) # the normal aligned guide is still there
|
||||
self.calls["r2v"].clear()
|
||||
self.run_cond(shot, m, inpaint=SL.INPAINT_MODES[0], mask_guide=SL.MASK_GUIDES[3], mask_ref_size="1/2")
|
||||
self.assertEqual(tuple(self.calls["r2v"][0]["ref_videos"]["ref_video_1"].shape[1:3]), (32, 32))
|
||||
|
||||
def test_extra_and_instead_guides(self):
|
||||
shot, m = self._shot()
|
||||
self.run_cond(shot, m, inpaint=SL.INPAINT_MODES[0], mask_guide=SL.MASK_GUIDES[1])
|
||||
self.assertEqual(len(self.calls["guides"]), 2)
|
||||
self.calls["guides"].clear()
|
||||
self.run_cond(shot, m, inpaint=SL.INPAINT_MODES[0], mask_guide=SL.MASK_GUIDES[2])
|
||||
self.assertEqual(len(self.calls["guides"]), 1)
|
||||
self.assertAlmostEqual(float(self.calls["guides"][0][0, 2, 2, 0]), 0.5) # it is the masked one
|
||||
|
||||
def test_ignored_outside_mask_only(self):
|
||||
shot, m = self._shot(inpaint=False)
|
||||
self.run_cond(shot, m, inpaint=SL.INPAINT_MODES[0], mask_guide=SL.MASK_GUIDES[3])
|
||||
self.assertNotIn("ref_videos", self.calls["r2v"][0])
|
||||
shot, m = self._shot(crop=True)
|
||||
self.calls["r2v"].clear()
|
||||
self.run_cond(shot, m, inpaint=SL.INPAINT_MODES[0], mask_guide=SL.MASK_GUIDES[3])
|
||||
self.assertNotIn("ref_videos", self.calls["r2v"][0])
|
||||
|
||||
|
||||
class DescribeShortTest(unittest.TestCase):
|
||||
def test_short_details_fit_inside_the_prompt(self):
|
||||
self.assertEqual(SL.tidy_details('"The green goblin in a torn leather vest."\nextra', {"describe_preset": "short"}),
|
||||
"green goblin in a torn leather vest")
|
||||
self.assertEqual(SL.tidy_details("A young woman.", {"describe_preset": "full body"}), "A young woman.")
|
||||
self.assertIn("one English noun phrase", SL.describe_instruction({}))
|
||||
|
||||
def test_every_preset_removes_vlm_answer_labels(self):
|
||||
for preset in SL.DESCRIBE_PRESETS:
|
||||
with self.subTest(preset=preset):
|
||||
self.assertEqual(SL.tidy_details("**Answer:** elderly woman with large glasses", {"describe_preset": preset}),
|
||||
"elderly woman with large glasses")
|
||||
|
||||
def test_short_mode_honours_its_twenty_word_contract(self):
|
||||
text = "Answer: The " + " ".join(f"word{i}" for i in range(30))
|
||||
out = SL.tidy_details(text, {"describe_preset": "short"})
|
||||
self.assertEqual(len(out.split()), 20)
|
||||
self.assertFalse(out.lower().startswith(("answer", "the ")))
|
||||
|
||||
def test_long_modes_are_bounded_but_not_collapsed_to_short(self):
|
||||
text = " ".join(f"word{i}" for i in range(170))
|
||||
full = SL.tidy_details(text, {"describe_preset": "full body"})
|
||||
face = SL.tidy_details(text, {"describe_preset": "head / face"})
|
||||
self.assertEqual(len(full.split()), 150)
|
||||
self.assertEqual(len(face.split()), 110)
|
||||
self.assertTrue(full.endswith("."))
|
||||
|
||||
|
||||
class FitPromptTest(unittest.TestCase):
|
||||
P = ("body_swap: subject_definitions: <Subject 1> is the {details} shown in <Picture 1>, whose identity define the "
|
||||
"character. The guide video provides the scene; only {target} is replaced: their face become <Subject 1>. "
|
||||
"summary: Replace {target} in the guide video with <Subject 1>.")
|
||||
|
||||
def test_slot_is_the_sentence_with_a_blank(self):
|
||||
d = SL.prompt_slot(self.P, "{details}")
|
||||
self.assertIn("<Subject 1> is the ___ shown in <Picture 1>", d)
|
||||
self.assertNotIn("{target}", d)
|
||||
t = SL.prompt_slot(self.P, "{target}")
|
||||
self.assertIn("only ___ is replaced", t)
|
||||
self.assertEqual(SL.prompt_slot("no marker here", "{details}"), "")
|
||||
|
||||
def test_answer_matches_the_blank(self):
|
||||
d = SL.prompt_slot(self.P, "{details}")
|
||||
self.assertEqual(SL.tidy_slot("A golden retriever with a thick coat.", d), "golden retriever with a thick coat")
|
||||
t = SL.prompt_slot(self.P, "{target}")
|
||||
self.assertEqual(SL.tidy_slot("the young woman in a black corset.", t), "the young woman in a black corset")
|
||||
self.assertIn("goes into the blank", SL.describe_instruction({"describe_preset": "short"}, d))
|
||||
self.assertNotIn("goes into the blank", SL.describe_instruction({"describe_preset": "short", "fit_prompt": False}, d))
|
||||
self.assertNotIn("goes into the blank", SL.describe_instruction({"describe_preset": "full body"}, d))
|
||||
# the VLM echoing the sentence (the bug the user hit)
|
||||
echo = ("humanoid robot is the ___ shown in <Picture 1>, whose identity define the character. The robot has a sleek "
|
||||
"body with a metallic silver torso.")
|
||||
self.assertEqual(SL.tidy_slot(echo, d), "humanoid robot")
|
||||
self.assertEqual(SL.tidy_slot("sleek silver humanoid robot with black joints shown in <Picture 1>", d),
|
||||
"sleek silver humanoid robot with black joints")
|
||||
self.assertEqual(SL.tidy_slot("<Subject 1> is the green goblin in a vest", d), "green goblin in a vest")
|
||||
|
||||
def test_target_label_is_removed_even_without_a_prompt_slot(self):
|
||||
old = SL.vlm_generate
|
||||
SL.vlm_generate = lambda *args, **kwargs: "Answer: elderly woman with large tortoiseshell glasses"
|
||||
try:
|
||||
crop = __import__("numpy").zeros((8, 8, 3), dtype="uint8")
|
||||
self.assertEqual(SL.describe_target(object(), crop),
|
||||
"the elderly woman with large tortoiseshell glasses")
|
||||
finally:
|
||||
SL.vlm_generate = old
|
||||
|
||||
|
||||
class RangeTest(unittest.TestCase):
|
||||
def test_start_end_and_max_total_from_the_start(self):
|
||||
self.assertEqual(SL.plan_range(1000, 24.0, 10, 20), (240, 480))
|
||||
self.assertEqual(SL.plan_range(1000, 24.0, 10, 0), (240, 1000))
|
||||
self.assertEqual(SL.plan_range(1000, 24.0, 10, 0, 5), (240, 360)) # max total counted from the start
|
||||
self.assertEqual(SL.plan_range(1000, 24.0, 0, 0, 0, start_f=250, end_f=500), (250, 500)) # frames win
|
||||
self.assertEqual(SL.plan_range(1000, 24.0, 0, 0, 0, start_f=250, end_f=0), (250, 1000))
|
||||
segs = SL.plan_segments(480, [100, 300], "shots", 107, 10, start=240)
|
||||
self.assertEqual(segs[0]["start"], 240)
|
||||
self.assertEqual(segs[-1]["end"], 480)
|
||||
self.assertTrue(all(240 <= s["start"] < s["end"] <= 480 for s in segs))
|
||||
self.assertTrue(any(s["start"] == 300 for s in segs)) # the cut inside the range is kept
|
||||
fixed = SL.plan_segments(480, [], "fixed", 107, 10, start=240)
|
||||
self.assertEqual((fixed[0]["start"], fixed[-1]["end"]), (240, 480))
|
||||
manual = SL.plan_segments(480, [], "manual", 107, 10, manual=[100, 350], start=240)
|
||||
self.assertEqual([(s["start"], s["end"]) for s in manual], [(240, 350), (350, 480)])
|
||||
|
||||
|
||||
class TrackModeTest(unittest.TestCase):
|
||||
def test_track_is_used_by_shots_without_their_own_selection_and_sliced_per_shot(self):
|
||||
p = {"track": {"points": [{"x": 0.5, "y": 0.5, "label": 1}], "frame": 120, "text": "woman"}, "fps": 24}
|
||||
self.assertIn("track", SL.plan_spec(p, {}, "v.mp4")["ext"])
|
||||
self.assertNotIn("ext", SL.plan_spec(p, {"text": "man"}, "v.mp4")) # the shot's own text wins
|
||||
self.assertEqual(SL.plan_spec(p, {"video": "m.mp4"}, "v.mp4")["ext"]["scope"], "shot")
|
||||
whole = torch.zeros(200, 36, 64, dtype=torch.uint8)
|
||||
whole[100:150, 10:20, 10:20] = 1 # the subject only in frames 100-149
|
||||
orig = SL.track_video
|
||||
SL.track_video = lambda path, a, t: (whole, 50) # the track covers frames 50-249
|
||||
try:
|
||||
spec = SL.plan_spec(p, {}, "v.mp4")
|
||||
an = {"n": 300, "fps": 24.0}
|
||||
m = SL.ext_masks(spec["ext"], an, 140, 20, 64, 36) # shot 140-159
|
||||
self.assertEqual(tuple(m.shape), (20, 36, 64))
|
||||
self.assertFalse(bool(m[0].any())) # frame 140 = track index 90
|
||||
self.assertTrue(bool(m[-1].any())) # frame 159 = index 109
|
||||
finally:
|
||||
SL.track_video = orig
|
||||
|
||||
|
||||
class DuetPromptTest(unittest.TestCase):
|
||||
def test_fields_are_found_and_the_vlm_fills_them(self):
|
||||
tpl = ("subject_definitions:\n<Subject 1> is the woman from <Picture 1>: {skin, face}.\n<Subject 2> is the {room} "
|
||||
"from {setting}.\n\nsummary:\nx {layout}")
|
||||
self.assertEqual(SL.template_slots(tpl), ["skin, face", "room"])
|
||||
asked = []
|
||||
|
||||
def gen(clip, q, images, max_tokens, temperature=0.0, seed=0):
|
||||
asked.append(q)
|
||||
if images is None:
|
||||
return tpl.replace("{skin, face}", "pale skin, oval face").replace("{room}", "kitchen")
|
||||
return '{"medium": "phone footage", "actions": "the person waves"}'
|
||||
|
||||
old = SL.vlm_generate, SL.vlm_describe
|
||||
SL.vlm_generate, SL.vlm_describe = gen, lambda clip, ims, q, mt=320, temperature=0.0, seed=0: "the kitchen with white cabinets"
|
||||
try:
|
||||
fr = torch.rand(9, 32, 32, 3)
|
||||
out = SL.duet_prompt(object(), tpl, fr, [torch.rand(1, 32, 32, 3)], "character swap", "", 512,
|
||||
setting=torch.rand(1, 32, 32, 3))
|
||||
self.assertIn("pale skin, oval face", out); self.assertIn("{setting}", out); self.assertIn("{layout}", out)
|
||||
self.assertIn("the person waves", asked[-1])
|
||||
self.assertEqual(SL.duet_prompt(None, tpl, fr, [], "character swap", ""), tpl.strip())
|
||||
self.assertEqual(SL.duet_prompt(object(), "a full prompt", fr, [], "character swap", ""), "a full prompt")
|
||||
w = SL.duet_prompt(object(), "", fr, [torch.rand(1, 32, 32, 3)], "character swap", "", 512,
|
||||
setting=torch.rand(1, 32, 32, 3))
|
||||
self.assertIn("<Subject 2> is the kitchen with white cabinets from {setting}.", w)
|
||||
finally:
|
||||
SL.vlm_generate, SL.vlm_describe = old
|
||||
|
||||
|
||||
class ShotHistoryTest(unittest.TestCase):
|
||||
def test_history_is_taken_out_of_the_shot(self):
|
||||
h = torch.full((17, 8, 8, 3), 255, dtype=torch.uint8)
|
||||
shot = {"chain": "history", "index": 1, "count": 2, "history": h, "history_src": h}
|
||||
hist, src = SL.shot_history(shot)
|
||||
self.assertEqual((tuple(hist.shape), float(hist.max()), "history" in shot), ((17, 8, 8, 3), 1.0, False))
|
||||
self.assertEqual(SL.shot_history({"chain": "off", "index": 1}), (None, None))
|
||||
|
||||
def test_auto_loop_takes_the_previous_render(self):
|
||||
SL._LAST_RESULT.clear()
|
||||
SL._LAST_RESULT.update(index=0, count=2, frames=torch.rand(40, 8, 8, 3))
|
||||
hist, _ = SL.shot_history({"chain": "history", "index": 1, "count": 2, "prev_length": 30, "chain_history": 34})
|
||||
self.assertEqual(hist.shape[0], 17) # 30 real frames -> one whole group of 17
|
||||
SL._LAST_RESULT.clear()
|
||||
|
||||
|
||||
class FillDetailsTest(unittest.TestCase):
|
||||
def test_render_node_fills_details_with_its_vlm(self):
|
||||
old = SL.vlm_describe
|
||||
SL.vlm_describe = lambda clip, ims, q, mt=320, temperature=0.0, seed=0: "The elderly man with white hair in a light blue t-shirt."
|
||||
try:
|
||||
out = SL.fill_details_now("<Subject 1> is the {details} shown in <Picture 1>.", object(), [torch.rand(1, 8, 8, 3)])
|
||||
self.assertEqual(out, "<Subject 1> is the elderly man with white hair in a light blue t-shirt shown in <Picture 1>.")
|
||||
self.assertIn("character", SL.fill_details_now("is the {details} shown", None, []))
|
||||
self.assertEqual(SL.fill_details_now("no slot", None, []), "no slot")
|
||||
finally:
|
||||
SL.vlm_describe = old
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -94,12 +94,15 @@ function html(type) {
|
||||
<tr><td>appearance</td><td><code>an elderly woman with short grey hair</code></td></tr>
|
||||
<tr><td>lighting / weather</td><td><code>night, lit by pink and blue neon signs</code></td></tr>
|
||||
</table>
|
||||
<p>Rules that matter (render-tested): the panel has <b>no tag</b> (it is not <Picture n> or <Video n>): call it
|
||||
"the kept footage" by its side, or write <code>{layout}</code> to insert the sentence. <b>Never describe the panel's person,
|
||||
clothes or room</b> (what you describe gets drawn; what you leave out is copied from the panel). Restate the new identity in
|
||||
every shot ("her face from <Picture 1>" + 2–3 face/hair/outfit words). Give exact times for cuts
|
||||
("[Shot 2] At 00:03.708, both halves cut together to…"). No negations ("no hat" draws a hat). The full example is in the
|
||||
prompt tooltip.</p>
|
||||
<p>Rules that matter (TSC, newer strategy): <b>H3 has no idea what is pinned and what is noised</b>, it reads one
|
||||
description of one video. Describe the finished split screen as plain footage: never write "kept footage", "generated",
|
||||
"preserved", "in sync with", "seamlessly". The pinned clip gets <b>no tag and no retention line</b>: name its region by where
|
||||
it is plus what is seen there, in one clause ("the top quarter shows a man dancing in a small bedroom";
|
||||
<code>{panel_region}</code> / <code>{video_region}</code> give the region names, <code>{layout}</code> the whole sentence).
|
||||
Write the other region as its own scene in a real place. <b>Spell out the motion</b> beat by beat, with times for the big
|
||||
moments on the video's clock. The soundscape is the sounds of the new scene. Cuts: "[Shot 2] At 00:03.708, both halves cut
|
||||
together to…". No negations ("no hat" draws a hat). Connect a VLM to <code>vlm</code> to write the prompt or fill a
|
||||
template's <code>{...}</code> fields. The full example is in the prompt tooltip.</p>
|
||||
|
||||
<h3>Layout</h3>
|
||||
<p><b>position</b>: the side of the panel.</p>
|
||||
|
||||
+148
-40
@@ -11,8 +11,8 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { showGuide } from "./bfs_md_view.js";
|
||||
import { createApp, ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount, h } from "./vendor/vue.esm-browser.prod.mjs";
|
||||
import { DEFAULTS, MASK_DEFAULTS, VLM_DEFAULTS, snapUp, snapDown, segKey, hasMask, pill } from "./shotloop/common.js";
|
||||
import { createApp, ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from "./vendor/vue.esm-browser.prod.mjs";
|
||||
import { h, t, uiLanguage, setUiLanguage, DEFAULTS, MASK_DEFAULTS, VLM_DEFAULTS, snapUp, snapDown, segKey, hasMask, pill } from "./shotloop/common.js";
|
||||
import { styles } from "./shotloop/styles.js";
|
||||
import { timelineCard } from "./shotloop/view_timeline.js";
|
||||
import { videoTab } from "./shotloop/view_video.js";
|
||||
@@ -20,7 +20,7 @@ import { shotsTab } from "./shotloop/view_shots.js";
|
||||
import { peopleTab } from "./shotloop/view_people.js";
|
||||
import { promptsTab } from "./shotloop/view_prompts.js";
|
||||
import { runTab } from "./shotloop/view_run.js";
|
||||
import { busyBar, pointsModal } from "./shotloop/view_modal.js";
|
||||
import { busyBar, pointsModal, tourModal } from "./shotloop/view_modal.js";
|
||||
|
||||
const viewUrl = name => {
|
||||
if (!name) return "";
|
||||
@@ -65,9 +65,10 @@ function Panel(io) {
|
||||
const targetCrop = ref({}); // "start-end" -> data URL of the subject the description was made from
|
||||
const tab = ref(store.get("bfs.shotloop.tab", "shots"));
|
||||
watch(tab, v => store.set("bfs.shotloop.tab", v));
|
||||
const tour = ref(0); // opened only by the user's Tour button; never shown automatically
|
||||
// what "Copy to other shots" copies (remembered per browser)
|
||||
const copyOpts = reactive(store.get("bfs.shotloop.copy", { ref: true, prompt: false, mask_text: false, points: false, crop: false,
|
||||
target: false, chain: false, scope: "all" }));
|
||||
target: false, chain: false, color_guide: false, scope: "all" }));
|
||||
watch(copyOpts, v => store.set("bfs.shotloop.copy", { ...v }), { deep: true });
|
||||
|
||||
const load = () => {
|
||||
@@ -84,15 +85,23 @@ function Panel(io) {
|
||||
};
|
||||
const save = () => io.setPlan(JSON.stringify(plan));
|
||||
|
||||
const n = computed(() => {
|
||||
if (!an.value) return 0;
|
||||
const cap = plan.max_total_s > 0 ? Math.round(plan.max_total_s * plan.fps) : Infinity;
|
||||
return Math.min(an.value.n, cap);
|
||||
const n = computed(() => (an.value ? an.value.n : 0)); // the whole video on the timeline
|
||||
// the part the plan works on [lo, hi): start / end (0 = the end), then "max total seconds" counted from the start
|
||||
const range = computed(() => {
|
||||
const N = n.value; if (!N) return { lo: 0, hi: 0 };
|
||||
// frames (older plans saved seconds)
|
||||
const a = plan.start_f != null ? +plan.start_f : Math.round((+plan.start_s || 0) * plan.fps);
|
||||
const b = plan.end_f != null ? +plan.end_f : (+plan.end_s > 0 ? Math.round(+plan.end_s * plan.fps) : 0);
|
||||
const lo = Math.max(0, Math.min(N - 1, a));
|
||||
let hi = b > 0 ? Math.max(lo + 1, Math.min(N, b)) : N;
|
||||
if (+plan.max_total_s > 0) hi = Math.min(hi, lo + Math.max(1, Math.round(+plan.max_total_s * plan.fps)));
|
||||
return { lo, hi };
|
||||
});
|
||||
const maxLen = computed(() => snapDown(Math.round(plan.max_s * plan.fps), plan.grid));
|
||||
const segs = computed(() => {
|
||||
const N = n.value; if (!N) return [];
|
||||
const b = [0, ...plan.bounds.filter(x => x > 0 && x < N), N].sort((a, c) => a - c);
|
||||
const { lo, hi } = range.value;
|
||||
const b = [lo, ...plan.bounds.filter(x => x > lo && x < hi), hi].sort((a, c) => a - c);
|
||||
const cs = new Set(cuts.value);
|
||||
let out = [];
|
||||
for (let i = 0; i < b.length - 1; i++) {
|
||||
@@ -100,8 +109,9 @@ function Panel(io) {
|
||||
const m = plan.segs[out.length] || {};
|
||||
out.push({ start: b[i], end: b[i + 1], len: b[i + 1] - b[i], gen: snapUp(b[i + 1] - b[i], plan.grid),
|
||||
cut: cs.has(b[i]), enabled: m.enabled !== false, ref: m.ref || "", ref2: m.ref2 || "", prompt: m.prompt || "",
|
||||
force: m.force || "auto", chain: m.chain || "off", chainFrame: m.chain_frame || "first",
|
||||
crop: !!m.crop, inpaint: !!m.inpaint, mask: m.mask || {}, target: m.target || "" });
|
||||
force: m.force || "auto", chain: m.chain || "history", chainFrame: m.chain_frame || "first", chainHistory: m.chain_history || 17,
|
||||
crop: !!m.crop, inpaint: !!m.inpaint, paste: !!m.paste, pasteText: m.paste_text || "", strength: m.strength ?? 1, mask: m.mask || {}, target: m.target || "",
|
||||
extMask: !!plan.mask_video || !!(plan.track?.points?.length), trackMask: !!(plan.track?.points?.length) });
|
||||
}
|
||||
if (plan.max_parts > 0) out = out.slice(0, plan.max_parts);
|
||||
return out;
|
||||
@@ -137,8 +147,11 @@ function Panel(io) {
|
||||
|
||||
// how a shot is generated: crop (the planner crops, the join pastes back) x inpaint (only the mask is regenerated,
|
||||
// read by BFS Shot H3 Conditioning with inpaint = per shot)
|
||||
const MODES = [["frame", "Full frame", false, false], ["mask", "Mask only", false, true], ["crop", "Crop", true, false], ["cropmask", "Crop + mask", true, true]];
|
||||
const modeOf = s => MODES.find(m => m[2] === !!s.crop && m[3] === !!s.inpaint)[0];
|
||||
// [key, name, crop, inpaint, paste]; paste = the whole frame is generated and BFS Shot Join pastes only the person back
|
||||
const MODES = [["frame", "Full frame", false, false, false], ["paste", "Frame + paste", false, false, true], ["mask", "Mask only", false, true, false],
|
||||
["crop", "Crop", true, false, false], ["cropmask", "Crop + mask", true, true, false]];
|
||||
const modeOf = s => (MODES.find(m => m[2] === !!s.crop && m[3] === !!s.inpaint && m[4] === (!!s.paste && !s.crop && !s.inpaint))
|
||||
|| MODES[0])[0];
|
||||
const modeName = s => MODES.find(m => m[0] === modeOf(s))[1];
|
||||
|
||||
const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return plan.segs[i]; };
|
||||
@@ -154,11 +167,15 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
if (skipWhy(s)) return;
|
||||
const add = (lvl, text) => out.push({ lvl, shot: i, text });
|
||||
const p = promptOf(s);
|
||||
if (s.len > maxLen.value) add("warn", `longer than ${maxLen.value} frames: split it`);
|
||||
if ((s.crop || s.inpaint) && !hasMask(s)) add("warn", `${modeName(s)} needs a mask (text or points) on the shot`);
|
||||
if (s.len > maxLen.value) add(plan.mode === "single" ? "note" : "warn", plan.mode === "single"
|
||||
? `one shot of ${s.len} frames (${(s.len / plan.fps).toFixed(1)} s), longer than Max seconds / shot: the model may not hold it`
|
||||
: `longer than ${maxLen.value} frames: split it`);
|
||||
if ((s.crop || s.inpaint || s.paste) && !hasMask(s)) add("warn", `${modeName(s)} needs a mask (text, points or a mask video; or the planner's mask input)`);
|
||||
if (hasMask(s) && maskPrev.value[segKey(s)]?.empty) add("warn", "the mask preview found nothing");
|
||||
if (/\{target\}/.test(p) && !s.target) add("warn", "the prompt uses {target} but the shot has no target description");
|
||||
if (/\{details\}/.test(p) && !detailsOf(s) && !plan.vlm_cfg?.enabled) add("info", "the prompt uses {details} but its references have no description yet");
|
||||
if (/\{details\}/.test(p) && (plan.vlm_cfg?.describe_preset || "short") !== "short")
|
||||
add("warn", "{details} sits inside a sentence of the prompt: use the 'short' describe preset (Prompts & refs), the other presets write a paragraph");
|
||||
if (!p) add("info", "no prompt (fine when the node's prompt input is connected)");
|
||||
if (!refOf(s, "ref")) add("info", "no reference (fine when the node's ref_image input is connected)");
|
||||
});
|
||||
@@ -198,8 +215,11 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
if (apply) {
|
||||
plan.bounds = j.segs.slice(1).map(s => s.start);
|
||||
const keep = plan.segs; plan.segs = j.segs.map((_, i) => ({ enabled: true, ref: keep[i]?.ref || "", ref2: keep[i]?.ref2 || "", prompt: keep[i]?.prompt || "",
|
||||
chain: keep[i]?.chain || "off", chain_frame: keep[i]?.chain_frame || "first",
|
||||
crop: !!keep[i]?.crop, inpaint: !!keep[i]?.inpaint, mask: keep[i]?.mask || {}, target: keep[i]?.target || "" }));
|
||||
chain: keep[i]?.chain || "history", chain_frame: keep[i]?.chain_frame || "first", chain_history: keep[i]?.chain_history || 17,
|
||||
crop: !!keep[i]?.crop, inpaint: !!keep[i]?.inpaint, paste: !!keep[i]?.paste, paste_text: keep[i]?.paste_text || "", strength: keep[i]?.strength ?? 1, mask: keep[i]?.mask || {}, target: keep[i]?.target || "",
|
||||
color_guide: !!keep[i]?.color_guide, color_guide_color: keep[i]?.color_guide_color || "", color_guide_opacity: keep[i]?.color_guide_opacity ?? 0.35,
|
||||
color_guide_person: keep[i]?.color_guide_person ?? null, color_guide_text2: keep[i]?.color_guide_text2 || "",
|
||||
color_guide_color2: keep[i]?.color_guide_color2 || "", color_guide_person2: keep[i]?.color_guide_person2 ?? null }));
|
||||
sel.value = 0; save();
|
||||
}
|
||||
} catch (e) { error.value = String(e.message || e); }
|
||||
@@ -228,7 +248,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
}
|
||||
const setCast = (id, k, v) => {
|
||||
plan.cast = { ...plan.cast, [String(id)]: { ...castOf(id), [k]: v } };
|
||||
if (k !== "ignore") remember(v);
|
||||
if (k === "ref" || k === "ref2") remember(v);
|
||||
save();
|
||||
};
|
||||
async function describeTarget(i) {
|
||||
@@ -239,7 +259,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
const j = await r.json(); if (j.error) throw new Error(j.error);
|
||||
targetCrop.value = { ...targetCrop.value, [segKey(s)]: j.crop };
|
||||
if (j.desc) setMeta(i, "target", j.desc);
|
||||
else error.value = "No VLM yet: connect it to the planner's vlm input and run once, or type the description yourself.";
|
||||
else error.value = "The SAM 3 target cutout was created (shown below), but no VLM is loaded to describe it. Connect Qwen3-VL to the Shot Planner's vlm input and queue the workflow once; then click Describe target again. You can also type the target description yourself.";
|
||||
} catch (e) { error.value = String(e.message || e); }
|
||||
busy.value = "";
|
||||
}
|
||||
@@ -284,15 +304,31 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
maskPrev.value = {}; save();
|
||||
};
|
||||
const savePointsAll = () => { pointsTo(modal.points, modal.key, modal.text, modal.idx); modal.open = false; };
|
||||
// one selection tracked by SAM 3 through the whole video (or range): every shot without its own selection takes its
|
||||
// part of that track, so the subject is found wherever it moves (copied points can fall on the background)
|
||||
const saveTrack = () => {
|
||||
const s0 = segs.value[modal.idx]; if (!s0 || !modal.points.length) return;
|
||||
const own = segs.value.filter((x, i) => i !== modal.idx && (x.mask.text || (x.mask.points || []).length)).length;
|
||||
if (own && !window.confirm(`${own} other shot(s) have their own mask text or points, which would win over the track. Clear them so every shot uses the track?`)) {
|
||||
// keep them: those shots keep their own selection
|
||||
} else {
|
||||
segs.value.forEach((_, i) => { const m = meta(i); if (m.mask) { m.mask = { ...m.mask, points: [], text: "" }; } });
|
||||
}
|
||||
const m = meta(modal.idx); m.mask = { ...(m.mask || {}), points: [], text: "" };
|
||||
plan.track = { points: modal.points.map(p => ({ ...p })), frame: s0.start + modal.key, text: modal.text || "",
|
||||
threshold: plan.mask_cfg?.threshold ?? 0.5 };
|
||||
maskPrev.value = {}; save(); modal.open = false;
|
||||
};
|
||||
const clearTrack = () => { plan.track = null; maskPrev.value = {}; save(); };
|
||||
const clearMask = i => {
|
||||
const m = meta(i); m.mask = {}; m.crop = false; m.inpaint = false;
|
||||
const m = meta(i); m.mask = {}; m.crop = false; m.inpaint = false; m.paste = false;
|
||||
const k = segs.value[i] ? segKey(segs.value[i]) : null;
|
||||
if (k) { const mp = { ...maskPrev.value }; delete mp[k]; maskPrev.value = mp; }
|
||||
save();
|
||||
};
|
||||
// copy the chosen settings of shot `from` to the other shots (all of them, or the ones after it)
|
||||
const COPY_FIELDS = { ref: "references", prompt: "prompt", mask_text: "mask text", points: "mask points", crop: "mode",
|
||||
target: "{target}", chain: "continuity" };
|
||||
const COPY_FIELDS = { ref: "references", prompt: "prompt", mask_text: "mask text", points: "mask points", mask_video: "mask video", crop: "mode",
|
||||
target: "{target}", chain: "continuity", color_guide: "Color Guide" };
|
||||
const copyFrom = (from, fields = copyOpts, scope = copyOpts.scope) => {
|
||||
const src = segs.value[from]; if (!src) return 0;
|
||||
const to = targets(scope, from);
|
||||
@@ -301,12 +337,19 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
if (fields.ref) { m.ref = src.ref; m.ref2 = src.ref2; }
|
||||
if (fields.prompt) m.prompt = src.prompt;
|
||||
if (fields.mask_text) m.mask = { ...(m.mask || {}), text: src.mask.text || "" };
|
||||
if (fields.crop && (hasMask(x) || src.mask.text || fields.mask_text)) { m.crop = src.crop; m.inpaint = src.inpaint; }
|
||||
if (fields.mask_video) m.mask = { ...(m.mask || {}), video: src.mask.video || "" };
|
||||
if (fields.crop && (hasMask(x) || src.mask.text || fields.mask_text)) { m.crop = src.crop; m.inpaint = src.inpaint; m.paste = src.paste; m.paste_text = src.pasteText; m.strength = src.strength; }
|
||||
if (fields.target) m.target = src.target;
|
||||
if (fields.chain && i > 0) { m.chain = src.chain; m.chain_frame = src.chainFrame; }
|
||||
if (fields.color_guide) {
|
||||
m.color_guide = src.color_guide; m.color_guide_color = src.color_guide_color;
|
||||
m.color_guide_opacity = src.color_guide_opacity; m.color_guide_person = src.color_guide_person;
|
||||
m.color_guide_text2 = src.color_guide_text2; m.color_guide_color2 = src.color_guide_color2;
|
||||
m.color_guide_person2 = src.color_guide_person2;
|
||||
}
|
||||
if (fields.chain && i > 0) { m.chain = src.chain; m.chain_frame = src.chainFrame; m.chain_history = src.chainHistory; }
|
||||
});
|
||||
if (fields.points && (src.mask.points || []).length) pointsTo(src.mask.points, src.mask.key ?? Math.floor(src.len / 2), undefined, from, scope);
|
||||
if (fields.mask_text || fields.points) maskPrev.value = {};
|
||||
if (fields.mask_text || fields.points || fields.mask_video) maskPrev.value = {};
|
||||
save();
|
||||
return to.length;
|
||||
};
|
||||
@@ -358,6 +401,10 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
const setCastOpt = (k, v) => { plan[k] = v; save(); if (k === "cast_split") autoSplit(true); };
|
||||
const setFilter = (k, v) => { plan.filters = { ...plan.filters, [k]: v }; save(); if (stats.value.length) analyzeContent(); };
|
||||
const setPlan = (k, v, after) => { plan[k] = v; save(); after && after(); };
|
||||
// the range changed: split again inside it (the per-shot settings stay with the shot at the same position)
|
||||
// start / end in timeline frames (end 0 = the end of the video); the old seconds fields are cleared
|
||||
const setRange = (k, v) => { plan[k] = Math.max(0, Math.round(+v || 0)); plan.start_s = 0; plan.end_s = 0; save(); autoSplit(true); };
|
||||
const setGlobalMaskVideo = v => { plan.mask_video = v; save(); maskPrev.value = {}; };
|
||||
// XHR instead of fetch: big videos show how much has been sent
|
||||
const postWithProgress = (url, body, onPct) => new Promise((resolve, reject) => {
|
||||
const x = new XMLHttpRequest();
|
||||
@@ -368,14 +415,39 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
x.onerror = () => reject(new Error("network error"));
|
||||
x.send(body);
|
||||
});
|
||||
// big files go in 8 MB pieces: ComfyUI refuses any single request over --max-upload-size (100 MB by default)
|
||||
const CHUNK = 8 * 1024 * 1024, CHUNKED_FROM = 32 * 1024 * 1024;
|
||||
async function uploadChunked(file, onPct) {
|
||||
const id = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
||||
for (let off = 0; off < file.size; off += CHUNK) {
|
||||
const piece = file.slice(off, Math.min(file.size, off + CHUNK));
|
||||
for (let tries = 0; ; tries++) { // a dropped piece is sent again (it is written at its offset)
|
||||
try {
|
||||
await postWithProgress(`/bfs/upload/chunk?id=${id}&offset=${off}`, piece,
|
||||
f => onPct((off + f * piece.size) / file.size));
|
||||
break;
|
||||
} catch (e) { if (tries >= 3) throw e; await new Promise(r => setTimeout(r, 1000 * (tries + 1))); }
|
||||
}
|
||||
}
|
||||
const r = await fetch(api.apiURL("/bfs/upload/finish"), { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, name: file.name, size: file.size }) });
|
||||
const j = await r.json();
|
||||
if (!r.ok || j.error) throw new Error(j.error || `HTTP ${r.status}`);
|
||||
return j.name;
|
||||
}
|
||||
async function upload(file, cb) {
|
||||
const fd = new FormData(); fd.append("image", file); fd.append("type", "input"); fd.append("overwrite", "true");
|
||||
const mb = (file.size / 1e6).toFixed(1);
|
||||
busy.value = `Uploading ${file.name} (${mb} MB)…`; upPct.value = 0;
|
||||
try {
|
||||
const j = await postWithProgress("/upload/image", fd, f => { upPct.value = f; });
|
||||
let name;
|
||||
if (file.size >= CHUNKED_FROM) name = await uploadChunked(file, f => { upPct.value = f; });
|
||||
else {
|
||||
const fd = new FormData(); fd.append("image", file); fd.append("type", "input"); fd.append("overwrite", "true");
|
||||
const j = await postWithProgress("/upload/image", fd, f => { upPct.value = f; });
|
||||
name = j.subfolder ? `${j.subfolder}/${j.name}` : j.name;
|
||||
}
|
||||
await refreshFiles();
|
||||
cb(j.subfolder ? `${j.subfolder}/${j.name}` : j.name);
|
||||
cb(name);
|
||||
} catch (e) { error.value = `Upload failed: ${e}`; }
|
||||
busy.value = "";
|
||||
}
|
||||
@@ -392,10 +464,22 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
}
|
||||
|
||||
// ---- editing
|
||||
const setVideo = v => { plan.video = v; plan.bounds = []; plan.segs = []; save(); analyze(); if (v) tab.value = "shots"; };
|
||||
const setVideo = v => {
|
||||
plan.video = v; plan.bounds = []; plan.segs = [];
|
||||
if (v) {
|
||||
// A new source starts as one manageable clip. The user can widen the range or ask for an automatic split later;
|
||||
// do not surprise them with a wall of detected shots on first load.
|
||||
plan.mode = "single";
|
||||
plan.start_f = 0;
|
||||
plan.end_f = Math.round(15 * plan.fps);
|
||||
plan.start_s = 0; plan.end_s = 0; plan.max_total_s = 0;
|
||||
tab.value = "video";
|
||||
}
|
||||
save(); analyze();
|
||||
};
|
||||
const splitAt = f => {
|
||||
const N = n.value; f = Math.round(f);
|
||||
if (f <= 0 || f >= N || plan.bounds.includes(f)) return;
|
||||
const { lo, hi } = range.value; f = Math.round(f);
|
||||
if (f <= lo || f >= hi || plan.bounds.includes(f)) return;
|
||||
const i = segs.value.findIndex(s => f > s.start && f < s.end);
|
||||
plan.bounds = [...plan.bounds, f].sort((a, c) => a - c);
|
||||
plan.segs.splice(i + 1, 0, { ...(plan.segs[i] || {}) });
|
||||
@@ -425,14 +509,14 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
removeCut(sel.value);
|
||||
};
|
||||
const setMeta = (i, k, v) => { meta(i)[k] = v; save(); };
|
||||
const setMode = (i, mode) => { const m = MODES.find(x => x[0] === mode); Object.assign(meta(i), { crop: m[2], inpaint: m[3] }); save(); };
|
||||
const setMode = (i, mode) => { const m = MODES.find(x => x[0] === mode); Object.assign(meta(i), { crop: m[2], inpaint: m[3], paste: m[4] }); save(); };
|
||||
const drag = (k, ev) => {
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
const box = tlEl.value.getBoundingClientRect();
|
||||
const move = e => {
|
||||
const x = e.clientX - box.left + tlEl.value.scrollLeft;
|
||||
const f = Math.round(x / pxPerFrame.value);
|
||||
const lo = (plan.bounds[k - 1] ?? 0) + 1, hi = (plan.bounds[k + 1] ?? n.value) - 1;
|
||||
const lo = (plan.bounds[k - 1] ?? range.value.lo) + 1, hi = (plan.bounds[k + 1] ?? range.value.hi) - 1;
|
||||
plan.bounds[k] = Math.max(lo, Math.min(hi, f));
|
||||
hover.value = { frame: plan.bounds[k], x };
|
||||
};
|
||||
@@ -499,18 +583,32 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
// ---- references
|
||||
// the last 10 references picked, remembered across workflows (per browser)
|
||||
const RECENT_KEY = "bfs.shotloop.recentRefs";
|
||||
const recent = ref(store.get(RECENT_KEY, []));
|
||||
const isColorValue = name => typeof name === "string" && /^#[0-9a-f]{6}$/i.test(name);
|
||||
const savedRecent = store.get(RECENT_KEY, []);
|
||||
const recent = ref((Array.isArray(savedRecent) ? savedRecent : []).filter(name => typeof name === "string" && !isColorValue(name)));
|
||||
if (recent.value.length !== (Array.isArray(savedRecent) ? savedRecent.length : 0)) store.set(RECENT_KEY, recent.value);
|
||||
const remember = name => {
|
||||
if (!name) return;
|
||||
if (!name || typeof name !== "string" || isColorValue(name)) return;
|
||||
recent.value = [name, ...recent.value.filter(x => x !== name)].slice(0, 10);
|
||||
store.set(RECENT_KEY, recent.value);
|
||||
};
|
||||
// target: shot index or "global"; field: ref | ref2 (global_ref | global_ref2 for "global")
|
||||
const setRef = (target, field, name) => {
|
||||
if (isColorValue(name)) return; // colors belong to Color Guide settings, never to image references
|
||||
if (target === "global") plan[field] = name; else meta(target)[field] = name;
|
||||
remember(name); save();
|
||||
};
|
||||
const uploadRef = (target, field) => pickFile("image/*", name => setRef(target, field, name));
|
||||
const clearRecent = () => { recent.value = []; store.set(RECENT_KEY, []); };
|
||||
// everything: the recent list AND every reference this plan uses (global, per shot, per person in the cast)
|
||||
const clearAllRefs = () => {
|
||||
if (!confirm("Remove every reference picture from this plan (global, every shot and every person) and forget the recent list?")) return;
|
||||
clearRecent();
|
||||
plan.global_ref = ""; plan.global_ref2 = "";
|
||||
plan.segs.forEach(m => { if (m) { m.ref = ""; m.ref2 = ""; } });
|
||||
Object.values(plan.cast || {}).forEach(cc => { if (cc) { cc.ref = ""; cc.ref2 = ""; } });
|
||||
save();
|
||||
};
|
||||
const usedRefs = computed(() => {
|
||||
const set = new Set([...recent.value, plan.global_ref, plan.global_ref2, ...plan.segs.flatMap(m => [m?.ref, m?.ref2])].filter(Boolean));
|
||||
return [...set].slice(0, 10);
|
||||
@@ -519,12 +617,12 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
// everything the views need
|
||||
const c = {
|
||||
api, plan, files, an, cuts, detectorUsed, size, busy, error, status, busySince, now, upPct, sel, zoom, hover, prog, tlEl,
|
||||
stats, people, maskPrev, showMasks, vlmSug, modal, segPeople, targetCrop, tab, copyOpts, COPY_FIELDS, vid, play, recent,
|
||||
n, maxLen, segs, active, pxPerFrame, tlWidth, filtersOn, refSets, usedRefs, checks,
|
||||
stats, people, maskPrev, showMasks, vlmSug, modal, segPeople, targetCrop, tab, copyOpts, COPY_FIELDS, vid, play, recent, clearRecent, clearAllRefs,
|
||||
n, range, setRange, maxLen, segs, active, pxPerFrame, tlWidth, filtersOn, refSets, usedRefs, checks, tour,
|
||||
MODES, modeOf, modeName, viewUrl, save, meta, setMeta, setMode, setPlan, whoIn, castOf, linked, castRef, personOf, statFor, skipWhy, refOf, promptOf, checksFor,
|
||||
refreshFiles, analyze, autoSplit, analyzeContent, findPeople, setCast, describeTarget, previewMask, setMask, setMaskCfg,
|
||||
openPoints, savePoints, savePointsAll, pointsTo, clearMask, copyFrom, analyseVLM, describeRefs, setDetail, setVlmCfg,
|
||||
applySug, applyAllSug, setCastOpt, setFilter, pickFile, progress, setVideo, splitAt, mergeNext, removeCut, select, drag,
|
||||
openPoints, savePoints, savePointsAll, saveTrack, clearTrack, pointsTo, clearMask, copyFrom, analyseVLM, describeRefs, setDetail, setVlmCfg,
|
||||
applySug, applyAllSug, setCastOpt, setGlobalMaskVideo, setFilter, pickFile, progress, setVideo, splitAt, mergeNext, removeCut, select, drag,
|
||||
frameAt, thumbFor, playShot, playAll, stop, seek, setRef, uploadRef,
|
||||
};
|
||||
|
||||
@@ -534,10 +632,19 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
const guideUrl = new URL("./docs/BFSShotPlanner.md", import.meta.url).href;
|
||||
const header = h("div", { class: "hdr" }, [
|
||||
h("span", { class: "ttl" }, "🎬 Shot Planner"),
|
||||
an.value ? pill(`${active.value.length}/${S.length} shots · ${(N / fps).toFixed(1)}s`, "ok") : null,
|
||||
an.value ? pill(`${active.value.length}/${S.length} shots · ${((range.value.hi - range.value.lo) / fps).toFixed(1)}s`
|
||||
+ (range.value.lo > 0 || range.value.hi < N ? " (range)" : ""), "ok") : null,
|
||||
warns ? h("span", { class: "pill warn", style: "cursor:pointer", title: "open the checks (Run tab)", onClick: () => { tab.value = "run"; } }, `⚠ ${warns}`) : null,
|
||||
h("span", { class: "grow" }),
|
||||
h("label", { class: "langpick", title: t("Language") }, [
|
||||
h("span", { class: "hint" }, "🌐"),
|
||||
h("select", { value: uiLanguage.value, "aria-label": t("Language"), onChange: e => setUiLanguage(e.target.value) }, [
|
||||
h("option", { value: "en" }, "English"), h("option", { value: "pt" }, "Português"),
|
||||
h("option", { value: "es" }, "Español"), h("option", { value: "zh" }, "中文"),
|
||||
]),
|
||||
]),
|
||||
pill(plan.run === "queue" ? "queue loop" : "auto loop"),
|
||||
h("button", { title: t("Shot Planner tour"), onClick: () => { tour.value = 1; tab.value = "video"; } }, t("🧭 Tour")),
|
||||
h("button", { title: "Open the full guide (every setting explained, with examples)", onClick: () => showGuide(guideUrl) }, "📖 Guide"),
|
||||
]);
|
||||
const counts = { shots: S.length || null, people: people.value.length || null };
|
||||
@@ -554,6 +661,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
an.value ? timelineCard(c) : null,
|
||||
views[cur](c),
|
||||
pointsModal(c),
|
||||
tourModal({ tour, tab }),
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
+79
-18
@@ -33,7 +33,7 @@ node's advanced settings, with defaults that work.
|
||||
|
||||
- **The source clip** (TSC's duet): the new character copies its motion, camera and cuts frame for
|
||||
frame. Give the new character with `<Picture 1>` (and the place with `<Picture 2>`). The pinned clip
|
||||
has no tag: the prompt calls it "the kept footage" (`layout_text` gives the sentence). See TSC's
|
||||
has no tag: the prompt names its region by where it is and what is seen there (`{layout}` / `{panel_region}`). See TSC's
|
||||
prompting guide: never describe the source performer, restate the identity in every shot.
|
||||
- **A reference image**: a static picture of the person next to the video.
|
||||
|
||||
@@ -62,40 +62,101 @@ References work like the official Reference to Video node: up to 9 images (`<Pic
|
||||
|
||||
## Prompt
|
||||
|
||||
The node's prompt box starts with a template. The side panel has **no tag**: it is not a `<Picture n>`
|
||||
or `<Video n>`, and the text encoder never sees it. Name it by its place ("the LEFT half is the kept
|
||||
footage"); `{layout}` inserts that sentence for the current side and size. Describe only the generated
|
||||
part: never the panel's performer, clothes or room, even to contrast them, because whatever you leave
|
||||
undescribed is copied from the panel. Restate the new identity in every shot ("her face from
|
||||
`<Picture 1>`" plus two or three face, hair or outfit words) and give exact times for cuts.
|
||||
TSC's newer strategy (2026-10): **H3 has no idea what is pinned and what is noised.** It reads one description of
|
||||
one video, so the prompt describes the finished split screen as plain footage. Never write "kept footage", "kept
|
||||
exactly", "generated", "preserved", "in sync with", "seamlessly": they describe nothing on screen and crowd out the
|
||||
action.
|
||||
|
||||
Example (character swap, source clip pinned on the left, a face picture and a full-body picture):
|
||||
- **No tag and no retention line for the pinned clip.** It is not a reference input, only latents in the canvas. Name
|
||||
its region by where it is plus what is seen there, in one clause: "the top quarter shows a man dancing in a small
|
||||
bedroom". `{panel_region}` / `{video_region}` become the region names for the current side and size ("the top
|
||||
quarter", "the bottom three quarters"), `{layout}` the whole split-screen sentence.
|
||||
- **Write the other region as its own scene**, doing the same movements at the same moments, in a real place with
|
||||
concrete details.
|
||||
- **Spell out the motion** beat by beat, with times for the big moments (cuts, strikes, jumps, a line). The pinned
|
||||
half alone does not carry the moves. Times are on the video's clock (the pinned clip starts at frame 0 here).
|
||||
- **Soundscape** = the sounds of the new scene.
|
||||
- Cuts: `[Shot 2] At MM:SS.mmm, both halves cut together to ...` with the new framing.
|
||||
|
||||
The VLM writer and the template filler follow these rules, and a scrub removes the old meta words from what they
|
||||
return. Template (fill the `{...}`, or let the VLM fill them):
|
||||
|
||||
```
|
||||
subject_definitions:
|
||||
<Subject 1> is the woman whose appearance comes from <Picture 1> and <Picture 2>: fair skin, a narrow oval face, grey-green eyes, light brown hair in a low ponytail, wearing a white long-sleeved top under a navy denim apron.
|
||||
<Subject 1> is {name or plain description} whose appearance comes from <Picture 1>: {concrete look words}, wearing {outfit piece by piece}.
|
||||
|
||||
summary:
|
||||
[reference generation] The target video is a split screen: the kept footage beside <Subject 1>, who moves in sync with it, in the same bedroom.
|
||||
[reference generation] The target video is a split screen: {panel_region} shows {what is seen in the source, one clause}, and {video_region} shows <Subject 1> doing the same movements at the same moments, in {place}.
|
||||
|
||||
retention_analysis:
|
||||
<Subject 1> (appears in [Shot 1], [Shot 2]): fully_preserved - her face, ponytail, white top and navy apron are retained.
|
||||
The kept footage: fully_preserved - the panel is kept exactly.
|
||||
<Subject 1> (appears in [Shot 1]): fully_preserved - {key look words} are retained.
|
||||
|
||||
detailed_description:
|
||||
The target video is in a realistic style, as handheld vertical smartphone footage under soft daylight. {layout}
|
||||
The target video is in a {realistic / 3D CG / anime} style, as {the medium of the source clip}. A split screen divided by a thin straight {vertical / horizontal} line: {panel_region} shows {what is seen there, one clause}; {video_region} shows <Subject 1> performing the same movements at the same moments, in {place with concrete details}.
|
||||
|
||||
[Shot 1] A medium close-up at chest height, the phone steady at eye level. <Subject 1>, her face from <Picture 1> with grey-green eyes and soft pink lips, her light brown ponytail and navy apron, talks to the camera with small nods.
|
||||
|
||||
[Shot 2] At 00:03.708, both halves cut together to a closer framing. <Subject 1>, her face from <Picture 1>, the white sleeves and apron straps visible, raises one open hand beside her mouth and smiles.
|
||||
[Shot 1] {Camera distance, angle and movement as the source shows it}. <Subject 1>, the face from <Picture 1>, {what they do, one sentence per real action}. At {00:01.500} {big beat}.
|
||||
|
||||
overall_soundscape:
|
||||
Her voice speaking to the camera in a quiet bedroom.
|
||||
{The sounds of the new scene and when they happen.}
|
||||
|
||||
non_diegetic_music:
|
||||
None.
|
||||
```
|
||||
|
||||
Example (character swap, source clip pinned on the left half, one face picture and one full-body picture):
|
||||
|
||||
```
|
||||
subject_definitions:
|
||||
<Subject 1> is a young woman whose appearance comes from <Picture 1> and <Picture 2>: fair skin, a narrow oval face, grey-green eyes, light brown hair in a low ponytail, wearing a white long-sleeved top under a navy denim apron.
|
||||
|
||||
summary:
|
||||
[reference generation] The target video is a split screen: the left half shows a man talking to a phone camera in a bedroom, and the right half shows <Subject 1> doing the same movements at the same moments, in a sunny kitchen with white tiles.
|
||||
|
||||
retention_analysis:
|
||||
<Subject 1> (appears in [Shot 1], [Shot 2]): fully_preserved - her face, ponytail, white top and navy apron are retained.
|
||||
|
||||
detailed_description:
|
||||
The target video is in a realistic style, as handheld vertical smartphone footage under soft daylight. A split screen divided by a thin straight vertical line: the left half shows a man talking to a phone camera in a bedroom; the right half shows <Subject 1> performing the same movements at the same moments, in a sunny kitchen with white tiles and a window behind her.
|
||||
|
||||
[Shot 1] A medium close-up at chest height, the phone steady at eye level. <Subject 1>, her face from <Picture 1> with grey-green eyes and soft pink lips, talks to the camera with small nods. At 00:01.800 she laughs and tilts her head back.
|
||||
|
||||
[Shot 2] At 00:03.708, both halves cut together to a closer framing. <Subject 1>, her face from <Picture 1>, the white sleeves and apron straps visible, raises one open hand beside her mouth and smiles.
|
||||
|
||||
overall_soundscape:
|
||||
Her voice speaking to the camera, a kettle starting to hiss in the kitchen at 00:02.500.
|
||||
|
||||
non_diegetic_music:
|
||||
None.
|
||||
```
|
||||
|
||||
## Same-world swaps: static and setting picture (1.84.0)
|
||||
|
||||
TSC's two fixes for a swap in the same room with the same clothes, without a VLM:
|
||||
|
||||
- **`panel_static`** (`off` / `face` / `person`): SAM 3 tracks the face (or the whole person) of the pinned clip and
|
||||
covers it in black-and-white TV static on every frame. The generated half can no longer copy the source face, so the
|
||||
identity comes from the reference picture; motion, framing and camera still come from the panel. The static only
|
||||
exists in the panel, which is cropped off, so it never shows in the output. `face` keeps the body (best when the
|
||||
outfit should stay); `person` hides the whole performer (best when the build and outfit should change).
|
||||
- **`setting`** + **BFS Setting Picture**: one frame of the clip with the person in static. Plugged into `setting`, it
|
||||
becomes the last `<Picture n>` and the prompt gets *"<Picture n> shows the setting, the same place as the original performance
|
||||
in full detail; the noise patch in it is where <Subject 1> stands."* (or write `{setting}` to place its tag yourself).
|
||||
This stops the background and framing from being taken from the character picture.
|
||||
|
||||
## Prompt from a VLM (optional, 1.85.0)
|
||||
|
||||
Plug a Qwen3-VL CLIP (the same CLIPLoader as `clip` works) into `vlm`:
|
||||
|
||||
- **prompt empty**: the VLM writes it from the task + instruction (TSC's six sections; with a `setting` picture the
|
||||
place becomes `<Subject 2> ... from <Picture n>`).
|
||||
- **prompt with `{...}` fields** (TSC's template): the VLM fills only the fields, from the clip's frames, the
|
||||
reference pictures and the setting picture; every other word stays. `{layout}`, `{setting}`, `{target}` and
|
||||
`{details}` are filled by the node, not the VLM.
|
||||
- **prompt without fields**: used as written (the VLM is not called).
|
||||
|
||||
Without `vlm`, a prompt that still has `{...}` fields logs a warning. The prompt actually used is the `prompt` output
|
||||
and is printed under the comparison.
|
||||
|
||||
## Settings
|
||||
|
||||
| setting | effect |
|
||||
@@ -107,7 +168,7 @@ None.
|
||||
| hold | pin the panel for the whole clip, or only its first latent frame |
|
||||
| guide | optional aligned latent guide in the video area (what the body-swap LoRAs use) |
|
||||
| rope_mode | *canvas*: panel and video share one wide grid (TSC). *shifted*: the video keeps the RoPE positions of a render without the panel, and the panel sits past its edge |
|
||||
| (prompt in shifted mode) | With *shifted*, `{layout}` becomes "the whole frame shows the generated video, moving in sync with the kept footage": do not describe a split screen there, or the model splits the video itself. *canvas* keeps the split-screen wording and follows the framing more closely (recommended). |
|
||||
| (prompt in shifted mode) | With *shifted*, `{layout}` becomes empty: do not describe a split screen there, or the model splits the video itself. *canvas* keeps the split-screen wording and follows the framing more closely (recommended). |
|
||||
| rope_gap | *shifted* only: empty RoPE steps (2x2 patches) between video and panel; the panel moves away in position without any pixels in between. Keep it small against the video width: at 320 px wide (10 patches) a gap of 8 made the model draw its own split screen; 0 works everywhere |
|
||||
|
||||
## Notes from tests
|
||||
|
||||
@@ -25,6 +25,10 @@ every shot inside that range.
|
||||
|
||||
## The panel
|
||||
|
||||
Use the language selector in the top bar to switch the interface between English, Portuguese, Spanish and Simplified
|
||||
Chinese. The choice is saved in this browser and does not alter the workflow plan or prompt text. **Tour** opens an
|
||||
optional step-by-step introduction when clicked; it never appears automatically.
|
||||
|
||||
The planner's panel has five tabs, in the order you work. The timeline stays on top of every tab.
|
||||
|
||||
| tab | what is there |
|
||||
@@ -74,10 +78,20 @@ camera cuts.
|
||||
minimum merge into a neighbour when the merge still fits; shots longer than the maximum split
|
||||
into equal parts.
|
||||
- **Fixed length**: equal parts no longer than the maximum.
|
||||
- **One shot (whole range)**: the whole range (start / end frame, *Max total seconds*) as a single shot, whatever its
|
||||
length; *Max seconds / shot* is not applied. For "N seconds in one piece" set *Range length, seconds* = N, right
|
||||
next to the mode (it moves the end frame; *Max total seconds* in the Run tab does the same for quick tests).
|
||||
For pieces of at most X seconds use *Fixed length* with *Max seconds / shot* = X.
|
||||
- **By hand**: drag the white handles on the timeline to move a boundary, double-click the
|
||||
shot bar to split, *⇥ Merge* to join, or select a shot and press **Delete** to remove the cut at its
|
||||
start (it merges into the previous shot). Your edits are what runs.
|
||||
|
||||
**Range** (🎞 Video tab, with its own player): *Start frame* / *End frame* (timeline frames; end 0 = the end of the
|
||||
video) limit the plan to a part of the video, before anything is split, analysed or generated. Play or scrub the player
|
||||
(or click the timeline) and press *⇤ start here* / *⇥ end here* to take its exact frame. The
|
||||
timeline dims what is outside; the join and the soundtrack cover only the range. *Max total seconds* counts from the
|
||||
start of the range.
|
||||
|
||||
*Max seconds / shot* is converted to frames and snapped down to the grid (4.5 s at 24 fps =
|
||||
107 frames for H3). *Max shots* and *Max total seconds* cap a long source.
|
||||
|
||||
@@ -122,8 +136,11 @@ matching audio) when *Skipped shots in the output* is set to remove them.
|
||||
## Cast (people by face)
|
||||
|
||||
*Find people* samples the video every 0.5 s, detects faces and groups them into people by face
|
||||
identity (InsightFace `buffalo_l` in `models/insightface`, on the CPU with a few threads so it stays
|
||||
light and never touches the GPU; the result is cached per video). Each
|
||||
identity, on the CPU (it never touches the GPU; the result is cached per video). **No extra package is needed**: by
|
||||
default it uses OpenCV Zoo's **YuNet** (detection) + **SFace** (recognition), Apache-2.0 models run by OpenCV itself and
|
||||
downloaded once into `models/opencv_face` (0.2 + 37 MB). If **InsightFace** is installed (`buffalo_l` in
|
||||
`models/insightface`) it is used instead (a bit more accurate; mind its license). Last resort, without either: SAM 3.1
|
||||
faces grouped by colours (hair, skin, clothes). Each
|
||||
person card shows the face, screen share and first/last appearance (click the face to seek).
|
||||
Give a person a reference (and a second one) by clicking the slot, or pick one from the recent
|
||||
references; *ignore* leaves that person out. Every shot card shows who is in it, with the main
|
||||
@@ -160,17 +177,79 @@ yellow = crop box). Then pick how the shot is generated:
|
||||
| mode | what is generated | crop / uncrop |
|
||||
|---|---|---|
|
||||
| **Full frame** | the whole frame; the mask only feeds `{target}` and the setting picture | no |
|
||||
| **🧩 Frame + paste** | the whole frame (so the model follows the guide's pose freely and the new person may be bigger); BFS Shot Join then pastes only the person onto the original frame: the shot's mask plus the new subject's outline (SAM 3 on the result with the shot's *new subject* text, default `person`; `paste_new_outline`) | no crop; paste in the Join |
|
||||
| **🎭 Mask only** | only the mask, on the whole frame; everything else stays exactly as it was | no |
|
||||
| **✂ Crop** | a box around the mask (more pixels for a small person); BFS Shot Join pastes it back | yes |
|
||||
| **✂🎭 Crop + mask** | only the mask, inside the crop: the most detail with the background kept | yes |
|
||||
|
||||
The two mask modes need **BFS Shot H3 Conditioning → inpaint = per shot (planner)** (the default; *only the mask*
|
||||
*Frame + paste* is the safest for a different body (e.g. a woman in profile replaced by a man facing the camera):
|
||||
*Mask only* binds the new person to the old outline, *Frame + paste* does not. The two mask modes need **BFS Shot H3 Conditioning → inpaint = per shot (planner)** (the default; *only the mask*
|
||||
forces it on every shot, *off* never). The crop is one box around the mask (the union over all its frames, so it
|
||||
does not shake), at the source resolution, sent to the model at the generation size; **BFS Shot Join** pastes the
|
||||
result back, feathered by the mask (or the whole box). **BFS Shot Unpack** also outputs the shot's mask (`mask`),
|
||||
e.g. for an inpainting model. In **BFS Shot Join**'s comparison video, cropped shots show their mask (red) and crop
|
||||
box (yellow) over the original column (`comparison_mask`, on by default).
|
||||
|
||||
**Mask opacity** (mask modes, per shot, slider under the modes): the value of the white inside the generation mask.
|
||||
1 regenerates the masked area completely. A grey mask (e.g. 0.85) keeps part of the original there: H3 puts those
|
||||
rows at *opacity* × the noise level, so they start from the original partly visible. 0.8-0.9 keeps pose, outline and lighting while still swapping; too low copies the original
|
||||
person. With `guide_mode = aligned guide` the model also sees the whole original shot as its guide, so it follows
|
||||
the motion either way. (Not applied with the duet panel.)
|
||||
|
||||
**🎨 Color Guide** is a separate optional visual cue. Turn it on for a shot with a mask and choose the marker color
|
||||
for ref 1. The default colors for the global references are set below each image in **Prompts & refs**; a shot may
|
||||
override them. The selected region is tinted in the source guide, while the image detail,
|
||||
movement and expression remain visible underneath. This does not turn on `Mask only` or create an H3 generation
|
||||
mask. The prompt must explain that the tint is temporary and that the matching reference replaces the tinted person.
|
||||
|
||||
For consistency across shots, **People & masks → Find people** groups sampled faces by identity (ArcFace through
|
||||
InsightFace when installed; otherwise YuNet + SFace, or a weaker appearance fallback). Choose the matching `Person N`
|
||||
in each shot to inherit that cast member's Color Guide color. This is a user-confirmed link: profile views, small faces
|
||||
and occlusion can make identity grouping uncertain. Leave the shot on `manual identity` when unsure. The shot's mask
|
||||
still selects the actual region to tint, and its color can be overridden for that shot.
|
||||
|
||||
For two people in one shot, open **+ Add a second person**, use **ref 2** for that selection, and choose a different
|
||||
color (purple by default; duplicate colors are disabled). Enable *use_ref_2* on BFS Shot H3 Conditioning. The
|
||||
second SAM 3 text selection is optional; preview it before queuing. Color opacity and ArcFace identity links are
|
||||
under the optional settings. Use this prompt and replace the color names if you choose another palette:
|
||||
|
||||
```text
|
||||
body_swap: subject_definitions: <Subject 1> is the character shown in <Picture 1>, whose identity, face, head, hair or fur, skin, body shape, proportions and clothing define the green-marked replacement. <Subject 2> is the character shown in <Picture 2>, whose identity, face, head, hair or fur, skin, body shape, proportions and clothing define the purple-marked replacement. In the guide video, the translucent green and purple tints are temporary editing markers, not character appearance. Replace the green-tinted person only with <Subject 1> and the purple-tinted person only with <Subject 2>. The guide video provides the scene and the complete performance of both people, including their body poses, gestures, head motion, gaze, speech and facial expressions.
|
||||
|
||||
summary: [video generation + references] Replace the green-tinted guide person with <Subject 1> from <Picture 1> and the purple-tinted guide person with <Subject 2> from <Picture 2>, preserving the scene, camera and complete motion and expression of both people.
|
||||
|
||||
detailed_description: [Shot 1] Replace the person marked by the translucent green tint with <Subject 1> from <Picture 1>, and the person marked by the translucent purple tint with <Subject 2> from <Picture 2>. Each character follows that person's exact position, timing, body pose, gestures, head motion, gaze, speech and facial expressions in the guide. Remove both temporary color markers completely. Keep the two reference identities distinct and stable in every frame. Preserve their interaction, the background, lighting, camera and all unselected content from the guide video. Prevent identity drift, clothing changes, texture crawling and flicker. The result is a real, photographic video.
|
||||
```
|
||||
|
||||
The VLM's shot analysis suggests a SAM 3 text selection from the original frames; it does not assign guide colors or
|
||||
decide that two detections are the same identity. **Describe target** only describes the first shot selection. For
|
||||
two-color swaps, choose both selections and identities yourself, and use the explicit mapping above. The VLM prompt
|
||||
writer for *duet* is a separate split-screen task; its generic character-swap draft assumes the supplied pictures
|
||||
show one character, so use *planner prompt* for this two-character template. `Describe refs` also treats ref and ref 2
|
||||
as views of one identity; write the two subject definitions explicitly when ref 2 is a different person.
|
||||
|
||||
**Mask guide** (experimental, *Mask only* shots, BFS Shot H3 Conditioning → `mask_guide`): the shot with only the
|
||||
masked region visible, the rest grey, so the model looks at the subject's pose and outline on its own while the mask
|
||||
limits where it generates. *+ extra guide* adds it as a second aligned guide next to the normal one (the model sees
|
||||
both); *instead of the full guide* replaces the normal one; *+ reference video* gives it as a native reference video
|
||||
(`<Video n>`, after the shot's own one when guide_mode uses it; write `{mask_video}` in the prompt where its tag goes, otherwise a sentence is added). `mask_guide_look` sets what it shows of the subject: *grey blurred* (default: volume, light and head direction, no colours or face, so the model follows the motion without copying the old subject), *colour* (as it is; can make the model copy the old subject), *silhouette* (flat shape), *edges* (outlines) or *pose (people)* (the skeleton of the people in the mask, OpenPose colours; needs ultralytics and a YOLO pose model in `models/ultralytics`, e.g. `pose/yolov8m-pose.pt`). `mask_ref_size` makes that reference video smaller (default 1/2, ~1/4 of the tokens): it only has to show the pose and outline. Compare with
|
||||
*off*: the LoRAs were trained with one full guide, and it can also pull the old subject's look.
|
||||
|
||||
### Masks from a video (rotoscoping) instead of SAM 3
|
||||
|
||||
A mask made elsewhere (After Effects Roto Brush, DaVinci Resolve Magic Mask, another ComfyUI workflow…) can replace
|
||||
SAM 3. It is a black and white video, **white = the subject**, and it works with every mode (Mask only, Crop,
|
||||
Crop + mask), `{target}`, the setting picture and the comparison overlay. Three places, the most specific wins:
|
||||
|
||||
| where | covers | wins over |
|
||||
|---|---|---|
|
||||
| shot editor → **🎞 mask video** | only that shot (its first frame = the shot's first frame) | everything |
|
||||
| the shot's own SAM 3 text / points | that shot | the two below |
|
||||
| the planner's optional **`mask`** input (MASK or IMAGE batch, e.g. Load Video + Convert Image to Mask) | the whole video, one mask per source frame | the panel's mask video |
|
||||
| People & masks → **🎞 Mask video (whole video)** | the whole video, matched by time | SAM 3 for shots without a mask |
|
||||
|
||||
Any size and frame rate: it is matched to the shot by time and resized. The console says which mask each shot used.
|
||||
|
||||
**Stitch finishing** (BFS Shot Join, cropped shots only; adapted from Neko (Nekodificador)'s *NKD Inpaint Stitch*, MIT, built on AbleJones's workflow and nodes):
|
||||
|
||||
| Option | What it does |
|
||||
@@ -209,12 +288,19 @@ editor shows its suggestion with buttons to apply it. The summary output lists t
|
||||
|
||||
## Describe references ({details})
|
||||
|
||||
The VLM can describe the references, for any task (not tied to swaps). In the **Prompts & refs** tab pick an instruction
|
||||
preset: *full body* (face, hair, skin, age, build, clothing piece by piece), *head / face*, *face attributes* (a
|
||||
The VLM can describe the references, for any task (not tied to swaps), and any subject: a person, a creature, a robot,
|
||||
an animal or a cartoon character (it says what the subject is first). In the **Prompts & refs** tab pick an instruction
|
||||
preset: *short* (the default: one noun phrase of at most ~20 words that fits inside a sentence, e.g. `green goblin with
|
||||
long pointed ears in a torn brown leather vest`, for `<Subject 1> is the {details} shown in <Picture 1>`), *full body* (face, hair, skin, age, build, clothing piece by piece), *head / face*, *face attributes* (a
|
||||
short comma-separated list), *outfit*, or *custom* (your own instruction). **📝 Describe refs** writes one
|
||||
description per reference set: the global references, every shot's and every cast person's. They are saved in
|
||||
the plan and editable. Write **`{details}`** anywhere in a prompt and every shot gets the description of its own
|
||||
references; a set without a description is described by the VLM at run time.
|
||||
references; a set without a description is described by the VLM at run time (no click needed).
|
||||
|
||||
**Fit the prompt** (on by default, next to 📝 Describe refs): the VLM gets the sentence of the prompt that holds the
|
||||
marker, with a blank in its place (e.g. `<Subject 1> is the ___ shown in <Picture 1>`), and writes the description for
|
||||
it, so the description adapts to the prompt and not the other way round (no "the A goblin", no paragraph inside a
|
||||
sentence). The panel buttons use the global prompt (or the shot's own); at run time the prompt the shot really uses.
|
||||
|
||||
The VLM buttons in the panel work after the workflow has run once with the VLM connected (ComfyUI only hands
|
||||
models to nodes when they run). Tested with Qwen3-VL 8B; some smaller or modified models return empty answers
|
||||
@@ -227,7 +313,7 @@ Every setting in the panel has a hover tooltip (ⓘ). The main ones:
|
||||
|
||||
| setting | what it does |
|
||||
|---|---|
|
||||
| Mode | Camera cuts (shots start at cuts, long ones split evenly) or fixed length |
|
||||
| Mode | Camera cuts (shots start at cuts, long ones split evenly), fixed length, or one shot (the whole range) |
|
||||
| Detector / Sensitivity | PySceneDetect adaptive or content, or the built-in one; higher sensitivity finds more cuts |
|
||||
| Frame grid | frame counts the model accepts (H3 17n+5, LTX/Wan 8n+1, Wan 4n+1) |
|
||||
| Max / Min seconds per shot | longest shot sent to the model; shorter shots merge into a neighbour |
|
||||
@@ -240,6 +326,59 @@ 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 |
|
||||
|
||||
## One seed per shot: BFS Shot Seed (1.94.0)
|
||||
|
||||
A seed widget on the sampler gives every shot the SAME seed (the node runs once per shot with the same value).
|
||||
**BFS Shot Seed** (shot in, seed out) makes it a choice: *same for every shot*, *increment per shot* (seed, seed+1 ...)
|
||||
or *random per shot* (unrelated but repeatable seeds from the base seed). `overrides` pins single shots, e.g.
|
||||
`3:12345, 5:777`, to keep the shots that came out well and re-roll only the others.
|
||||
|
||||
## Structured starting noise: BFS Shot Noise (1.94.0, experimental)
|
||||
|
||||
Optional. **BFS Shot Noise** gives the NOISE for SamplerCustomAdvanced; with every amount at 0 it is exactly RandomNoise.
|
||||
|
||||
| Input | What it does |
|
||||
|---|---|
|
||||
| `shot_seed` (+ `shot`, `overrides`) | the seed of each shot: same / increment / random per shot, as BFS Shot Seed |
|
||||
| `shared_base` | the coarse pattern of the noise comes from `base_seed`: the same for every shot, at any resolution and any length (it is a tape along the timeline); the fine detail stays per shot. Put the seed of a result that worked in `base_seed` to keep its coarse noise |
|
||||
| `distribution` | gaussian (default), laplacian, student-t or uniform, all at unit variance |
|
||||
| `spectrum_tilt` | 1/f^alpha spectrum: > 0 puts more energy in the large blobs ("fractal" noise), < 0 in the fine grain |
|
||||
| `pyramid` | coarser layers of noise added on top |
|
||||
| `static_share` | a part of the noise identical in every frame |
|
||||
| `guide_structure` (`vae`, `guide_frames`, `mask`) | outside the mask, the guide's latent is mixed into the noise |
|
||||
| `ref_tint` (`vae`, `ref_image`, `mask`) | inside the mask, the noise leans toward the reference's average latent |
|
||||
|
||||
All mixes keep unit variance. The model was trained on plain noise: use small amounts and compare with 0.
|
||||
|
||||
## {details} filled automatically (1.92.0)
|
||||
|
||||
A `{details}` in a shot's prompt is filled without pressing 📝 Describe refs: the planner's VLM describes the shot's
|
||||
references before any SAM 3 work; with no VLM on the planner, the slot is left for **BFS Shot H3 Conditioning** or
|
||||
**BFS Shot H3 Duet**, whose `vlm` input (the same Qwen3-VL CLIPLoader) describes them (the *short* preset, fitted to
|
||||
the sentence). Edited descriptions always win. With no VLM anywhere it becomes "character" and the log says so.
|
||||
|
||||
## 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
|
||||
to upload in most nodes. The planner's ⬆ Upload (source video, mask videos, pictures) sends files of 32 MB or more in
|
||||
8 MB pieces that are written straight to disk and assembled in the input folder, so any size works without changing
|
||||
how ComfyUI is started (a dropped piece is sent again). For other nodes, start ComfyUI with a higher limit, e.g.
|
||||
`--max-upload-size 4096`, or copy the file into `ComfyUI/input` and pick it from the list.
|
||||
|
||||
A newly selected or uploaded source starts as **one shot covering at most its first 15 seconds**. This is only the
|
||||
initial plan: widen the range or choose *Camera cuts* / *Fixed length* and press *Auto split* when you want multiple
|
||||
shots. Existing saved plans keep their own range and split mode.
|
||||
|
||||
## Continuity between shots
|
||||
|
||||
Each shot (after the first) can continue from the **previous shot's generated result**: in the shot editor,
|
||||
@@ -253,6 +392,29 @@ the **auto loop with BFS Shot H3 Duet** (it renders shot by shot and keeps the l
|
||||
outputs it as `previous_result`. This is different from H3 Conditioning's *first_frame*, which anchors the shot's
|
||||
own frame from the source video.
|
||||
|
||||
### History: a long take, window by window (1.87.0)
|
||||
|
||||
*Continuity* = **previous shot's end as history** carries the **last 17, 34 or 51 frames of the previous shot's
|
||||
result** into the next shot as clean frames placed **just before frame 0 on H3's timeline**: the model sees them as
|
||||
the immediate past of the video it is generating, so motion, camera, light and the new look continue across the join
|
||||
(instead of one still frame). Idea from Akatz's [H3 Relay](https://github.com/akatz-ai/h3-relay) windowed edit and
|
||||
Ethanfel's MiniMax H3 Context Loop; this is BFS's own implementation (a process-local patch of H3's packed layout
|
||||
that moves the video, its audio and the other guides past the history; ComfyUI files are not edited).
|
||||
|
||||
- **On by default** (1.88.0) for every shot after the first; set *Continuity = off* on a shot to drop it. Plans saved
|
||||
before keep what they had.
|
||||
- **Same take only.** A shot that starts at a camera cut skips it (nothing to continue). For one long take, split it
|
||||
with *Mode = fixed length* (or let *Camera cuts* split long shots) and set history on every shot after the first
|
||||
with **Copy to other shots → continuity**. Shots still exist: they are the windows, and cuts reset the history.
|
||||
- **Every mode:** full frame, *Frame + paste*, *Mask only* (the mask regenerates the target; the history is a
|
||||
condition outside it), *Crop* (the history is cut with this shot's box) and the **duet canvas** (the source frames
|
||||
under the history go into the panel strip). Not with duet *shifted RoPE*. The aligned guide still drives this shot's
|
||||
own frames, so sync stays frame-exact.
|
||||
- **BFS Shot H3 Conditioning and BFS Shot H3 Duet** both use it (duet canvas or aligned guide).
|
||||
- **Queue loop:** shots run in order and each reads the stored result of the one before. Auto loop: taken from the
|
||||
previous render when it matches.
|
||||
- Video only for now (the previous audio is not carried). Costs one more 17/34/51-frame condition per shot.
|
||||
|
||||
## MiniMax H3 with an aligned guide (example)
|
||||
|
||||
```
|
||||
@@ -316,6 +478,12 @@ Then save:
|
||||
|
||||
**Save → all shots** is for a person who stays in the same place across shots (a sequential video, a fixed camera).
|
||||
|
||||
**🎯 Track → whole video** (in the points window) is for a person who moves around: the selection is tracked by SAM 3
|
||||
through the whole video (or the range), forward and backward from the frame you clicked on, and every shot without
|
||||
its own selection takes its part of that track. Also type a short text (e.g. `woman in pink top`): after a camera cut
|
||||
the tracker uses it to find the subject again. The People & masks tab shows the track and removes it. The first run
|
||||
tracks the whole range (a minute or two per few hundred frames); the result is cached until the selection changes.
|
||||
|
||||
### 3. Describe the person
|
||||
|
||||
Click **🧑 Describe target** in the shot's editor:
|
||||
@@ -335,7 +503,9 @@ Click **🧑 Describe target** in the shot's editor:
|
||||
|
||||
### What happens when the workflow runs
|
||||
|
||||
Every shot's prompt gets its own `{target}` text. A shot without a description gets "the person". The selection is
|
||||
Every shot's prompt gets its own `{target}` text: the one you wrote or made with 🧑 Describe target; when the field is
|
||||
empty but the shot has a selection (mask text, points or a mask video) and the VLM is connected, the subject is cut out
|
||||
and described automatically (written for the prompt's `{target}` sentence). A shot with neither gets "the person". The selection is
|
||||
only used to make the description: it does not crop or mask anything unless the shot's mode is *Mask only*, *Crop*
|
||||
or *Crop + mask*.
|
||||
|
||||
@@ -358,4 +528,3 @@ or *Crop + mask*.
|
||||
| Only the face or the hair is described | the click was on the head: click the body |
|
||||
| The other person is described | add an exclude point (right-click) on the other person, **👁 Segment**, save, describe again |
|
||||
| The swap still hits the wrong person | make the description more specific (position: "on the left", clothing colours) |
|
||||
|
||||
|
||||
+230
-7
@@ -1,11 +1,225 @@
|
||||
// Shot Planner: constants and small helpers shared by the panel and its views (no side effects: ComfyUI loads every
|
||||
// .js under web/js as an extension, so this module only exports).
|
||||
import { h } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
import { h as vueH, reactive, ref } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
|
||||
// UI language is a browser preference, not part of the workflow or generation prompts.
|
||||
const LANG_KEY = "bfs.shotloop.language";
|
||||
const savedLanguage = (() => { try { return localStorage.getItem(LANG_KEY) || "en"; } catch { return "en"; } })();
|
||||
export const uiLanguage = ref(["en", "pt", "es", "zh"].includes(savedLanguage) ? savedLanguage : "en");
|
||||
export function setUiLanguage(value) {
|
||||
if (!["en", "pt", "es", "zh"].includes(value)) return;
|
||||
uiLanguage.value = value;
|
||||
try { localStorage.setItem(LANG_KEY, value); } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
"🎞 Video": ["🎞 Vídeo", "🎞 Vídeo", "🎞 视频"], "🎬 Shots": ["🎬 Shots", "🎬 Planos", "🎬 镜头"],
|
||||
"👥 People & masks": ["👥 Pessoas e máscaras", "👥 Personas y máscaras", "👥 人物和蒙版"],
|
||||
"📝 Prompts & refs": ["📝 Prompts e referências", "📝 Prompts y referencias", "📝 提示词和参考图"],
|
||||
"▶ Run": ["▶ Executar", "▶ Ejecutar", "▶ 运行"], "📖 Guide": ["📖 Guia", "📖 Guía", "📖 指南"],
|
||||
"🧭 Tour": ["🧭 Tour", "🧭 Recorrido", "🧭 引导"], "Language": ["Idioma", "Idioma", "语言"],
|
||||
"Source video": ["Vídeo de origem", "Vídeo de origen", "源视频"], "Range": ["Trecho", "Rango", "范围"],
|
||||
"Split into shots": ["Dividir em planos", "Dividir en planos", "拆分镜头"], "Generation size": ["Tamanho da geração", "Tamaño de generación", "生成尺寸"],
|
||||
"Camera cuts": ["Cortes de câmera", "Cortes de cámara", "镜头切换"], "Fixed length": ["Duração fixa", "Duración fija", "固定时长"],
|
||||
"One shot (whole range)": ["Um plano (trecho inteiro)", "Un plano (rango completo)", "单个镜头(整个范围)"],
|
||||
"Mode": ["Modo", "Modo", "模式"], "Detector": ["Detector", "Detector", "检测器"], "Sensitivity": ["Sensibilidade", "Sensibilidad", "灵敏度"],
|
||||
"Frame grid": ["Grade de frames", "Múltiplo de fotogramas", "帧步长"], "Max seconds / shot": ["Máx. segundos por plano", "Máx. segundos por plano", "每个镜头最长秒数"],
|
||||
"Min seconds / shot": ["Mín. segundos por plano", "Mín. segundos por plano", "每个镜头最短秒数"], "Timeline fps": ["FPS da linha do tempo", "FPS de la línea de tiempo", "时间轴帧率"],
|
||||
"Megapixels": ["Megapixels", "Megapíxeles", "百万像素"], "Size multiple": ["Múltiplo do tamanho", "Múltiplo del tamaño", "尺寸倍数"],
|
||||
"✂ Auto split": ["✂ Dividir automaticamente", "✂ División automática", "✂ 自动拆分"], "↻ Re-analyse": ["↻ Analisar novamente", "↻ Volver a analizar", "↻ 重新分析"],
|
||||
"Reference & prompt": ["Referência e prompt", "Referencia y prompt", "参考图和提示词"], "Who is replaced": ["Quem será substituído", "Quién se reemplaza", "替换对象"],
|
||||
"Continuity": ["Continuidade", "Continuidad", "连续性"], "Copy to other shots": ["Copiar para outros planos", "Copiar a otros planos", "复制到其他镜头"],
|
||||
"Global reference & prompt": ["Referência e prompt globais", "Referencia y prompt globales", "全局参考图和提示词"],
|
||||
"Describe references ({details})": ["Descrever referências ({details})", "Describir referencias ({details})", "描述参考图({details})"],
|
||||
"VLM": ["VLM", "VLM", "视觉语言模型"], "Use the VLM when the workflow runs": ["Usar o VLM ao executar o workflow", "Usar el VLM al ejecutar el flujo", "运行工作流时使用 VLM"],
|
||||
"Fill the mask text": ["Preencher o texto da máscara", "Completar el texto de la máscara", "填写蒙版描述"],
|
||||
"Fill {shot} in the prompt": ["Preencher {shot} no prompt", "Completar {shot} en el prompt", "在提示词中填入 {shot}"],
|
||||
"Frames per shot": ["Frames por plano", "Fotogramas por plano", "每个镜头的帧数"], "Max tokens": ["Máx. tokens", "Máx. tokens", "最大 token 数"],
|
||||
"🤖 Analyse shots": ["🤖 Analisar planos", "🤖 Analizar planos", "🤖 分析镜头"], "📝 Describe refs": ["📝 Descrever referências", "📝 Describir referencias", "📝 描述参考图"],
|
||||
"🎨 Color Guide": ["🎨 Guia de cores", "🎨 Guía de color", "🎨 颜色引导"], "Ref 1 color": ["Cor da ref. 1", "Color de ref. 1", "参考图 1 的颜色"],
|
||||
"Ref 2": ["Ref. 2", "Ref. 2", "参考图 2"], "+ Add a second person (optional)": ["+ Adicionar segunda pessoa (opcional)", "+ Añadir segunda persona (opcional)", "+ 添加第二个人(可选)"],
|
||||
"Second person · Ref 2": ["Segunda pessoa · Ref. 2", "Segunda persona · Ref. 2", "第二个人 · 参考图 2"],
|
||||
"Describe the second person for SAM 3": ["Descreva a segunda pessoa para o SAM 3", "Describe la segunda persona para SAM 3", "为 SAM 3 描述第二个人"],
|
||||
"👁 Preview": ["👁 Prévia", "👁 Vista previa", "👁 预览"], "More options": ["Mais opções", "Más opciones", "更多选项"],
|
||||
"Keep ref 2 color consistent across shots (optional)": ["Manter a cor da ref. 2 nos outros planos (opcional)", "Mantener el color de ref. 2 en otros planos (opcional)", "在其他镜头中保持参考图 2 的颜色(可选)"],
|
||||
"Ref 1 identity": ["Identidade da ref. 1", "Identidad de ref. 1", "参考图 1 的身份"], "Ref 2 identity": ["Identidade da ref. 2", "Identidad de ref. 2", "参考图 2 的身份"],
|
||||
"Choose manually": ["Escolher manualmente", "Elegir manualmente", "手动选择"], "↩ cast color": ["↩ cor do elenco", "↩ color del reparto", "↩ 使用角色颜色"],
|
||||
"Opacity": ["Opacidade", "Opacidad", "不透明度"], "The default keeps movement and expression visible.": ["O padrão mantém o movimento e a expressão visíveis.", "El valor predeterminado mantiene visibles el movimiento y la expresión.", "默认值会保留动作和表情。"],
|
||||
"Full frame": ["Quadro inteiro", "Fotograma completo", "整帧"], "🧩 Frame + paste": ["🧩 Quadro + colar", "🧩 Fotograma + pegar", "🧩 整帧生成并贴回"],
|
||||
"🎭 Mask only": ["🎭 Somente máscara", "🎭 Solo máscara", "🎭 仅蒙版"], "✂ Crop": ["✂ Recorte", "✂ Recorte", "✂ 裁剪"], "✂🎭 Crop + mask": ["✂🎭 Recorte + máscara", "✂🎭 Recorte + máscara", "✂🎭 裁剪并蒙版"],
|
||||
"People & masks": ["Pessoas e máscaras", "Personas y máscaras", "人物和蒙版"], "Cast": ["Elenco", "Reparto", "角色"],
|
||||
"Mask & crop settings": ["Configurações de máscara e recorte", "Ajustes de máscara y recorte", "蒙版和裁剪设置"],
|
||||
"Color Guide identity color": ["Cor de identidade do Guia de cores", "Color de identidad de la Guía de color", "颜色引导身份颜色"],
|
||||
"No Color Guide color": ["Sem cor", "Sin color", "无颜色引导颜色"], "Green": ["Verde", "Verde", "绿色"], "Purple": ["Roxo", "Morado", "紫色"],
|
||||
"Red": ["Vermelho", "Rojo", "红色"], "Blue": ["Azul", "Azul", "蓝色"], "Yellow": ["Amarelo", "Amarillo", "黄色"], "Magenta": ["Magenta", "Magenta", "品红"], "Cyan": ["Ciano", "Cian", "青色"],
|
||||
"Checks": ["Verificações", "Comprobaciones", "检查"], "Run": ["Executar", "Ejecutar", "运行"], "Filters": ["Filtros", "Filtros", "筛选"],
|
||||
"Auto loop (one run)": ["Loop automático (uma execução)", "Bucle automático (una ejecución)", "自动循环(一次运行)"],
|
||||
"Queue loop (one shot per run)": ["Fila (um plano por execução)", "Cola (un plano por ejecución)", "队列循环(每次运行一个镜头)"],
|
||||
"Skipped shots in the output": ["Planos ignorados na saída", "Planos omitidos en la salida", "输出中跳过的镜头"], "Keep original video": ["Manter vídeo original", "Mantener vídeo original", "保留原视频"],
|
||||
"Remove them": ["Removê-los", "Quitarlos", "移除"], "Max shots (0 = all)": ["Máx. planos (0 = todos)", "Máx. planos (0 = todos)", "镜头上限(0 = 全部)"],
|
||||
"Max total seconds (0 = all)": ["Máx. duração total (0 = tudo)", "Duración total máx. (0 = todo)", "总时长上限(0 = 全部)"],
|
||||
"Auto-queue the next shot": ["Enfileirar próximo plano automaticamente", "Añadir automáticamente el siguiente plano a la cola", "自动将下一个镜头加入队列"],
|
||||
"Needs a person": ["Precisa ter uma pessoa", "Debe haber una persona", "需要有人物"], "Needs a face": ["Precisa ter um rosto", "Debe haber un rostro", "需要有人脸"],
|
||||
"Skip dark / fades": ["Ignorar cenas escuras / fades", "Omitir escenas oscuras / fundidos", "跳过暗场/淡入淡出"], "Skip static shots": ["Ignorar planos estáticos", "Omitir planos estáticos", "跳过静止镜头"],
|
||||
"👤 Analyse people & faces": ["👤 Analisar pessoas e rostos", "👤 Analizar personas y rostros", "👤 分析人物和人脸"],
|
||||
"Video": ["Vídeo", "Vídeo", "视频"], "Shots": ["Planos", "Planos", "镜头"], "People": ["Pessoas", "Personas", "人物"], "Prompts": ["Prompts", "Prompts", "提示词"],
|
||||
"Back": ["Voltar", "Atrás", "上一步"], "Next": ["Avançar", "Siguiente", "下一步"], "Close tour": ["Fechar tour", "Cerrar recorrido", "关闭引导"],
|
||||
"Shot Planner tour": ["Tour do Shot Planner", "Recorrido de Shot Planner", "Shot Planner 引导"], "Shot": ["Plano", "Plano", "镜头"],
|
||||
"This tour is optional. Use Next to visit each main tab; close it whenever you like.": ["Este tour é opcional. Use Avançar para conhecer cada aba; feche quando quiser.", "Este recorrido es opcional. Pulsa Siguiente para visitar cada pestaña; puedes cerrarlo cuando quieras.", "此引导为可选。点击下一步浏览各个主选项卡,也可随时关闭。"],
|
||||
"Choose a video, set the frame range, split at camera cuts, then set the generation size.": ["Escolha um vídeo e o trecho, divida nos cortes de câmera e defina o tamanho da geração.", "Elige un vídeo y un rango, divídelo en cortes de cámara y define el tamaño de generación.", "选择视频和范围,按镜头切换拆分,然后设置生成尺寸。"],
|
||||
"Review each shot, assign its references and prompt, and choose how the result is applied.": ["Revise cada plano, associe referências e prompt e escolha como aplicar o resultado.", "Revisa cada plano, asigna referencias y prompt, y elige cómo aplicar el resultado.", "检查每个镜头,设置参考图和提示词,并选择结果的应用方式。"],
|
||||
"Link detected people to references. Set masks here or per shot; ArcFace links are a guide and need your review.": ["Associe as pessoas detectadas às referências. Configure máscaras aqui ou por plano; revise os vínculos ArcFace.", "Vincula las personas detectadas con referencias. Configura máscaras aquí o por plano; revisa los enlaces ArcFace.", "将检测到的人物关联到参考图。可在此处或逐镜头设置蒙版;请检查 ArcFace 关联。"],
|
||||
"Set shared references, prompts and optional VLM assistance. Written prompts are kept exactly as authored.": ["Defina referências, prompts e assistência opcional do VLM. Os prompts escritos são preservados como estão.", "Configura referencias, prompts y asistencia opcional del VLM. Los prompts escritos se conservan tal cual.", "设置共享参考图、提示词和可选的 VLM 辅助。提示词会保持原样。"],
|
||||
"Check warnings, choose auto or queue loop, and set filters or quick-run limits.": ["Confira avisos, escolha loop automático ou fila e configure filtros e limites de teste.", "Revisa avisos, elige bucle automático o cola y configura filtros y límites de prueba.", "查看警告,选择自动循环或队列,并设置筛选条件和快速运行限制。"],
|
||||
"Color Guide adds a translucent marker to a selected person. Ref 1 and Ref 2 must use different colors in the same shot.": ["O Guia de cores aplica uma cor translúcida à pessoa selecionada. Ref. 1 e Ref. 2 precisam de cores diferentes no mesmo plano.", "La Guía de color aplica una marca translúcida a la persona seleccionada. Ref. 1 y Ref. 2 deben tener colores distintos en el mismo plano.", "颜色引导会给选中的人物添加半透明颜色。同一镜头中的参考图 1 和参考图 2 必须使用不同颜色。"],
|
||||
"🎬 Shot Planner": ["🎬 Planejador de planos", "🎬 Planificador de planos", "🎬 镜头规划器"],
|
||||
"— choose a video from the input folder —": ["— escolha um vídeo da pasta de entrada —", "— elige un vídeo de la carpeta de entrada —", "— 从输入文件夹选择视频 —"],
|
||||
"⬆ Upload": ["⬆ Enviar", "⬆ Subir", "⬆ 上传"], "Pick or upload a video to start": ["Escolha ou envie um vídeo para começar", "Elige o sube un vídeo para empezar", "选择或上传视频以开始"],
|
||||
"It is split into shots the model can generate; every shot then gets its own reference, prompt and mask.": ["O vídeo será dividido em planos que o modelo consegue gerar; cada plano terá sua própria referência, prompt e máscara.", "El vídeo se divide en planos que el modelo puede generar; cada uno tendrá su referencia, prompt y máscara.", "视频会拆分为模型可生成的镜头,每个镜头都有自己的参考图、提示词和蒙版。"],
|
||||
"work on a part of the video": ["trabalhe em um trecho do vídeo", "trabaja en una parte del vídeo", "处理视频的一部分"],
|
||||
"Start frame": ["Frame inicial", "Fotograma inicial", "起始帧"], "End frame (0 = end)": ["Frame final (0 = fim)", "Fotograma final (0 = final)", "结束帧(0 = 末尾)"],
|
||||
"⇤ start here": ["⇤ começar aqui", "⇤ empezar aquí", "⇤ 从此处开始"], "⇥ end here": ["⇥ terminar aqui", "⇥ terminar aquí", "⇥ 在此处结束"], "✕ whole video": ["✕ vídeo inteiro", "✕ vídeo completo", "✕ 整个视频"],
|
||||
"Camera cuts or fixed length, on the model's frame grid": ["Cortes de câmera ou duração fixa, na grade de frames do modelo", "Cortes de cámara o duración fija, según la cuadrícula de fotogramas del modelo", "按镜头切换或固定时长,并符合模型帧步长"],
|
||||
"re-analyses": ["reanalisar", "vuelve a analizar", "重新分析"], "ignores Max seconds / shot": ["ignora Máx. segundos por plano", "ignora Máx. segundos por plano", "忽略每个镜头的最长秒数"],
|
||||
"cropped shots use it for the crop": ["planos recortados usam esse valor no recorte", "los planos recortados usan este valor", "裁剪镜头会使用此尺寸"],
|
||||
"Shots use their main person's reference": ["Usar a referência da pessoa principal do plano", "Usar la referencia de la persona principal del plano", "镜头使用主要人物的参考图"],
|
||||
"Split where the main person changes": ["Dividir quando a pessoa principal mudar", "Dividir cuando cambie la persona principal", "主要人物变化时拆分"],
|
||||
"Only run shots with a linked person": ["Executar apenas planos com pessoa vinculada", "Ejecutar solo planos con una persona vinculada", "仅运行已关联人物的镜头"],
|
||||
"a shot without its own reference takes the reference of the person with the most screen time in it": ["um plano sem referência própria usa a referência da pessoa que aparece por mais tempo nele", "un plano sin referencia propia usa la de la persona que aparece más tiempo", "没有单独参考图的镜头会使用出镜时间最长人物的参考图"],
|
||||
"adds a boundary when the biggest face switches to someone else (re-splits the plan)": ["cria uma divisão quando o maior rosto muda para outra pessoa (refaz a divisão)", "añade un corte cuando el rostro más grande cambia de persona (vuelve a dividir)", "最大人脸切换到其他人时添加分界(重新拆分计划)"],
|
||||
"shots where no linked person appears are skipped": ["planos sem pessoa vinculada são ignorados", "se omiten los planos sin una persona vinculada", "跳过没有关联人物的镜头"],
|
||||
"Nobody found yet.": ["Nenhuma pessoa encontrada.", "Aún no se encontraron personas.", "尚未检测到人物。"], "↻ Re-analyse people": ["↻ Analisar pessoas novamente", "↻ Volver a analizar personas", "↻ 重新分析人物"], "👥 Find people": ["👥 Encontrar pessoas", "👥 Buscar personas", "👥 查找人物"],
|
||||
"faces sampled every 0.5 s and grouped by identity (InsightFace, CPU)": ["rostos amostrados a cada 0,5 s e agrupados por identidade (InsightFace, CPU)", "rostros muestreados cada 0,5 s y agrupados por identidad (InsightFace, CPU)", "每 0.5 秒采样人脸并按身份分组(InsightFace,CPU)"],
|
||||
"detects faces across the video and groups them into people": ["detecta rostos no vídeo e os agrupa por pessoa", "detecta rostros en el vídeo y los agrupa por persona", "检测视频中的人脸并按人物分组"],
|
||||
"track faces, link each person to a reference, split and assign shots by who is on screen": ["rastreie rostos, associe cada pessoa a uma referência e divida os planos por quem aparece", "rastrea rostros, vincula cada persona a una referencia y divide los planos según quién aparece", "跟踪人脸、关联参考图,并按出镜人物拆分镜头"],
|
||||
"Each shot picks what it marks (🎯 text or points, or 🎞 its own mask video) and how it is generated (": ["Cada plano define a seleção (🎯 texto, pontos ou 🎞 vídeo de máscara) e o modo de geração (", "Cada plano define la selección (🎯 texto, puntos o 🎞 vídeo de máscara) y el modo de generación (", "每个镜头设置选区(🎯 文本、点或 🎞 蒙版视频)和生成方式("],
|
||||
"🎞 Mask video (whole video)": ["🎞 Vídeo de máscara (vídeo inteiro)", "🎞 Vídeo de máscara (vídeo completo)", "🎞 蒙版视频(整个视频)"],
|
||||
"— none: SAM 3 per shot —": ["— nenhum: SAM 3 por plano —", "— ninguno: SAM 3 por plano —", "— 无:每个镜头使用 SAM 3 —"], "Fill holes": ["Preencher buracos", "Rellenar huecos", "填充孔洞"], "Invert": ["Inverter", "Invertir", "反转"],
|
||||
"Show masks on shots": ["Mostrar máscaras nos planos", "Mostrar máscaras en los planos", "在镜头卡片上显示蒙版"], "Expand (px)": ["Expandir (px)", "Expandir (px)", "扩展(像素)"],
|
||||
"Temporal expand (frames)": ["Expansão temporal (frames)", "Expansión temporal (fotogramas)", "时间扩展(帧)"], "Detection threshold": ["Limiar de detecção", "Umbral de detección", "检测阈值"], "Max objects": ["Máx. objetos", "Máx. objetos", "最大对象数"],
|
||||
"Crop padding (%)": ["Margem do recorte (%)", "Margen del recorte (%)", "裁剪边距(%)"], "Feather (px)": ["Suavização da borda (px)", "Suavizado del borde (px)", "羽化(像素)"], "Paste back": ["Colar de volta", "Pegar de vuelta", "贴回方式"],
|
||||
"only the mask (feathered)": ["somente a máscara (suavizada)", "solo la máscara (suavizada)", "仅蒙版(羽化边缘)"], "the whole box (feathered)": ["a caixa inteira (suavizada)", "todo el cuadro (suavizado)", "整个框(羽化边缘)"], "Blockify (px, 0 = off)": ["Blocos (px, 0 = desligado)", "Bloques (px, 0 = desactivado)", "块化(像素,0 = 关闭)"],
|
||||
"Uses the official sam3.1_multiplex_fp16 checkpoint (models/checkpoints), downloaded from Comfy-Org/sam3.1 the first time.": ["Usa o checkpoint oficial sam3.1_multiplex_fp16 (models/checkpoints), baixado de Comfy-Org/sam3.1 na primeira utilização.", "Usa el checkpoint oficial sam3.1_multiplex_fp16 (models/checkpoints), descargado de Comfy-Org/sam3.1 la primera vez.", "使用官方 sam3.1_multiplex_fp16 检查点(models/checkpoints),首次运行时从 Comfy-Org/sam3.1 下载。"],
|
||||
"SAM 3": ["SAM 3", "SAM 3", "SAM 3"], "track faces, link each person to a reference, split and assign shots by who is on screen": ["rastreie rostos, associe cada pessoa a uma referência e organize os planos por quem aparece", "rastrea rostros, vincula cada persona a una referencia y organiza los planos según quién aparece", "跟踪人脸、关联参考图,并按出镜人物分配镜头"],
|
||||
"Global reference & prompt": ["Referência e prompt globais", "Referencia y prompt globales", "全局参考图和提示词"], "defaults for every shot": ["padrão para todos os planos", "valor predeterminado para cada plano", "所有镜头的默认设置"],
|
||||
"who the shot becomes": ["em quem o plano será transformado", "en quién se transforma el plano", "镜头中的人物将替换为谁"], "Prompt for this shot (empty = global prompt). Placeholders: {target} {details} {shot} {setting}": ["Prompt deste plano (vazio = prompt global). Variáveis: {target} {details} {shot} {setting}", "Prompt de este plano (vacío = prompt global). Variables: {target} {details} {shot} {setting}", "此镜头的提示词(留空则使用全局提示词)。占位符:{target} {details} {shot} {setting}"],
|
||||
"recent (click = ref, shift+click = ref 2):": ["recentes (clique = ref., Shift+clique = ref. 2):", "recientes (clic = ref., Mayús+clic = ref. 2):", "最近使用(点击 = 参考图,Shift+点击 = 参考图 2):"], "✕ clear": ["✕ limpar", "✕ borrar", "✕ 清除"], "✕ clear all": ["✕ limpar tudo", "✕ borrar todo", "✕ 全部清除"], "Use global": ["Usar global", "Usar global", "使用全局设置"],
|
||||
"empty slots use the person's reference (Cast) or the global one (Prompts & refs tab)": ["campos vazios usam a referência da pessoa (Elenco) ou a global (aba Prompts e referências)", "los campos vacíos usan la referencia de la persona (Reparto) o la global (Prompts y referencias)", "空白项使用人物参考图(角色)或全局参考图(提示词和参考图选项卡)"],
|
||||
"Full frame": ["Quadro inteiro", "Fotograma completo", "整帧"], "The whole frame is regenerated. The mask, if any, only feeds {target} / the setting picture.": ["O quadro inteiro é gerado novamente. A máscara, se houver, só fornece {target} / a imagem de cenário.", "Se vuelve a generar todo el fotograma. La máscara, si existe, solo aporta {target} / la imagen del escenario.", "重新生成整帧。蒙版(如有)仅用于提供 {target} / 场景参考图。"],
|
||||
"Only the mask is regenerated, on the whole frame: the rest stays exactly as it was. No crop / uncrop.": ["Somente a área da máscara é regenerada; o restante permanece igual. Sem recorte.", "Solo se regenera el área de la máscara; el resto permanece igual. Sin recorte.", "仅重新生成蒙版区域,其余部分保持不变。不进行裁剪。"],
|
||||
"A box around the mask is regenerated (more pixels for a small subject); BFS Shot Join pastes it back.": ["A caixa ao redor da máscara é regenerada (mais pixels para pessoas pequenas); o BFS Shot Join cola o resultado de volta.", "Se regenera el cuadro alrededor de la máscara (más píxeles para sujetos pequeños); BFS Shot Join lo pega de vuelta.", "重新生成蒙版周围的区域(为较小主体提供更多像素);BFS Shot Join 会将其贴回。"],
|
||||
"Inside the crop, only the mask is regenerated: the most detail with the background kept.": ["Dentro do recorte, somente a máscara é regenerada: mais detalhes e fundo preservado.", "Dentro del recorte, solo se regenera la máscara: más detalle y fondo conservado.", "仅重新生成裁剪区域内的蒙版:细节更多,同时保留背景。"],
|
||||
"The whole frame is generated (the pose follows the guide, the new subject may be bigger); BFS Shot Join pastes only the subject onto the original: background exact.": ["O quadro inteiro é gerado (a pose segue o guia e o novo sujeito pode ser maior); o BFS Shot Join cola apenas o sujeito no original e preserva o fundo.", "Se genera todo el fotograma (la pose sigue la guía y el sujeto puede ser más grande); BFS Shot Join pega solo al sujeto sobre el original y conserva el fondo.", "生成整帧(姿势遵循引导,主体可更大);BFS Shot Join 仅将主体贴回原画面,背景保持原样。"],
|
||||
"🎯 Points…": ["🎯 Pontos…", "🎯 Puntos…", "🎯 点…"], "👁 Preview": ["👁 Prévia", "👁 Vista previa", "👁 预览"], "✕ Clear": ["✕ Limpar", "✕ Borrar", "✕ 清除"],
|
||||
"🎞 mask video": ["🎞 vídeo de máscara", "🎞 vídeo de máscara", "🎞 蒙版视频"], "↳ global prompt": ["↳ prompt global", "↳ prompt global", "↳ 全局提示词"], "— no prompt —": ["— sem prompt —", "— sin prompt —", "— 无提示词 —"],
|
||||
"▶ Play all": ["▶ Reproduzir todos", "▶ Reproducir todos", "▶ 播放全部"], "■ Stop": ["■ Parar", "■ Detener", "■ 停止"], "loop shot": ["repetir plano", "repetir plano", "循环播放镜头"],
|
||||
"Click the timeline to seek. Play all skips shots that will not run.": ["Clique na linha do tempo para buscar. Reproduzir todos ignora planos que não serão executados.", "Haz clic en la línea de tiempo para buscar. Reproducir todos omite los planos que no se ejecutarán.", "点击时间轴进行定位。播放全部会跳过不运行的镜头。"],
|
||||
"Reference & prompt": ["Referência e prompt", "Referencia y prompt", "参考图和提示词"], "Who is replaced": ["Quem será substituído", "Quién se reemplaza", "替换对象"], "Continuity": ["Continuidade", "Continuidad", "连续性"],
|
||||
"Global reference & prompt": ["Referência e prompt globais", "Referencia y prompt globales", "全局参考图和提示词"], "Describe references ({details})": ["Descrever referências ({details})", "Describir referencias ({details})", "描述参考图({details})"],
|
||||
"Custom": ["Personalizado", "Personalizado", "自定义"], "short": ["curto", "breve", "简短"], "full body": ["corpo inteiro", "cuerpo entero", "全身"], "head / face": ["cabeça / rosto", "cabeza / rostro", "头部/面部"], "face attributes": ["características do rosto", "rasgos faciales", "面部特征"], "outfit": ["roupa", "ropa", "服装"],
|
||||
"Apply suggestions → all": ["Aplicar sugestões → todos", "Aplicar sugerencias → todos", "应用建议 → 全部"], "fit the prompt": ["ajustar ao prompt", "ajustar al prompt", "适配提示词"],
|
||||
"Connect a Qwen3-VL (CLIPLoader) to the planner's vlm input. The VLM buttons work after the workflow has run once with it connected (ComfyUI only hands models to nodes when they run). Each shot's suggestion shows in its editor.": ["Conecte um Qwen3-VL (CLIPLoader) à entrada vlm do planner. Os botões do VLM funcionam depois que o workflow executar uma vez com ele conectado. A sugestão de cada plano aparece no editor.", "Conecta un Qwen3-VL (CLIPLoader) a la entrada vlm del planificador. Los botones del VLM funcionan después de ejecutar el flujo una vez con él conectado. La sugerencia de cada plano aparece en su editor.", "将 Qwen3-VL(CLIPLoader)连接到规划器的 vlm 输入。工作流连接后运行一次,VLM 按钮即可使用。每个镜头的建议会显示在其编辑器中。"],
|
||||
"look at every shot and suggests the mask, a description and whether to run it": ["analisa cada plano e sugere máscara, descrição e se deve ser executado", "analiza cada plano y sugiere una máscara, una descripción y si debe ejecutarse", "分析每个镜头并建议蒙版、描述及是否运行"],
|
||||
"🎯 Track → whole video": ["🎯 Rastrear → vídeo inteiro", "🎯 Seguir → vídeo completo", "🎯 跟踪 → 整个视频"], "Save (this shot)": ["Salvar (este plano)", "Guardar (este plano)", "保存(此镜头)"], "Save → all shots": ["Salvar → todos os planos", "Guardar → todos los planos", "保存 → 所有镜头"], "Cancel": ["Cancelar", "Cancelar", "取消"], "Clear points": ["Limpar pontos", "Borrar puntos", "清除点"], "👁 Segment": ["👁 Segmentar", "👁 Segmentar", "👁 分割"],
|
||||
"Apply these points to EVERY shot, each on its frame at the same relative position (a subject that stays in place)": ["Aplicar estes pontos a TODOS os planos, na mesma posição relativa (para um sujeito que não muda de lugar)", "Aplicar estos puntos a TODOS los planos, en la misma posición relativa (si el sujeto no se mueve)", "将这些点应用到每个镜头的相同相对位置(适用于位置不变的主体)"],
|
||||
"the range starts at the player's frame": ["o trecho começa no frame atual do player", "el rango empieza en el fotograma actual", "范围从播放器当前帧开始"], "the range ends after the player's frame": ["o trecho termina após o frame atual do player", "el rango termina después del fotograma actual", "范围在播放器当前帧之后结束"],
|
||||
"Nothing to fix in the shots that run.": ["Nenhum problema nos planos que serão executados.", "No hay nada que corregir en los planos que se ejecutarán.", "将运行的镜头没有需要修复的问题。"], "all good": ["tudo certo", "todo bien", "一切正常"], "warning(s)": ["aviso(s)", "aviso(s)", "条警告"], "note(s)": ["observação(ões)", "nota(s)", "条备注"],
|
||||
"quick tests": ["testes rápidos", "pruebas rápidas", "快速测试"], "from the start of the range": ["a partir do início do trecho", "desde el inicio del rango", "从范围起点开始"], "Each run generates one shot and stores it. Nodes after BFS Shot Join only run on the last shot, with the full video.": ["Cada execução gera e salva um plano. Os nodes após BFS Shot Join só executam no último plano, com o vídeo completo.", "Cada ejecución genera y guarda un plano. Los nodos posteriores a BFS Shot Join solo se ejecutan en el último plano, con el vídeo completo.", "每次运行生成并保存一个镜头。BFS Shot Join 后的节点仅在最后一个镜头运行,并接收完整视频。"],
|
||||
"skipped shots do not run": ["planos ignorados não são executados", "los planos omitidos no se ejecutan", "跳过的镜头不会运行"], "only shots that run": ["somente planos executados", "solo los planos que se ejecutan", "仅统计将运行的镜头"],
|
||||
"off": ["desligado", "desactivado", "关闭"], "on": ["ligado", "activado", "开启"], "run": ["executar", "ejecutar", "运行"], "skip": ["ignorar", "omitir", "跳过"], "filters decide": ["filtros decidem", "deciden los filtros", "由筛选条件决定"], "always run": ["sempre executar", "ejecutar siempre", "始终运行"], "always skip": ["sempre ignorar", "omitir siempre", "始终跳过"], "⏸ Disable": ["⏸ Desativar", "⏸ Desactivar", "⏸ 禁用"], "▶ Enable": ["▶ Ativar", "▶ Activar", "▶ 启用"], "✂ Split": ["✂ Dividir", "✂ Dividir", "✂ 拆分"], "⇥ Merge": ["⇥ Unir", "⇥ Unir", "⇥ 合并"],
|
||||
"Advanced": ["Avançado", "Avanzado", "高级"], "English": ["Inglês", "Inglés", "英语"], "Português": ["Português", "Portugués", "葡萄牙语"], "Español": ["Espanhol", "Español", "西班牙语"], "中文": ["Chinês", "Chino", "中文"],
|
||||
"Color Guide": ["Guia de cores", "Guía de color", "颜色引导"], "Video": ["Vídeo", "Vídeo", "视频"], "Shots": ["Planos", "Planos", "镜头"], "Prompts": ["Prompts", "Prompts", "提示词"],
|
||||
"Guide color · Ref 1": ["Cor guia · Ref. 1", "Color guía · Ref. 1", "引导颜色 · 参考图 1"], "Guide color · Ref 2": ["Cor guia · Ref. 2", "Color guía · Ref. 2", "引导颜色 · 参考图 2"],
|
||||
"These colors are defaults for the global references. Turn on Color Guide on a shot to apply them; shot-level colors can override them.": ["Estas cores são os padrões das referências globais. Ative o Guia de cores no plano para aplicá-las; cada plano pode substituí-las.", "Estos colores son los valores predeterminados de las referencias globales. Activa la Guía de color en un plano para aplicarlos; cada plano puede cambiarlos.", "这些颜色是全局参考图的默认值。启用镜头的颜色引导即可应用;镜头也可以单独覆盖。"],
|
||||
"Audio": ["Áudio", "Audio", "音频"], "use the video's audio": ["usar o áudio do vídeo", "usar el audio del vídeo", "使用视频音频"], "silent track": ["faixa silenciosa", "pista silenciosa", "静音音轨"],
|
||||
"auto": ["automático", "automático", "自动"], "fixed": ["fixo", "fijo", "固定"], "single": ["único", "único", "单个"], "adaptive": ["adaptativo", "adaptativo", "自适应"], "content": ["conteúdo", "contenido", "内容"], "builtin": ["integrado", "integrado", "内置"],
|
||||
"PySceneDetect adaptive": ["PySceneDetect adaptativo", "PySceneDetect adaptativo", "PySceneDetect 自适应"], "PySceneDetect content": ["PySceneDetect por conteúdo", "PySceneDetect por contenido", "PySceneDetect 内容检测"], "Built-in": ["Integrado", "Integrado", "内置"],
|
||||
"Auto loop": ["Loop automático", "Bucle automático", "自动循环"], "Queue loop": ["Loop em fila", "Bucle en cola", "队列循环"], "queue loop": ["loop em fila", "bucle en cola", "队列循环"], "auto loop": ["loop automático", "bucle automático", "自动循环"],
|
||||
"no audio": ["sem áudio", "sin audio", "无音频"], "silent track": ["faixa silenciosa", "pista silenciosa", "静音音轨"], "timeline": ["linha do tempo", "línea de tiempo", "时间轴"], "generate at": ["gerar em", "generar a", "生成尺寸"],
|
||||
"Max person size (0-1 of frame)": ["Tamanho mínimo da pessoa (0–1 do quadro)", "Tamaño mínimo de persona (0–1 del fotograma)", "人物最小尺寸(占画面 0–1)"], "Min person size (0-1 of frame)": ["Tamanho mínimo da pessoa (0–1 do quadro)", "Tamaño mínimo de persona (0–1 del fotograma)", "人物最小尺寸(占画面 0–1)"],
|
||||
"Max people (0 = any)": ["Máx. pessoas (0 = qualquer)", "Máx. personas (0 = cualquiera)", "人物上限(0 = 不限)"], "Min frames (0 = off)": ["Mín. frames (0 = desligado)", "Mín. fotogramas (0 = desactivado)", "最少帧数(0 = 关闭)"], "Frames sampled / shot": ["Frames amostrados por plano", "Fotogramas muestreados por plano", "每个镜头采样帧数"], "Dark below (0-1)": ["Escuro abaixo de (0–1)", "Oscuro por debajo de (0–1)", "暗度低于(0–1)"], "Static below": ["Estático abaixo de", "Estático por debajo de", "静止度低于"],
|
||||
"e.g. 0.03 skips wide shots": ["ex.: 0,03 ignora planos abertos", "p. ej., 0,03 omite planos amplios", "例如 0.03 会跳过远景镜头"], "skip crowds": ["ignorar multidões", "omitir multitudes", "跳过人群"], "re-analyses": ["reanalisar", "vuelve a analizar", "重新分析"],
|
||||
"Describe target": ["Descrever alvo", "Describir objetivo", "描述目标"], "🧑 Describe target": ["🧑 Descrever alvo", "🧑 Describir objetivo", "🧑 描述目标"], "new subject": ["novo sujeito", "nuevo sujeto", "新主体"], "person (or dog, cat, car… what the result shows)": ["pessoa (ou cão, gato, carro… o que aparecerá no resultado)", "persona (o perro, gato, coche… lo que aparecerá en el resultado)", "人物(或狗、猫、汽车等结果中的主体)"],
|
||||
"The second person (optional)": ["Segunda pessoa (opcional)", "Segunda persona (opcional)", "第二个人(可选)"], "Follow cast": ["Seguir identidade", "Seguir reparto", "跟随角色"], "manual identity": ["identidade manual", "identidad manual", "手动身份"],
|
||||
"Guide video": ["Vídeo guia", "Vídeo guía", "引导视频"], "Reference": ["Referência", "Referencia", "参考图"], "Prompt": ["Prompt", "Prompt", "提示词"], "reference": ["referência", "referencia", "参考图"],
|
||||
"on": ["ativado", "activado", "开启"], "off": ["desativado", "desactivado", "关闭"], "not analysed": ["não analisado", "sin analizar", "未分析"], "not analyzed": ["não analisado", "sin analizar", "未分析"],
|
||||
"No shots yet": ["Nenhum plano ainda", "Aún no hay planos", "尚无镜头"], "No video": ["Nenhum vídeo", "Sin vídeo", "没有视频"], "No faces": ["Nenhum rosto", "Sin rostros", "没有人脸"], "no faces": ["sem rostos", "sin rostros", "没有人脸"],
|
||||
"cut": ["corte", "corte", "切换"], "run": ["executar", "ejecutar", "运行"], "skip": ["ignorar", "omitir", "跳过"], "skip this shot": ["ignorar este plano", "omitir este plano", "跳过此镜头"],
|
||||
"people": ["pessoas", "personas", "人物"], "person": ["pessoa", "persona", "人物"], "shots": ["planos", "planos", "镜头"], "shot(s)": ["plano(s)", "plano(s)", "个镜头"], "linked": ["vinculada", "vinculada", "已关联"], "main": ["principal", "principal", "主要"],
|
||||
"No VLM yet": ["Nenhum VLM carregado", "Aún no hay un VLM", "尚未加载 VLM"], "Connect a Qwen3-VL": ["Conecte um Qwen3-VL", "Conecta un Qwen3-VL", "连接 Qwen3-VL"],
|
||||
"↻ Status": ["↻ Status", "↻ Estado", "↻ 状态"], "⟲ Reset loop": ["⟲ Reiniciar loop", "⟲ Reiniciar bucle", "⟲ 重置循环"], "Queue loop": ["Loop em fila", "Bucle en cola", "队列循环"],
|
||||
"Start here": ["Começar aqui", "Empezar aquí", "从此处开始"], "End here": ["Terminar aqui", "Terminar aquí", "在此处结束"], "reset": ["redefinir", "restablecer", "重置"], "all shots": ["todos os planos", "todos los planos", "所有镜头"], "this shot": ["este plano", "este plano", "此镜头"],
|
||||
"Describe target": ["Descrever alvo", "Describir objetivo", "描述目标"], "Settings": ["Configurações", "Ajustes", "设置"], "mask text": ["texto da máscara", "texto de máscara", "蒙版描述"],
|
||||
"The global reference and prompt are used when a shot has no values of its own.": ["A referência e o prompt globais são usados quando um plano não tem valores próprios.", "La referencia y el prompt globales se usan cuando un plano no tiene valores propios.", "镜头没有单独设置时会使用全局参考图和提示词。"],
|
||||
"A VLM is optional": ["O VLM é opcional", "El VLM es opcional", "VLM 为可选项"], "Optional": ["Opcional", "Opcional", "可选"], "optional": ["opcional", "opcional", "可选"],
|
||||
"person in white": ["pessoa de branco", "persona de blanco", "穿白衣的人"], "woman in pink top": ["mulher de blusa rosa", "mujer con blusa rosa", "穿粉色上衣的女人"],
|
||||
"nothing found": ["nada encontrado", "no se encontró nada", "未找到内容"], "not found": ["não encontrado", "no encontrado", "未找到"], "Preview": ["Prévia", "Vista previa", "预览"],
|
||||
"Mask only / Crop + mask": ["Somente máscara / Recorte + máscara", "Solo máscara / Recorte + máscara", "仅蒙版 / 裁剪并蒙版"], "per shot (planner)": ["por plano (planner)", "por plano (planificador)", "逐镜头(规划器)"],
|
||||
"Person": ["Pessoa", "Persona", "人物"], "shot": ["plano", "plano", "镜头"], "shot(s)": ["plano(s)", "plano(s)", "个镜头"],
|
||||
"mask covers": ["máscara cobre", "la máscara cubre", "蒙版覆盖"], "nothing to fix": ["nada a corrigir", "nada que corregir", "无需修复"],
|
||||
"Camera cuts: shots start at the detected cuts, long ones are split evenly. Fixed length: equal parts no longer than the maximum.": ["Cortes de câmera: os planos começam nos cortes detectados; planos longos são divididos igualmente. Duração fixa: partes iguais limitadas pelo máximo.", "Cortes de cámara: los planos empiezan en los cortes detectados; los largos se dividen por igual. Duración fija: partes iguales con el máximo indicado.", "镜头切换:镜头从检测到的切点开始,较长镜头会均匀拆分。固定时长:拆分为不超过上限的等长片段。"],
|
||||
"Cut detector. PySceneDetect adaptive works on most footage (fast camera moves don't trigger it); content is stricter on hard cuts; built-in needs no package.": ["Detector de cortes. O modo adaptativo do PySceneDetect funciona na maioria dos vídeos (movimentos rápidos de câmera não acionam cortes); o modo conteúdo é mais rigoroso; o integrado não exige pacote.", "Detector de cortes. El modo adaptativo de PySceneDetect funciona en la mayoría de vídeos (los movimientos rápidos no activan cortes); contenido es más estricto; el integrado no requiere paquetes.", "镜头切换检测器。PySceneDetect 自适应模式适用于大多数素材(快速运镜不会触发切点);内容模式对硬切更严格;内置模式无需额外软件包。"],
|
||||
"How easily a change counts as a cut. Higher finds more cuts (and more false ones).": ["O quanto uma mudança é considerada corte. Um valor maior encontra mais cortes e também mais falsos positivos.", "Facilidad con la que un cambio cuenta como corte. Un valor mayor detecta más cortes y también más falsos positivos.", "变化被判定为切点的难易程度。数值越高,检测到的切点越多,误报也越多。"],
|
||||
"Frame counts the model accepts. H3: 17n+5, LTX/Wan: 8n+1, Wan: 4n+1. Each shot is generated at the next valid length and trimmed back.": ["Contagens de frames aceitas pelo modelo. H3: 17n+5; LTX/Wan: 8n+1; Wan: 4n+1. Cada plano é gerado no próximo comprimento válido e depois aparado.", "Cantidad de fotogramas que acepta el modelo. H3: 17n+5; LTX/Wan: 8n+1; Wan: 4n+1. Cada plano se genera con la siguiente duración válida y luego se recorta.", "模型接受的帧数规则。H3:17n+5;LTX/Wan:8n+1;Wan:4n+1。每个镜头按下一个有效长度生成后再裁短。"],
|
||||
"Longest shot sent to the model. Keep it within what the model / LoRA was trained on (H3 body swap: about 4.5 s).": ["Duração máxima enviada ao modelo. Mantenha dentro do que o modelo/LoRA aprendeu (body swap H3: cerca de 4,5 s).", "Duración máxima enviada al modelo. Respeta lo usado en el entrenamiento del modelo/LoRA (body swap H3: unos 4,5 s).", "发送给模型的镜头最长时长。应符合模型/LoRA 的训练长度(H3 换人约 4.5 秒)。"],
|
||||
"Shorter shots merge into a neighbour when the merge still fits the maximum.": ["Planos mais curtos são unidos ao vizinho quando a junção ainda respeita o máximo.", "Los planos más cortos se unen al vecino si la unión respeta el máximo.", "较短镜头会在合并后仍未超过上限时与相邻镜头合并。"],
|
||||
"The planner works on this frame rate (the source is resampled). Generated video and audio use it too.": ["O planner usa esta taxa de frames (o vídeo de origem é reamostrado). O vídeo e áudio gerados também a usam.", "El planificador usa esta frecuencia de fotogramas (el origen se remuestrea). El vídeo y el audio generados también la usan.", "规划器使用此帧率(源视频会重新采样)。生成的视频和音频也会使用此帧率。"],
|
||||
"Only the first N shots (0 = all). Handy for quick tests.": ["Somente os primeiros N planos (0 = todos). Útil para testes rápidos.", "Solo los primeros N planos (0 = todos). Útil para pruebas rápidas.", "仅处理前 N 个镜头(0 = 全部)。适合快速测试。"],
|
||||
"Only the first N seconds of the video (0 = all).": ["Somente os primeiros N segundos do vídeo (0 = todos).", "Solo los primeros N segundos del vídeo (0 = todos).", "仅处理视频前 N 秒(0 = 全部)。"],
|
||||
"Generation size: the source aspect ratio at this pixel area (0.15 ≈ 512×288). Cropped shots use it for the crop.": ["Tamanho da geração: mantém a proporção do vídeo nessa área de pixels (0,15 ≈ 512×288). Planos recortados usam esse valor no recorte.", "Tamaño de generación: mantiene la proporción de origen con esta área de píxeles (0,15 ≈ 512×288). Los planos recortados usan este valor.", "生成尺寸:按此像素面积保持源视频宽高比(0.15 ≈ 512×288)。裁剪镜头使用此尺寸。"],
|
||||
"Width and height snap to this multiple (32 for H3 and most video models).": ["Largura e altura são ajustadas a este múltiplo (32 para H3 e a maioria dos modelos de vídeo).", "El ancho y alto se ajustan a este múltiplo (32 para H3 y la mayoría de modelos de vídeo).", "宽度和高度会对齐到此倍数(H3 和多数视频模型使用 32)。"],
|
||||
"Auto loop: every shot in one queue run. Queue loop: one shot per run, stored on disk, the join outputs the video after the last shot.": ["Loop automático: todos os planos em uma execução da fila. Loop em fila: um plano por execução, salvo em disco; o join gera o vídeo após o último plano.", "Bucle automático: todos los planos en una ejecución. Bucle en cola: un plano por ejecución, guardado en disco; el join genera el vídeo al terminar el último.", "自动循环:一次队列运行所有镜头。队列循环:每次运行一个镜头并保存到磁盘;最后一个镜头完成后由 Join 输出视频。"],
|
||||
"Shots that do not run: keep the original video there, or remove them (with their audio).": ["Planos não executados: manter o vídeo original nesses trechos ou removê-los junto com o áudio.", "Planos que no se ejecutan: conserva el vídeo original en ese tramo o elimínalo junto con su audio.", "未运行的镜头:保留对应原视频片段,或连同音频一起移除。"],
|
||||
"Context kept around the mask's box, as a % of the box. More context blends better; less gives the model more pixels on the subject.": ["Contexto mantido ao redor da caixa da máscara, como porcentagem da caixa. Mais contexto mistura melhor; menos dá mais pixels ao sujeito.", "Contexto alrededor del cuadro de la máscara, como porcentaje. Más contexto mezcla mejor; menos ofrece más píxeles al sujeto.", "蒙版边框周围保留的上下文比例。上下文越多越容易融合;越少则主体像素越多。"],
|
||||
"Grow the mask by this many pixels: before pasting a cropped result back, and for the generation mask (inpaint 'only the mask'). Raise it when the new person is bigger than the old one (longer hair, wider body).": ["Expande a máscara por esta quantidade de pixels antes de colar o recorte e na máscara de geração. Aumente se a nova pessoa for maior (cabelo mais comprido, corpo mais largo).", "Amplía la máscara esta cantidad de píxeles antes de pegar el recorte y para la máscara de generación. Auméntala si la persona nueva es más grande.", "粘贴裁剪结果前以及生成蒙版时,将蒙版扩展指定像素。新人物更大时(头发更长、身体更宽)可调高。"],
|
||||
"Soft edge of the paste-back, in pixels.": ["Suavização da borda ao colar de volta, em pixels.", "Suavizado del borde al pegar de vuelta, en píxeles.", "贴回时边缘的柔化像素数。"],
|
||||
"Hold the mask this many frames before and after: removes flicker where SAM 3 drops a frame.": ["Estende a máscara por esta quantidade de frames antes e depois; reduz falhas quando o SAM 3 perde um frame.", "Mantiene la máscara estos fotogramas antes y después; reduce parpadeos cuando SAM 3 pierde uno.", "蒙版前后延续指定帧数,减少 SAM 3 漏掉某帧时的闪烁。"],
|
||||
"Square blocks of this size (0 = off). 16 matches H3's latent grid, useful as an inpainting mask.": ["Blocos quadrados deste tamanho (0 = desligado). 16 corresponde à grade latente do H3 e pode ser útil como máscara de inpainting.", "Bloques cuadrados de este tamaño (0 = desactivado). 16 coincide con la cuadrícula latente de H3 y sirve para inpainting.", "使用此尺寸的方块(0 = 关闭)。16 与 H3 潜空间网格匹配,可用于修补蒙版。"],
|
||||
"SAM 3 score needed to keep a text match. Lower finds more (and more wrong) objects.": ["Pontuação mínima do SAM 3 para manter uma correspondência por texto. Valores menores encontram mais objetos, inclusive incorretos.", "Puntuación mínima de SAM 3 para conservar una coincidencia de texto. Un valor menor encuentra más objetos, también erróneos.", "保留文本匹配所需的 SAM 3 分数。调低会找到更多对象,也会增加误检。"],
|
||||
"How many matches of the text prompt are tracked together.": ["Quantas correspondências do texto serão rastreadas juntas.", "Cuántas coincidencias del texto se siguen a la vez.", "同时跟踪多少个符合文本的对象。"],
|
||||
"Only the mask: the result replaces the masked pixels. The whole box: the full crop is pasted with feathered borders.": ["Somente máscara: o resultado substitui os pixels mascarados. Caixa inteira: todo o recorte é colado com bordas suaves.", "Solo máscara: el resultado sustituye los píxeles enmascarados. Cuadro completo: se pega todo el recorte con bordes suavizados.", "仅蒙版:结果替换蒙版像素。整个框:粘贴完整裁剪区域并柔化边缘。"],
|
||||
"How many frames of each shot the VLM looks at.": ["Quantos frames de cada plano o VLM analisa.", "Cuántos fotogramas de cada plano analiza el VLM.", "VLM 分析每个镜头的帧数。"], "Longest VLM answer. Raise it for long descriptions.": ["Tamanho máximo da resposta do VLM. Aumente para descrições longas.", "Longitud máxima de respuesta del VLM. Auméntala para descripciones largas.", "VLM 回复的最大长度。长描述可调高。"],
|
||||
};
|
||||
export function t(value) {
|
||||
const s = String(value ?? ""), lang = uiLanguage.value;
|
||||
if (lang === "en") return s;
|
||||
const entry = TEXT[s];
|
||||
if (entry) return entry[{ pt: 0, es: 1, zh: 2 }[lang]];
|
||||
let m;
|
||||
if ((m = s.match(/^Person (\d+)$/))) return `${t("Person")} ${m[1]}`;
|
||||
if ((m = s.match(/^Shot #(\d+)( · points)?$/))) return `${t("Shot")} #${m[1]}${m[2] ? ` · ${lang === "pt" ? "pontos" : lang === "es" ? "puntos" : "点"}` : ""}`;
|
||||
if ((m = s.match(/^(\d+) people · (\d+) linked$/))) return lang === "pt" ? `${m[1]} pessoas · ${m[2]} vinculadas` : lang === "es" ? `${m[1]} personas · ${m[2]} vinculadas` : `${m[1]} 人物 · 已关联 ${m[2]} 人`;
|
||||
if ((m = s.match(/^(\d+) shot\(s\) will run$/))) return lang === "pt" ? `${m[1]} plano(s) serão executados` : lang === "es" ? `se ejecutarán ${m[1]} plano(s)` : `将运行 ${m[1]} 个镜头`;
|
||||
if ((m = s.match(/^(\d+) suggestions$/))) return lang === "pt" ? `${m[1]} sugestões` : lang === "es" ? `${m[1]} sugerencias` : `${m[1]} 条建议`;
|
||||
if ((m = s.match(/^(\d+) shot\(s\)$/))) return lang === "pt" ? `${m[1]} plano(s)` : lang === "es" ? `${m[1]} plano(s)` : `${m[1]} 个镜头`;
|
||||
if ((m = s.match(/^(\d+) warning\(s\) · (\d+) note\(s\)$/))) return lang === "pt" ? `${m[1]} aviso(s) · ${m[2]} observação(ões)` : lang === "es" ? `${m[1]} aviso(s) · ${m[2]} nota(s)` : `${m[1]} 条警告 · ${m[2]} 条备注`;
|
||||
return s;
|
||||
}
|
||||
function localizeProps(props) {
|
||||
if (!props || typeof props !== "object") return props;
|
||||
let changed = false; const out = { ...props };
|
||||
for (const k of ["title", "placeholder", "aria-label"]) if (typeof out[k] === "string") { const v = t(out[k]); changed ||= v !== out[k]; out[k] = v; }
|
||||
return changed ? out : props;
|
||||
}
|
||||
function localizeChildren(value) {
|
||||
if (typeof value === "string") return t(value);
|
||||
if (Array.isArray(value)) return value.map(localizeChildren);
|
||||
return value;
|
||||
}
|
||||
// All view modules use this h wrapper, so labels, placeholders, tooltips and section titles stay in sync.
|
||||
export function h(type, props, children) {
|
||||
const hasChildrenArg = arguments.length >= 3;
|
||||
if (!hasChildrenArg && props?.__v_isVNode) return vueH(type, null, props);
|
||||
const p = localizeProps(props);
|
||||
if (hasChildrenArg) return vueH(type, p, localizeChildren(children));
|
||||
if (typeof props === "string" || Array.isArray(props)) return vueH(type, localizeChildren(props));
|
||||
return vueH(type, p);
|
||||
}
|
||||
|
||||
export const GRIDS = { "H3 (17n+5)": [17, 5], "LTX / Wan (8n+1)": [8, 1], "Wan (4n+1)": [4, 1], "any": [1, 0] };
|
||||
export const DEFAULTS = {
|
||||
video: "", fps: 24, grid: "H3 (17n+5)", mode: "shots", max_s: 4.5, min_s: 1.0, sensitivity: 0.5,
|
||||
max_parts: 0, max_total_s: 0, bounds: [], segs: [], global_ref: "", global_ref2: "", global_prompt: "",
|
||||
max_parts: 0, max_total_s: 0, start_s: 0, end_s: 0, start_f: null, end_f: null, track: null, bounds: [], segs: [], global_ref: "", global_ref2: "", global_prompt: "", global_color_guide_ref1: "#00ff00", global_color_guide_ref2: "#9b30ff", mask_video: "",
|
||||
megapixels: 0.15, multiple: 32, detector: "adaptive", run: "auto", auto_continue: true,
|
||||
skip_fill: "original", audio_mode: "auto", ref_details: {}, mask_cfg: {}, vlm_cfg: {}, cast: {}, cast_assign: true, cast_split: false, cast_only: false,
|
||||
filters: { person: false, min_person_area: 0, max_persons: 0, face: false, skip_dark: false, dark_level: 0.06,
|
||||
@@ -17,7 +231,7 @@ export const MASK_DEFAULTS = { threshold: 0.5, max_objects: 4, invert: false, fi
|
||||
padding: 0.15, expand: 16, feather: 12, paste: "mask" };
|
||||
|
||||
export const VLM_DEFAULTS = { enabled: false, frames: 3, max_tokens: 1024, auto_segment: true, auto_shot: true, instruction: "",
|
||||
describe_preset: "full body", describe_custom: "" };
|
||||
describe_preset: "short", describe_custom: "", fit_prompt: true, temperature: 0, seed: 0 };
|
||||
|
||||
// hover help for every setting (the 📖 Guide button opens the full documentation)
|
||||
export const TIPS = {
|
||||
@@ -57,7 +271,11 @@ export const snapDown = (n, grid) => {
|
||||
export const hue = i => `hsl(${(i * 47 + 200) % 360} 55% 46%)`;
|
||||
export const fmtT = (f, fps) => { const s = f / fps; return `${Math.floor(s / 60)}:${(s % 60).toFixed(2).padStart(5, "0")}`; };
|
||||
export const segKey = s => `${s.start}-${s.end}`;
|
||||
export const hasMask = s => !!(s.mask?.text || (s.mask?.points || []).length);
|
||||
// a shot has a mask: its own mask video, SAM 3 text or points, or the plan's mask video for the whole video
|
||||
// (the planner's `mask` input is only known when the workflow runs)
|
||||
export const hasMask = s => !!(s.mask?.video || s.mask?.text || (s.mask?.points || []).length || s.extMask);
|
||||
export const maskSource = s => s.mask?.video ? "video" : (s.mask?.text || (s.mask?.points || []).length) ? "sam"
|
||||
: s.trackMask ? "track" : s.extMask ? "global" : "";
|
||||
|
||||
// ---- small view helpers
|
||||
export const pill = (text, kind = "", title = "") => h("span", { class: ["pill", kind], title }, text);
|
||||
@@ -72,9 +290,14 @@ export const check = (checked, onChange, label, title = "") =>
|
||||
export const select = (value, options, onChange, attrs = {}) => h("select", { value, onChange: e => onChange(e.target.value), ...attrs },
|
||||
options.map(o => h("option", { value: Array.isArray(o) ? o[0] : o }, Array.isArray(o) ? o[1] : o)));
|
||||
// a titled section inside a card; `open` makes it a collapsible <details>
|
||||
// what the user opened or closed wins over a section's default: re-renders (a timer, a status line) no longer snap a
|
||||
// section back shut right after it was clicked open
|
||||
const OPEN = reactive({});
|
||||
export const section = (title, body, { sub = "", right = null, open = null, cls = "" } = {}) => {
|
||||
const head = [h("span", { class: "sect" }, title), sub ? hint(sub) : null, h("span", { class: "grow" }), right];
|
||||
return open === null
|
||||
? h("div", { class: ["sec", cls] }, [h("div", { class: "sech" }, head), ...[body].flat()])
|
||||
: h("details", { class: ["sec", cls], open }, [h("summary", { class: "sech" }, [h("span", { class: "caret" }, "▸"), ...head]), ...[body].flat()]);
|
||||
if (open === null) return h("div", { class: ["sec", cls] }, [h("div", { class: "sech" }, head), ...[body].flat()]);
|
||||
const key = `${cls}|${typeof title === "string" ? title : ""}`;
|
||||
const isOpen = key in OPEN ? OPEN[key] : !!open;
|
||||
return h("details", { class: ["sec", cls], open: isOpen, onToggle: e => { OPEN[key] = e.target.open; } },
|
||||
[h("summary", { class: "sech" }, [h("span", { class: "caret" }, "▸"), ...head]), ...[body].flat()]);
|
||||
};
|
||||
|
||||
@@ -97,6 +97,10 @@ const CSS = `
|
||||
.bsl .hdl{position:absolute;top:-70px;bottom:0;width:9px;margin-left:-4px;cursor:ew-resize;z-index:3}
|
||||
.bsl .hdl::after{content:"";position:absolute;left:3px;top:0;bottom:0;width:3px;background:#fff;opacity:.85;border-radius:2px;box-shadow:0 0 4px #000}
|
||||
.bsl .hdl:hover::after{background:#ffd34d}
|
||||
.bsl .rgrid{display:grid;grid-template-columns:auto 130px auto 130px auto;gap:6px 10px;align-items:start;justify-content:start}
|
||||
.bsl .rgrid>button{margin-top:15px}
|
||||
.bsl .rgrid .fld .hint{margin-top:2px}
|
||||
.bsl .outr{position:absolute;top:0;bottom:0;background:repeating-linear-gradient(45deg,#000a 0 6px,#0007 6px 12px);z-index:2;pointer-events:none}
|
||||
.bsl .cut{position:absolute;top:0;height:64px;width:2px;background:#ff4d6d;opacity:.9;pointer-events:none}
|
||||
.bsl .ph{position:absolute;top:0;bottom:0;width:1px;background:#ffd34d;pointer-events:none;z-index:4}
|
||||
.bsl .playhead{position:absolute;top:0;bottom:0;width:2px;background:#ffd34d;box-shadow:0 0 6px #ffd34d;pointer-events:none;z-index:5}
|
||||
@@ -137,13 +141,15 @@ const CSS = `
|
||||
.bsl .refslot.sm{width:56px;height:56px;font-size:10px}
|
||||
.bsl .refslot.via{border-style:solid;border-color:#3b5b3b}
|
||||
.bsl .rslot{display:flex;flex-direction:column;gap:3px;align-items:center}
|
||||
.bsl .guidecolor{display:flex;flex-direction:column;align-items:center;gap:2px;max-width:112px}
|
||||
.bsl .guidecolor select{width:100%;font-size:10px;padding:2px 4px}
|
||||
.bsl .rbtns{display:flex;gap:3px;min-height:20px}
|
||||
.bsl .rbtns button{padding:1px 6px;font-size:10px}
|
||||
.bsl .recent{display:flex;gap:4px;flex-wrap:wrap;margin-top:6px;align-items:center}
|
||||
.bsl .recent img{width:30px;height:30px;border-radius:5px;object-fit:cover;cursor:pointer;border:1px solid #3a3a45}
|
||||
.bsl .recent img:hover{border-color:var(--acc)}
|
||||
.bsl .recent.sm img{width:22px;height:22px}
|
||||
.bsl .modes{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-bottom:6px}
|
||||
.bsl .modes{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:6px;margin-bottom:6px}
|
||||
.bsl .mode{border:1px solid var(--line2);border-radius:7px;padding:6px 8px;cursor:pointer;background:#18181d}
|
||||
.bsl .mode:hover{border-color:#55556a}
|
||||
.bsl .mode.on{border-color:var(--acc);background:#1a2034}
|
||||
@@ -176,6 +182,12 @@ const CSS = `
|
||||
.bsl .ck .lvl{font-size:12px}
|
||||
.bsl .prog{height:7px;border-radius:4px;background:#26262e;overflow:hidden}
|
||||
.bsl .prog>div{height:100%;background:linear-gradient(90deg,#3a63e0,#7fe0a8)}
|
||||
.bsl .langpick{display:flex;gap:5px;align-items:center}
|
||||
.bsl .langpick select{width:auto;min-width:100px;padding:4px 6px}
|
||||
.bsl .tourback{position:fixed;inset:0;z-index:10001;background:rgba(0,0,0,.68);display:flex;align-items:center;justify-content:center;padding:16px}
|
||||
.bsl .tourbox{width:min(480px,94vw);background:#1d1d23;border:1px solid #4a4a58;border-radius:12px;padding:16px;box-shadow:0 12px 48px #0009}
|
||||
.bsl .tourbox h3{margin:18px 0 8px;color:var(--hi)}
|
||||
.bsl .tourbox p{line-height:1.55}
|
||||
|
||||
/* points window (teleported to <body>) */
|
||||
.bsl .mmodal{position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:10000;display:flex;align-items:center;justify-content:center}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The busy bar and the points window (🎯 Points…: click on a frame to pick the subject for SAM 3).
|
||||
import { h, Teleport } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
import { hint } from "./common.js";
|
||||
import { Teleport } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
import { h, hint, t } from "./common.js";
|
||||
|
||||
export function busyBar(c, msg) {
|
||||
const { busySince, now, upPct, status } = c;
|
||||
@@ -52,8 +52,45 @@ export function pointsModal(c) {
|
||||
h("button", { onClick: () => { modal.open = false; } }, "Cancel"),
|
||||
h("button", { title: "Apply these points to EVERY shot, each on its frame at the same relative position (a subject that stays in place)",
|
||||
onClick: c.savePointsAll }, "Save → all shots"),
|
||||
h("button", { disabled: !modal.points.length,
|
||||
title: "Track this selection with SAM 3 through the whole video (or the range), forward and backward from this frame; every shot without its own selection takes its part of the track. Also type a short text (e.g. 'woman in pink top') so the subject is found again after camera cuts.",
|
||||
onClick: c.saveTrack }, "🎯 Track → whole video"),
|
||||
h("button", { class: "pri", onClick: c.savePoints }, "Save (this shot)"),
|
||||
]),
|
||||
]),
|
||||
])));
|
||||
}
|
||||
|
||||
const TOUR_STEPS = [
|
||||
["video", "Video", "Choose a video, set the frame range, split at camera cuts, then set the generation size."],
|
||||
["shots", "Shots", "Review each shot, assign its references and prompt, and choose how the result is applied."],
|
||||
["people", "People", "Link detected people to references. Set masks here or per shot; ArcFace links are a guide and need your review."],
|
||||
["prompts", "Prompts", "Set shared references, prompts and optional VLM assistance. Written prompts are kept exactly as authored."],
|
||||
["shots", "Color Guide", "Color Guide adds a translucent marker to a selected person. Ref 1 and Ref 2 must use different colors in the same shot."],
|
||||
["run", "Run", "Check warnings, choose auto or queue loop, and set filters or quick-run limits."],
|
||||
];
|
||||
|
||||
export function tourModal(c) {
|
||||
const { tour, tab } = c;
|
||||
const step = tour.value - 1;
|
||||
if (step < 0 || step >= TOUR_STEPS.length) return null;
|
||||
const [targetTab, title, body] = TOUR_STEPS[step];
|
||||
return h(Teleport, { to: "body" }, h("div", { class: "bsl", style: "background:none;padding:0" },
|
||||
h("div", { class: "tourback", role: "dialog", "aria-modal": "true", "aria-label": t("Shot Planner tour"),
|
||||
onKeydown: e => { if (e.key === "Escape") tour.value = 0; }, onPointerdown: e => { if (e.target === e.currentTarget) tour.value = 0; } }, [
|
||||
h("div", { class: "tourbox" }, [
|
||||
h("div", { class: "row" }, [h("b", t("Shot Planner tour")), h("span", { class: "grow" }), h("span", { class: "hint" }, `${step + 1} / ${TOUR_STEPS.length}`),
|
||||
h("button", { class: "ghost", title: t("Close tour"), "aria-label": t("Close tour"), onClick: () => { tour.value = 0; } }, "✕")]),
|
||||
h("h3", t(title)),
|
||||
h("p", t(body)),
|
||||
h("p", { class: "hint" }, t("This tour is optional. Use Next to visit each main tab; close it whenever you like.")),
|
||||
h("div", { class: "row", style: "justify-content:flex-end;margin-top:12px" }, [
|
||||
step > 0 ? h("button", { onClick: () => { tour.value--; tab.value = TOUR_STEPS[step - 1][0]; } }, t("Back")) : null,
|
||||
h("button", { class: "pri", onClick: () => {
|
||||
if (step === TOUR_STEPS.length - 1) tour.value = 0;
|
||||
else { tour.value++; tab.value = TOUR_STEPS[step + 1][0]; }
|
||||
} }, t(step === TOUR_STEPS.length - 1 ? "Close tour" : "Next")),
|
||||
]),
|
||||
]),
|
||||
])));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// 👥 People & masks tab: the cast found by face (each person linked to a reference) and the global SAM 3 mask settings.
|
||||
import { h } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
import { fmtT, fld, check, section, pill, hint } from "./common.js";
|
||||
import { h, fmtT, fld, check, section, pill, hint } from "./common.js";
|
||||
|
||||
const CAST_COLORS = [["", "No Color Guide color"], ["#00ff00", "Green"], ["#9b30ff", "Purple"], ["#ff2020", "Red"], ["#168cff", "Blue"],
|
||||
["#ffe600", "Yellow"], ["#ff00d4", "Magenta"], ["#00e5c0", "Cyan"]];
|
||||
|
||||
function castSection(c) {
|
||||
const { plan, people, busy, usedRefs } = c;
|
||||
@@ -19,6 +21,11 @@ function castSection(c) {
|
||||
h("img", { class: "face", src: p.thumb, title: `first seen ${fmtT(p.first, fps)} (click to seek)`, onClick: () => c.seek(p.first) }),
|
||||
h("div", { class: "t" }, [h("b", `Person ${p.id}`), ` · ${(p.share * 100).toFixed(1)}%`]),
|
||||
h("div", { class: "t" }, `${fmtT(p.first, fps)} → ${fmtT(p.last, fps)}`),
|
||||
h("div", { class: "row", style: "margin-top:4px;align-items:center" }, [
|
||||
h("span", { class: "hint" }, "Color Guide identity color"),
|
||||
h("select", { value: cc.guide_color || "", style: "width:auto", onChange: e => c.setCast(p.id, "guide_color", e.target.value) },
|
||||
CAST_COLORS.map(([v, label]) => h("option", { value: v }, label))),
|
||||
]),
|
||||
h("div", { class: "refbox" }, ["ref", "ref2"].map(k => h("div", { class: "rslot" }, [
|
||||
h("div", { class: "refslot sm", title: cc[k] ? cc[k] + " (click to replace)" : "click to upload",
|
||||
onClick: () => c.pickFile("image/*", name => c.setCast(p.id, k, name)) }, cc[k] ? [h("img", { src: c.viewUrl(cc[k]) })] : [k === "ref" ? "⬆ ref" : "⬆ ref 2"]),
|
||||
@@ -43,13 +50,26 @@ function maskSection(c) {
|
||||
const mnum = (k, step, label, help, scale = 1) => fld(label, h("input", { type: "number", step, min: 0, value: +(MC[k] * scale).toFixed(3),
|
||||
onChange: e => c.setMaskCfg(k, (parseFloat(e.target.value) || 0) / scale) }), help);
|
||||
const S = segs.value;
|
||||
const counts = { mask: 0, crop: 0, cropmask: 0 };
|
||||
const counts = { mask: 0, crop: 0, cropmask: 0, paste: 0 };
|
||||
S.forEach(s => { const m = c.modeOf(s); if (m in counts) counts[m]++; });
|
||||
const masked = S.filter(s => s.mask?.text || (s.mask?.points || []).length).length;
|
||||
const masked = S.filter(s => s.mask?.text || (s.mask?.points || []).length || s.mask?.video || s.extMask).length;
|
||||
return section("Mask & crop settings", [
|
||||
h("div", { class: "note", style: "margin-bottom:8px" }, [
|
||||
"Each shot picks what it marks (🎯 text or points) and how it is generated (", h("b", "Full frame · Mask only · Crop · Crop + mask"),
|
||||
") in the Shots tab. These settings apply to every shot."]),
|
||||
"Each shot picks what it marks (🎯 text or points, or 🎞 its own mask video) and how it is generated (", h("b", "Full frame · Frame + paste · Mask only · Crop · Crop + mask"),
|
||||
") in the Shots tab. Masks from a video (here, per shot, or the planner's ", h("b", "mask"), " input) replace SAM 3. These settings apply to every shot."]),
|
||||
h("div", { class: "row", style: "margin-bottom:8px;align-items:center" }, [
|
||||
h("span", { style: "white-space:nowrap", title: "A black/white video of the WHOLE source video (white = the subject), e.g. rotoscoped in another tool. Shots without their own mask (video, text or points) use it instead of SAM 3. The planner's optional mask input does the same from the graph and wins over this." }, "🎞 Mask video (whole video)"),
|
||||
h("select", { value: plan.mask_video || "", style: "flex:1;min-width:180px", onChange: e => c.setGlobalMaskVideo(e.target.value) },
|
||||
[h("option", { value: "" }, "— none: SAM 3 per shot —"), ...c.files.videos.map(v => h("option", { value: v }, v))]),
|
||||
h("button", { onClick: () => c.pickFile("video/*", c.setGlobalMaskVideo) }, "⬆ Upload"),
|
||||
plan.mask_video ? h("button", { onClick: () => c.setGlobalMaskVideo("") }, "✕") : null,
|
||||
]),
|
||||
plan.track?.points?.length ? h("div", { class: "note", style: "margin-bottom:8px;display:flex;gap:8px;align-items:center" }, [
|
||||
h("span", { class: "grow" }, [h("b", "🎯 Whole-video track: "), `${plan.track.points.length} point(s) at frame ${plan.track.frame}`,
|
||||
plan.track.text ? ` · re-found by "${plan.track.text}" after cuts` : " · no text: after a cut the subject may be lost (add one in 🎯 Points…)",
|
||||
". Shots without their own selection use it."]),
|
||||
h("button", { class: "ghost", onClick: c.clearTrack }, "✕ remove"),
|
||||
]) : null,
|
||||
h("div", { class: "row", style: "gap:14px;margin-bottom:6px" }, [
|
||||
check(MC.fill_holes, v => c.setMaskCfg("fill_holes", v), "Fill holes", "Fill enclosed gaps so each region is solid"),
|
||||
check(MC.invert, v => c.setMaskCfg("invert", v), "Invert", "Use everything except the segmented object"),
|
||||
@@ -68,7 +88,7 @@ function maskSection(c) {
|
||||
]),
|
||||
hint("Uses the official sam3.1_multiplex_fp16 checkpoint (models/checkpoints), downloaded from Comfy-Org/sam3.1 the first time.", "display:block;margin-top:6px"),
|
||||
], { sub: "SAM 3",
|
||||
right: h("span", { class: "row" }, [pill(`${masked} masked`), counts.mask ? pill(`${counts.mask} mask only`, "acc") : null,
|
||||
right: h("span", { class: "row" }, [pill(`${masked} masked`), counts.mask ? pill(`${counts.mask} mask only`, "acc") : null, counts.paste ? pill(`${counts.paste} frame+paste`, "acc") : null,
|
||||
counts.crop + counts.cropmask ? pill(`${counts.crop + counts.cropmask} cropped`, "warn") : null]) });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// 📝 Prompts & refs tab: the global reference / prompt, the VLM (shot suggestions) and the reference descriptions ({details}).
|
||||
import { h } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
import { fld, check, section, pill, hint, select } from "./common.js";
|
||||
import { h, fld, check, section, pill, hint, select } from "./common.js";
|
||||
|
||||
const GUIDE_COLORS = [["#00ff00", "Green"], ["#9b30ff", "Purple"], ["#ff2020", "Red"], ["#168cff", "Blue"],
|
||||
["#ffe600", "Yellow"], ["#ff00d4", "Magenta"], ["#00e5c0", "Cyan"]];
|
||||
|
||||
function globalSection(c) {
|
||||
const { plan } = c;
|
||||
@@ -8,6 +10,14 @@ function globalSection(c) {
|
||||
h("div", { class: "refslot", title: name ? name + " (click to replace)" : "click to upload", onClick: () => c.uploadRef("global", field) },
|
||||
name ? [h("img", { src: c.viewUrl(name) })] : [label]),
|
||||
h("div", { class: "rbtns" }, [name ? h("button", { title: "remove", onClick: () => c.setRef("global", field, "") }, "✕") : null]),
|
||||
h("label", { class: "guidecolor" }, [
|
||||
h("span", { class: "hint" }, field === "global_ref" ? "Guide color · Ref 1" : "Guide color · Ref 2"),
|
||||
h("select", { value: field === "global_ref" ? (plan.global_color_guide_ref1 || "#00ff00") : (plan.global_color_guide_ref2 || "#9b30ff"),
|
||||
disabled: !name,
|
||||
title: "Default Color Guide marker when this global reference is used",
|
||||
onChange: e => c.setPlan(field === "global_ref" ? "global_color_guide_ref1" : "global_color_guide_ref2", e.target.value) },
|
||||
GUIDE_COLORS.map(([v, text]) => h("option", { value: v }, text))),
|
||||
]),
|
||||
]);
|
||||
return section("Global reference & prompt", [
|
||||
h("div", { class: "row", style: "align-items:flex-start;gap:10px" }, [
|
||||
@@ -21,6 +31,7 @@ function globalSection(c) {
|
||||
"Used by shots without their own. Connected ref_image / ref_image_2 / prompt inputs on the node override these. Placeholders: ",
|
||||
h("b", "{target}"), " who is replaced (per shot) · ", h("b", "{details}"), " description of the shot's references · ",
|
||||
h("b", "{shot}"), " the VLM's description of the shot · ", h("b", "{setting}"), " the setting picture (H3 Conditioning)."]),
|
||||
h("div", { class: "hint", style: "margin-top:4px" }, "These colors are defaults for the global references. Turn on Color Guide on a shot to apply them; shot-level colors can override them."),
|
||||
], { sub: "defaults for every shot" });
|
||||
}
|
||||
|
||||
@@ -51,9 +62,20 @@ function vlmSection(c) {
|
||||
right: h("span", { class: "row" }, [VC.enabled ? pill("on", "ok") : pill("off"), sugN ? pill(`${sugN} suggestions`) : null]) }),
|
||||
section("Describe references ({details})", [
|
||||
h("div", { class: "row", style: "gap:8px" }, [
|
||||
select(VC.describe_preset, ["full body", "head / face", "face attributes", "outfit", "custom"], v => c.setVlmCfg("describe_preset", v), { style: "width:auto" }),
|
||||
select(VC.describe_preset, ["short", "full body", "head / face", "face attributes", "outfit", "custom"], v => c.setVlmCfg("describe_preset", v), { style: "width:auto" }),
|
||||
h("button", { class: "pri", disabled: !!busy.value || !refSets.value.length, onClick: c.describeRefs }, "📝 Describe refs"),
|
||||
h("button", { disabled: !!busy.value || !refSets.value.length,
|
||||
title: "Try another description: a new seed (and some temperature if it is 0), then describe again",
|
||||
onClick: () => { if (!(+VC.temperature > 0)) c.setVlmCfg("temperature", 0.7); c.setVlmCfg("seed", (+VC.seed || 0) + 1); c.describeRefs(); } }, "🎲"),
|
||||
fld("Temperature", h("input", { type: "number", min: 0, max: 1.5, step: 0.1, value: VC.temperature ?? 0, style: "width:70px",
|
||||
title: "0 = always the same answer (greedy); 0.5-1 = varied answers, picked by the seed",
|
||||
onChange: e => c.setVlmCfg("temperature", Math.max(0, parseFloat(e.target.value) || 0)) })),
|
||||
fld("Seed", h("input", { type: "number", min: 0, step: 1, value: VC.seed ?? 0, style: "width:80px",
|
||||
title: "Used when the temperature is above 0: another seed, another description",
|
||||
onChange: e => c.setVlmCfg("seed", Math.max(0, parseInt(e.target.value) || 0)) })),
|
||||
hint(`${refSets.value.length} reference set(s) · editable below`),
|
||||
check(VC.fit_prompt !== false, v => c.setVlmCfg("fit_prompt", v), "fit the prompt",
|
||||
"The VLM gets the global prompt's sentence that holds {details} (and the shot prompt's {target} sentence for 🧑 Describe target) with a blank in its place, and writes the description to fit it: the description adapts to the prompt, not the other way round."),
|
||||
]),
|
||||
VC.describe_preset === "custom" ? h("textarea", { style: "margin-top:6px;min-height:52px", value: VC.describe_custom || "",
|
||||
placeholder: "Your instruction for the VLM, e.g. 'Describe the character's costume and props piece by piece…'",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// ▶ Run tab: checks before running, how the loop runs (auto / queue), which shots run (filters) and test limits.
|
||||
import { h } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
import { fld, check, section, pill, hint, select } from "./common.js";
|
||||
import { h, fld, check, section, pill, hint, select } from "./common.js";
|
||||
|
||||
function checksSection(c) {
|
||||
const { checks, tab } = c;
|
||||
@@ -29,7 +28,7 @@ function loopSection(c) {
|
||||
fld("Run", select(plan.run, [["auto", "Auto loop (one run)"], ["queue", "Queue loop (one shot per run)"]], v => c.setPlan("run", v, c.progress))),
|
||||
fld("Skipped shots in the output", select(plan.skip_fill, [["original", "Keep original video"], ["drop", "Remove them"]], v => c.setPlan("skip_fill", v))),
|
||||
fld("Max shots (0 = all)", num("max_parts", 1, 0), "quick tests"),
|
||||
fld("Max total seconds (0 = all)", num("max_total_s", 0.5, 0), "quick tests"),
|
||||
fld("Max total seconds (0 = all)", num("max_total_s", 0.5, 0), "from the start of the range"),
|
||||
]),
|
||||
plan.run === "queue" ? h("div", { style: "margin-top:8px" }, [
|
||||
h("div", { class: "row", style: "margin-bottom:4px" }, [h("b", "Queue loop"), h("span", { class: "grow" }),
|
||||
|
||||
+132
-19
@@ -1,7 +1,10 @@
|
||||
// 🎬 Shots tab: one card per shot, then the selected shot's editor (preview, reference & prompt, who is replaced,
|
||||
// continuity, copy to other shots).
|
||||
import { h } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
import { hue, fmtT, segKey, hasMask, pill, hint, check, section } from "./common.js";
|
||||
import { h, hue, fmtT, segKey, hasMask, maskSource, pill, hint, check, section } from "./common.js";
|
||||
import { h as rawH } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
|
||||
const GUIDE_COLORS = [["#00ff00", "Green"], ["#9b30ff", "Purple"], ["#ff2020", "Red"], ["#168cff", "Blue"],
|
||||
["#ffe600", "Yellow"], ["#ff00d4", "Magenta"], ["#00e5c0", "Cyan"]];
|
||||
|
||||
const bdg = (text, kind = "", title = "") => h("span", { class: ["bdg", kind], title }, text);
|
||||
|
||||
@@ -16,10 +19,13 @@ function shotCard(c, s, i) {
|
||||
h("div", { class: "row" }, [h("b", `#${i + 1}`), s.cut ? pill("cut") : null,
|
||||
why ? pill("skip", "warn", why) : pill("run", "ok"), s.force !== "auto" ? pill(s.force) : null]),
|
||||
h("div", { class: "badges" }, [
|
||||
hasMask(s) ? bdg("🎯 " + (s.mask.text ? s.mask.text.slice(0, 14) : `${(s.mask.points || []).length} pts`), "", s.mask.text || `${(s.mask.points || []).length} points`) : null,
|
||||
s.crop || s.inpaint ? bdg({ mask: "🎭 mask only", crop: "✂ crop", cropmask: "✂🎭 crop+mask" }[c.modeOf(s)], "on", c.modeName(s)) : null,
|
||||
{ video: bdg("🎞 mask video", "", s.mask.video), global: bdg("🎞 global mask", "", "the plan's mask video for the whole video"),
|
||||
track: bdg("🎯 track", "", "its part of the whole-video SAM 3 track"),
|
||||
sam: bdg("🎯 " + (s.mask.text ? s.mask.text.slice(0, 14) : `${(s.mask.points || []).length} pts`), "", s.mask.text || `${(s.mask.points || []).length} points`) }[maskSource(s)] || null,
|
||||
s.crop || s.inpaint || s.paste ? bdg({ paste: "🧩 frame+paste", mask: "🎭 mask only", crop: "✂ crop", cropmask: "✂🎭 crop+mask" }[c.modeOf(s)] + (s.inpaint && s.strength < 1 ? ` ${Number(s.strength).toFixed(2)}` : ""), "on", c.modeName(s)) : null,
|
||||
s.color_guide ? bdg("🎨 color guide", "on", `${s.color_guide_color || "cast color"} · opacity ${s.color_guide_opacity ?? 0.35}`) : null,
|
||||
s.target ? bdg("🧑 target", "on", s.target) : null,
|
||||
i > 0 && s.chain !== "off" ? bdg("⛓", "on", `continues from the previous shot's ${s.chainFrame} frame as ${s.chain}`) : null,
|
||||
i > 0 && s.chain !== "off" ? bdg("⛓", "on", s.chain === "history" ? `continues the previous shot's last ${s.chainHistory} frames (history)` : `continues from the previous shot's ${s.chainFrame} frame as ${s.chain}`) : null,
|
||||
warnN ? bdg(`⚠ ${warnN}`, "warn", issues.map(x => x.text).join("\n")) : null,
|
||||
]),
|
||||
who ? h("div", { class: "who" }, who.people.length ? who.people.map(id => h("img", {
|
||||
@@ -38,7 +44,8 @@ function shotCard(c, s, i) {
|
||||
h("img", { class: "rt", style: "width:56px", src: showMasks.value && mp?.frames?.length
|
||||
? mp.frames[Math.floor(mp.frames.length / 2)].src : c.thumbFor(s.start + Math.floor(s.len / 2)) }),
|
||||
]),
|
||||
h("div", { class: "p" }, s.prompt ? s.prompt : (plan.global_prompt ? "↳ global prompt" : "— no prompt —")),
|
||||
s.prompt ? rawH("div", { class: "p" }, s.prompt)
|
||||
: h("div", { class: "p" }, plan.global_prompt ? "↳ global prompt" : "— no prompt —"),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -80,6 +87,9 @@ function editor(c, cur) {
|
||||
const { plan, sel, segs, maskPrev, targetCrop, vlmSug, busy, usedRefs, copyOpts, COPY_FIELDS } = c;
|
||||
const i = sel.value, S = segs.value, k = segKey(cur), mp = maskPrev.value[k], g = vlmSug.value[k];
|
||||
const issues = c.checksFor(i);
|
||||
const guideColor1 = cur.color_guide_color || (cur.color_guide_person != null
|
||||
? (c.castOf(cur.color_guide_person).guide_color || plan.global_color_guide_ref1 || "#00ff00")
|
||||
: (plan.global_color_guide_ref1 || "#00ff00"));
|
||||
const viaRef = f => cur[f] ? "" : (c.castRef(cur, f) || plan["global_" + f] || "");
|
||||
|
||||
const head = h("div", { class: "edhd" }, [
|
||||
@@ -109,11 +119,13 @@ function editor(c, cur) {
|
||||
h("div", { style: "flex:1;min-width:200px" }, [
|
||||
h("textarea", { placeholder: "Prompt for this shot (empty = global prompt). Placeholders: {target} {details} {shot} {setting}",
|
||||
value: cur.prompt, onChange: e => c.setMeta(i, "prompt", e.target.value) }),
|
||||
!cur.prompt && plan.global_prompt ? hint(`↳ uses the global prompt: ${plan.global_prompt.slice(0, 120)}${plan.global_prompt.length > 120 ? "…" : ""}`) : null,
|
||||
!cur.prompt && plan.global_prompt ? rawH("span", { class: "hint" }, `↳ uses the global prompt: ${plan.global_prompt.slice(0, 120)}${plan.global_prompt.length > 120 ? "…" : ""}`) : null,
|
||||
]),
|
||||
]),
|
||||
usedRefs.value.length ? h("div", { class: "recent" }, [hint("recent (click = ref, shift+click = ref 2):"),
|
||||
...usedRefs.value.map(n => h("img", { src: c.viewUrl(n), title: n, onClick: e => c.setRef(i, e.shiftKey ? "ref2" : "ref", n) }))]) : null,
|
||||
...usedRefs.value.map(n => h("img", { src: c.viewUrl(n), title: n, onClick: e => c.setRef(i, e.shiftKey ? "ref2" : "ref", n) })),
|
||||
h("button", { class: "sm", title: "Forget the recent pictures (the ones this plan still uses stay)", onClick: () => c.clearRecent() }, "✕ clear"),
|
||||
h("button", { class: "sm dng", title: "Remove EVERY reference from this plan (global, all shots, all people) and the recent list", onClick: () => c.clearAllRefs() }, "✕ clear all")]) : null,
|
||||
h("div", { class: "row", style: "margin-top:6px" }, [
|
||||
h("button", { class: "dng", title: "clear this shot's references and prompt: it uses its person's / the global ones",
|
||||
onClick: () => { ["ref", "ref2", "prompt"].forEach(f => { c.meta(i)[f] = ""; }); c.save(); } }, "Use global"),
|
||||
@@ -134,22 +146,113 @@ function editor(c, cur) {
|
||||
h("button", { class: hasMask(cur) && !mp ? "pri" : "", disabled: !!busy.value || !hasMask(cur),
|
||||
title: (cur.mask.points || []).length ? "Segment this shot (the points win over the text)" : "Segment this shot with the text and show a few frames",
|
||||
onClick: () => c.previewMask(i) }, "👁 Preview"),
|
||||
h("button", { class: "dng", disabled: !(hasMask(cur) || cur.crop), title: "Remove this shot's segmentation (points, text, crop) - for shots where it picked the wrong thing",
|
||||
h("button", { class: "dng", disabled: !(cur.mask.text || (cur.mask.points || []).length || cur.mask.video || cur.crop || cur.inpaint), title: "Remove this shot's mask (points, text, mask video) and mode - for shots where it picked the wrong thing",
|
||||
onClick: () => c.clearMask(i) }, "✕ Clear"),
|
||||
]),
|
||||
h("div", { class: "row", style: "margin-top:6px;align-items:center" }, [
|
||||
h("span", { class: "hint", style: "white-space:nowrap", title: "A black/white video (white = the subject), e.g. rotoscoped in another tool. It replaces SAM 3 for this shot and covers only this shot: its first frame is the shot's first frame." }, "🎞 mask video"),
|
||||
h("select", { value: cur.mask.video || "", style: "flex:1;min-width:160px",
|
||||
onChange: e => c.setMask(i, "video", e.target.value) },
|
||||
[h("option", { value: "" }, cur.extMask ? "— none: the global mask video (People & masks) —" : "— none: SAM 3 (text / points) —"),
|
||||
...c.files.videos.map(v => h("option", { value: v }, v))]),
|
||||
h("button", { title: "upload a mask video for this shot", onClick: () => c.pickFile("video/*", name => c.setMask(i, "video", name)) }, "⬆ Upload"),
|
||||
cur.mask.video ? h("button", { title: "back to SAM 3 / the global mask", onClick: () => c.setMask(i, "video", "") }, "✕") : null,
|
||||
]),
|
||||
maskSource(cur) === "video" ? hint("This shot uses its mask video: the text and points are ignored.", "display:block;margin-top:2px")
|
||||
: maskSource(cur) === "global" ? hint("No mask of its own: this shot uses the global mask video (People & masks tab).", "display:block;margin-top:2px")
|
||||
: maskSource(cur) === "track" ? hint("No mask of its own: this shot uses its part of the whole-video track (People & masks tab).", "display:block;margin-top:2px") : null,
|
||||
mp ? h("div", { class: "mstrip" }, [
|
||||
...mp.frames.map(f => h("img", { src: f.src, title: `frame ${f.f}` })),
|
||||
hint(mp.empty ? "nothing found: the shot runs without a mask" : `mask covers ${(mp.coverage * 100).toFixed(1)}%${cur.crop ? " · yellow = crop box" : ""}`),
|
||||
]) : null,
|
||||
h("div", { class: "modes", style: "margin-top:8px" }, [
|
||||
["frame", "Full frame", "The whole frame is regenerated. The mask, if any, only feeds {target} / the setting picture."],
|
||||
["paste", "🧩 Frame + paste", "The whole frame is generated (the pose follows the guide, the new subject may be bigger); BFS Shot Join pastes only the subject onto the original: background exact."],
|
||||
["mask", "🎭 Mask only", "Only the mask is regenerated, on the whole frame: the rest stays exactly as it was. No crop / uncrop."],
|
||||
["crop", "✂ Crop", "A box around the mask is regenerated (more pixels for a small person); BFS Shot Join pastes it back."],
|
||||
["crop", "✂ Crop", "A box around the mask is regenerated (more pixels for a small subject); BFS Shot Join pastes it back."],
|
||||
["cropmask", "✂🎭 Crop + mask", "Inside the crop, only the mask is regenerated: the most detail with the background kept."],
|
||||
].map(([k, t, d]) => mode(c.modeOf(cur) === k, t, d, () => c.setMode(i, k)))),
|
||||
(cur.crop || cur.inpaint) && !hasMask(cur) ? h("div", { class: "err" }, `${c.modeName(cur)} needs a mask on this shot: add a mask text or points.`) : null,
|
||||
h("div", { class: "row", style: "margin-top:8px;align-items:center;gap:8px" }, [
|
||||
check(!!cur.color_guide, v => c.setMeta(i, "color_guide", v), "🎨 Color Guide",
|
||||
"Tint the selected person in the source guide. This is a translucent visual cue; it does not enable H3 mask-only inpainting."),
|
||||
cur.color_guide ? h("label", { class: "row", style: "gap:6px;align-items:center" }, [
|
||||
h("span", { class: "hint" }, "Ref 1 color"),
|
||||
h("select", { value: cur.color_guide_color ||
|
||||
(cur.color_guide_person != null ? (c.castOf(cur.color_guide_person).guide_color || plan.global_color_guide_ref1 || "#00ff00")
|
||||
: (plan.global_color_guide_ref1 || "#00ff00")),
|
||||
onChange: e => c.setMeta(i, "color_guide_color", e.target.value) },
|
||||
GUIDE_COLORS.map(([v, label]) => h("option", { value: v }, label))),
|
||||
]) : null,
|
||||
]),
|
||||
cur.color_guide ? h("details", { class: "note", style: "margin:4px 0;padding:6px 8px", open: !!cur.color_guide_text2 }, [
|
||||
h("summary", { style: "cursor:pointer" }, cur.color_guide_text2 ? "Second person · Ref 2" : "+ Add a second person (optional)"),
|
||||
h("div", { class: "row", style: "margin-top:8px;align-items:center;gap:8px" }, [
|
||||
h("span", { class: "hint", style: "white-space:nowrap" }, "Ref 2"),
|
||||
h("input", { type: "text", value: cur.color_guide_text2 || "", style: "flex:1;min-width:160px",
|
||||
placeholder: "Describe the second person for SAM 3",
|
||||
onChange: e => c.setMeta(i, "color_guide_text2", e.target.value) }),
|
||||
h("button", { disabled: !!busy.value || !cur.color_guide_text2,
|
||||
onClick: () => c.previewMask(i, { text: cur.color_guide_text2, max_objects: 1 }) }, "👁 Preview"),
|
||||
h("select", { value: cur.color_guide_color2 || (cur.color_guide_person2 != null
|
||||
? (c.castOf(cur.color_guide_person2).guide_color || plan.global_color_guide_ref2 || "#9b30ff")
|
||||
: (plan.global_color_guide_ref2 || "#9b30ff")),
|
||||
title: "Choose the marker color for the person matched to ref 2",
|
||||
onChange: e => c.setMeta(i, "color_guide_color2", e.target.value) },
|
||||
GUIDE_COLORS.map(([v, label]) => h("option", { value: v, disabled: v === guideColor1 }, label))),
|
||||
]),
|
||||
h("details", { style: "margin-top:6px" }, [
|
||||
h("summary", { class: "hint", style: "cursor:pointer" }, "Keep ref 2 color consistent across shots (optional)"),
|
||||
h("div", { class: "row", style: "margin-top:6px;gap:8px" }, [
|
||||
h("span", { class: "hint" }, "Ref 2 identity"),
|
||||
h("select", { value: cur.color_guide_person2 == null ? "" : String(cur.color_guide_person2),
|
||||
onChange: e => { c.setMeta(i, "color_guide_person2", e.target.value === "" ? null : Number(e.target.value)); c.setMeta(i, "color_guide_color2", ""); } },
|
||||
[h("option", { value: "" }, "Choose manually"), ...c.people.value.map(p => h("option", { value: String(p.id) }, `Person ${p.id}`))]),
|
||||
]),
|
||||
]),
|
||||
h("details", { style: "margin-top:6px" }, [
|
||||
h("summary", { class: "hint", style: "cursor:pointer" }, `Advanced · opacity ${Number(cur.color_guide_opacity ?? 0.35).toFixed(2)}`),
|
||||
h("div", { class: "row", style: "margin-top:6px;align-items:center" }, [
|
||||
h("span", { class: "hint" }, "Opacity"),
|
||||
h("input", { type: "range", min: 0.05, max: 0.8, step: 0.01, value: cur.color_guide_opacity ?? 0.35,
|
||||
style: "flex:1;max-width:320px", onInput: e => { c.meta(i).color_guide_opacity = parseFloat(e.target.value); }, onChange: c.save }),
|
||||
hint("The default keeps movement and expression visible."),
|
||||
]),
|
||||
]),
|
||||
]) : null,
|
||||
cur.color_guide ? h("details", { style: "margin:4px 0" }, [
|
||||
h("summary", { class: "hint", style: "cursor:pointer" }, `More options · opacity ${Number(cur.color_guide_opacity ?? 0.35).toFixed(2)}`),
|
||||
h("div", { class: "row", style: "margin-top:6px;gap:8px;align-items:center" }, [
|
||||
h("span", { class: "hint" }, "Ref 1 identity"),
|
||||
h("select", { value: cur.color_guide_person == null ? "" : String(cur.color_guide_person),
|
||||
onChange: e => { c.setMeta(i, "color_guide_person", e.target.value === "" ? null : Number(e.target.value)); c.setMeta(i, "color_guide_color", ""); } },
|
||||
[h("option", { value: "" }, "Choose manually"), ...c.people.value.map(p => h("option", { value: String(p.id) }, `Person ${p.id}`))]),
|
||||
cur.color_guide_person != null ? h("button", { class: "ghost", title: "Use the cast member's assigned color",
|
||||
onClick: () => c.setMeta(i, "color_guide_color", "") }, "↩ cast color") : null,
|
||||
h("span", { class: "hint" }, "Opacity"),
|
||||
h("input", { type: "range", min: 0.05, max: 0.8, step: 0.01, value: cur.color_guide_opacity ?? 0.35,
|
||||
style: "flex:1;max-width:240px", onInput: e => { c.meta(i).color_guide_opacity = parseFloat(e.target.value); }, onChange: c.save }),
|
||||
]),
|
||||
]) : null,
|
||||
cur.color_guide && !hasMask(cur) ? h("div", { class: "err" }, "Color Guide needs this shot's person selection: add mask text, points or a mask video, then preview it.") : null,
|
||||
cur.color_guide && cur.crop && cur.color_guide_text2 ? h("div", { class: "err" }, "The second Color Guide selection uses the full frame; choose Full frame, Frame + paste or Mask only for this shot.") : null,
|
||||
(cur.crop || cur.inpaint || cur.paste) && !hasMask(cur) ? h("div", { class: "err" }, `${c.modeName(cur)} needs a mask on this shot: a mask text, points or a mask video (or the planner's mask input).`) : null,
|
||||
cur.inpaint ? h("div", { class: "row", style: "margin-bottom:6px;align-items:center",
|
||||
title: "Opacity of the generation mask: the value of the white inside the mask (1 = fully white, the default). H3 reads a grey mask value as 'regenerate this much': at 0.85 the masked area starts at 85% of the noise and keeps ~15% of the original there. 0.8-0.9 keeps pose, outline and lighting while still swapping; too low copies the original person." }, [
|
||||
h("span", { style: "white-space:nowrap" }, `Mask opacity ${Number(cur.strength).toFixed(2)}`),
|
||||
h("input", { type: "range", min: 0.3, max: 1, step: 0.01, value: cur.strength, style: "flex:1;max-width:320px",
|
||||
onInput: e => { c.meta(i).strength = parseFloat(e.target.value); }, onChange: c.save }),
|
||||
h("button", { class: "ghost", disabled: cur.strength >= 1, onClick: () => c.setMeta(i, "strength", 1) }, "reset"),
|
||||
hint(cur.strength >= 1 ? "regenerates the masked area completely" : `keeps ~${Math.round(100 - cur.strength * 100)}% of the original inside the mask`),
|
||||
]) : null,
|
||||
cur.inpaint ? h("div", { class: "note", style: "margin-bottom:6px" }, [h("b", "Mask only / Crop + mask: "),
|
||||
"set BFS Shot H3 Conditioning → inpaint to ", h("b", "per shot (planner)"), " (the default). Raise Expand (People & masks tab) when the new person is bigger than the old one."]) : null,
|
||||
"set BFS Shot H3 Conditioning → inpaint to ", h("b", "per shot (planner)"), " (the default). Raise Expand (People & masks tab) when the new subject is bigger than the old one."]) : null,
|
||||
cur.paste ? h("div", { class: "row", style: "margin-bottom:6px;align-items:center",
|
||||
title: "What the NEW subject is, in English, for SAM 3 on the result: BFS Shot Join also pastes its outline (when it is bigger than the old one). E.g. person, dog, cat, car. Empty = person." }, [
|
||||
h("span", { style: "white-space:nowrap" }, "new subject"),
|
||||
h("input", { type: "text", value: cur.pasteText || "", placeholder: "person (or dog, cat, car… what the result shows)", style: "flex:1;max-width:420px",
|
||||
onChange: e => c.setMeta(i, "paste_text", e.target.value) }),
|
||||
hint("its outline on the result is pasted too, so a bigger silhouette is not cut"),
|
||||
]) : null,
|
||||
h("div", { class: "row", style: "margin-top:4px;align-items:center" }, [
|
||||
targetCrop.value[k] ? h("img", { src: targetCrop.value[k], style: "height:56px;border-radius:6px" }) : null,
|
||||
h("span", { class: "hint", style: "white-space:nowrap" }, "{target}"),
|
||||
@@ -173,14 +276,23 @@ function editor(c, cur) {
|
||||
|
||||
const cont = section("Continuity", h("div", { class: "row" }, [
|
||||
h("select", { value: cur.chain, style: "width:auto", disabled: i === 0,
|
||||
title: "reference: the previous result's frame becomes one more <Picture n> after this shot's own references. first frame: it is anchored at frame 0 of this shot.",
|
||||
title: "reference: the previous result's frame becomes one more <Picture n> after this shot's own references. first frame: it is anchored at frame 0 of this shot. history: the END of the previous result (17/34/51 frames) goes just before this shot on H3's timeline, so a long take continues its motion and look (same take only, not across a cut).",
|
||||
onChange: e => c.setMeta(i, "chain", e.target.value) },
|
||||
[h("option", { value: "off" }, "off"), h("option", { value: "reference" }, "previous shot as reference"),
|
||||
h("option", { value: "first frame" }, "previous shot as first frame")]),
|
||||
h("select", { value: cur.chainFrame, style: "width:auto", disabled: i === 0 || cur.chain === "off",
|
||||
title: "Which frame of the previous shot's result", onChange: e => c.setMeta(i, "chain_frame", e.target.value) },
|
||||
[h("option", { value: "first" }, "its first frame"), h("option", { value: "middle" }, "its middle frame"), h("option", { value: "last" }, "its last frame")]),
|
||||
hint(i === 0 ? "the first shot uses only its references" : "uses a frame of the PREVIOUS shot's result (queue loop, or auto loop with BFS Shot H3 Duet)"),
|
||||
h("option", { value: "first frame" }, "previous shot as first frame"),
|
||||
h("option", { value: "history" }, "previous shot's end as history (long take)")]),
|
||||
cur.chain === "history"
|
||||
? h("select", { value: String(cur.chainHistory), style: "width:auto", disabled: i === 0,
|
||||
title: "How much of the previous result's end goes before this shot (whole H3 VAE groups). More carries more motion, costs more memory.",
|
||||
onChange: e => c.setMeta(i, "chain_history", Number(e.target.value)) },
|
||||
[17, 34, 51].map(n => h("option", { value: String(n) }, `last ${n} frames (${(n / 24).toFixed(1)} s)`)))
|
||||
: h("select", { value: cur.chainFrame, style: "width:auto", disabled: i === 0 || cur.chain === "off",
|
||||
title: "Which frame of the previous shot's result", onChange: e => c.setMeta(i, "chain_frame", e.target.value) },
|
||||
[h("option", { value: "first" }, "its first frame"), h("option", { value: "middle" }, "its middle frame"), h("option", { value: "last" }, "its last frame")]),
|
||||
hint(i === 0 ? "the first shot uses only its references"
|
||||
: cur.chain === "history" ? (cur.cut ? "⚠ a camera cut starts this shot: history is skipped (it only carries a continuing take)"
|
||||
: "the end of the PREVIOUS shot's result continues into this one (queue loop: shots run in order). Works with every mask mode, crop and the duet canvas.")
|
||||
: "uses a frame of the PREVIOUS shot's result (queue loop, or auto loop with BFS Shot H3 Duet)"),
|
||||
]), { open: cur.chain !== "off", sub: cur.chain !== "off" ? cur.chain : "off" });
|
||||
|
||||
const copy = section("Copy to other shots", [
|
||||
@@ -188,7 +300,8 @@ function editor(c, cur) {
|
||||
check(copyOpts[f], v => { copyOpts[f] = v; }, label, {
|
||||
ref: "reference + ref 2", prompt: "this shot's prompt", mask_text: "the mask text (shots keep their own points)",
|
||||
points: "the points, on each shot's frame at the same relative position (a subject that stays in place)",
|
||||
crop: "the generation mode: Full frame / Mask only / Crop / Crop + mask (shots with a mask)", target: "the {target} description",
|
||||
mask_video: "the shot's mask video (it covers only its own shot: usually you want a different one per shot)",
|
||||
crop: "the generation mode (Full frame / Frame + paste / Mask only / Crop / Crop + mask) and its mask strength (shots with a mask)", target: "the {target} description",
|
||||
chain: "continuity (not on the first shot)" }[f]))),
|
||||
h("div", { class: "row", style: "margin-top:6px" }, [
|
||||
h("div", { class: "seg-btns" }, [["all", "all shots"], ["after", "shots after this"]].map(([v, l]) =>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Timeline: thumbnails, cut marks, cut score, shot bars (click = select, drag the white handles, double-click = split).
|
||||
import { h } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
import { hue, fmtT, pill } from "./common.js";
|
||||
import { h, hue, fmtT, pill } from "./common.js";
|
||||
|
||||
export function timelineCard(c) {
|
||||
const { plan, an, segs, sel, n, pxPerFrame, tlWidth, maxLen, cuts, hover, tlEl, zoom, play, vid, active } = c;
|
||||
@@ -26,6 +25,9 @@ export function timelineCard(c) {
|
||||
onClick: e => { if (e.target.classList.contains("hdl")) return; c.seek(c.frameAt(e).frame); } }, [
|
||||
h("div", { class: "tlin", style: `width:${tlWidth.value}px` }, [
|
||||
h("div", { class: "strip" }, thumbs.map(t => h("img", { src: t.src, style: `width:${thumbW}px` }))),
|
||||
// outside the start / end range: dimmed, not planned
|
||||
c.range.value.lo > 0 ? h("div", { class: "outr", style: `left:0;width:${c.range.value.lo * ppf}px`, title: "before the start: not planned" }) : null,
|
||||
c.range.value.hi < N ? h("div", { class: "outr", style: `left:${c.range.value.hi * ppf}px;width:${(N - c.range.value.hi) * ppf}px`, title: "after the end: not planned" }) : null,
|
||||
...cuts.value.filter(x => x < N).map(x => h("div", { class: "cut", style: `left:${x * ppf}px`, title: `cut @ ${x}` })),
|
||||
h("svg", { class: "spark", viewBox: `0 0 ${tlWidth.value} 26`, preserveAspectRatio: "none" },
|
||||
[h("polyline", { points: sparkPts, fill: "none", stroke: "#ff7a90", "stroke-width": 1, "vector-effect": "non-scaling-stroke" })]),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// 🎞 Video tab: the source video, how it is split into shots, and the generation size.
|
||||
import { h } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
import { GRIDS, fld, select, section, pill, hint } from "./common.js";
|
||||
import { h, GRIDS, fld, select, section, pill, hint, fmtT } from "./common.js";
|
||||
|
||||
export function videoTab(c) {
|
||||
const { plan, an, files, size, busy, detectorUsed, maxLen } = c;
|
||||
@@ -29,9 +28,43 @@ export function videoTab(c) {
|
||||
"It is split into shots the model can generate; every shot then gets its own reference, prompt and mask."]),
|
||||
]);
|
||||
|
||||
const R = c.range.value, N = c.n.value;
|
||||
const playF = Math.min(N - 1, Math.max(0, c.play.frame || 0));
|
||||
// a player right here, so the range is picked by watching (scrub it, or click the timeline above)
|
||||
const rng = section("Range", [
|
||||
h("div", { class: "player" }, [
|
||||
h("video", { ref: c.vid, src: c.viewUrl(plan.video), preload: "metadata", controls: true, playsinline: true,
|
||||
style: "max-height:240px" }),
|
||||
h("div", { class: "pinfo" }, [
|
||||
h("div", { class: "tc" }, [
|
||||
h("div", ["now ", h("b", fmtT(playF, fps)), ` (frame ${playF})`]),
|
||||
h("div", ["start ", h("b", fmtT(R.lo, fps)), ` (frame ${R.lo})`]),
|
||||
h("div", ["end ", h("b", fmtT(R.hi, fps)), ` (frame ${R.hi})`]),
|
||||
]),
|
||||
h("div", { class: "rgrid" }, [
|
||||
h("button", { class: "pri", title: "the range starts at the player's frame", onClick: () => c.setRange("start_f", playF) }, "⇤ start here"),
|
||||
fld("Start frame", h("input", { type: "number", step: 1, min: 0, max: N - 1, value: R.lo,
|
||||
onChange: e => c.setRange("start_f", e.target.value) }), fmtT(R.lo, fps)),
|
||||
h("button", { class: "pri", title: "the range ends after the player's frame", onClick: () => c.setRange("end_f", playF + 1) }, "⇥ end here"),
|
||||
fld("End frame (0 = end)", h("input", { type: "number", step: 1, min: 0, max: N, value: R.hi >= N ? 0 : R.hi,
|
||||
onChange: e => c.setRange("end_f", e.target.value) }), fmtT(R.hi, fps)),
|
||||
(R.lo > 0 || R.hi < N) ? h("button", { class: "ghost", onClick: () => { plan.start_f = 0; c.setRange("end_f", 0); } }, "✕ whole video") : h("span"),
|
||||
]),
|
||||
hint(`Play or scrub the video (or click the timeline), then ⇤ / ⇥. ${R.hi - R.lo} frames (${((R.hi - R.lo) / fps).toFixed(2)} s) of ${N}: only this part is split, analysed, generated and joined.`),
|
||||
]),
|
||||
]),
|
||||
], { sub: "work on a part of the video" });
|
||||
|
||||
const split = section("Split into shots", [
|
||||
h("div", { class: "grid" }, [
|
||||
fld("Mode", sel_("mode", [["shots", "Camera cuts"], ["fixed", "Fixed length"]])),
|
||||
fld("Mode", sel_("mode", [["shots", "Camera cuts"], ["fixed", "Fixed length"], ["single", "One shot (whole range)"]]),
|
||||
plan.mode === "single" ? "ignores Max seconds / shot" : ""),
|
||||
// the range in seconds, here next to the mode: "15 s in one shot" is set without leaving this tab
|
||||
fld("Range length, seconds (0 = to the end)", h("input", { type: "number", step: 0.5, min: 0,
|
||||
value: c.range.value.hi < c.n.value ? +((c.range.value.hi - c.range.value.lo) / plan.fps).toFixed(2) : 0,
|
||||
onChange: e => { const v = parseFloat(e.target.value) || 0;
|
||||
c.setRange("end_f", v > 0 ? c.range.value.lo + Math.round(v * plan.fps) : 0); } }),
|
||||
`from the start frame · ${c.range.value.hi - c.range.value.lo} frames`),
|
||||
fld("Detector", sel_("detector", [["adaptive", "PySceneDetect adaptive"], ["content", "PySceneDetect content"], ["builtin", "Built-in"]])),
|
||||
fld(`Sensitivity ${Number(plan.sensitivity).toFixed(2)}`, h("input", { type: "range", min: 0, max: 1, step: 0.05, value: plan.sensitivity,
|
||||
onInput: e => { plan.sensitivity = parseFloat(e.target.value); }, onChange: c.save })),
|
||||
@@ -53,5 +86,5 @@ export function videoTab(c) {
|
||||
fld("Size multiple", num("multiple", 8, 8)),
|
||||
]), { sub: "cropped shots use it for the crop" });
|
||||
|
||||
return h("div", { class: "card" }, [source, an.value ? split : null, an.value ? gen : null]);
|
||||
return h("div", { class: "card" }, [source, an.value ? rng : null, an.value ? split : null, an.value ? gen : null]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user