Compare commits

..
21 changed files with 621 additions and 3617 deletions
+1 -2
View File
@@ -140,8 +140,7 @@ workflow once per shot, and join the results back with the original timing and s
- **BFS Shot Unpack** — guide frames, references, prompt and length of one shot, for any model.
- **BFS Shot H3 Conditioning** — MiniMax H3 conditioning for one shot with the native nodes
(aligned guide and/or native reference video).
- **BFS Shot Join** — concatenates, trims to the true lengths, cross-fades soft joins, returns audio. With `comparison` on, its `guide` output is the original video frame for frame.
- **BFS Video Compare** — one player for two videos (Join's `guide` and `images`): wipe ↔ / ↕, overlay or side by side, any of guide / target / difference on top, plus a per-pixel difference map and the difference per frame (click to jump).
- **BFS Shot Join** — concatenates, trims to the true lengths, cross-fades soft joins, returns audio.
**Cast (1.49.0)**: *Find people* tracks faces through the video (InsightFace) and groups them
into people. Link each person to a reference and every shot uses the reference of the person
-8
View File
@@ -112,12 +112,6 @@ try:
except Exception as _e: # noqa
print(f"[BFSNodes] LoRA Surgery node not loaded: {_e!r}")
LSURG_NODE_CLASS_MAPPINGS, LSURG_NODE_DISPLAY_NAME_MAPPINGS = {}, {}
try:
from .bfs_video_compare import NODE_CLASS_MAPPINGS as VCMP_NODE_CLASS_MAPPINGS
from .bfs_video_compare import NODE_DISPLAY_NAME_MAPPINGS as VCMP_NODE_DISPLAY_NAME_MAPPINGS
except Exception as _e: # noqa
print(f"[BFSNodes] Video Compare node not loaded: {_e!r}")
VCMP_NODE_CLASS_MAPPINGS, VCMP_NODE_DISPLAY_NAME_MAPPINGS = {}, {}
NODE_CLASS_MAPPINGS = {
**H3GUIDE_NODE_CLASS_MAPPINGS,
@@ -145,7 +139,6 @@ NODE_CLASS_MAPPINGS = {
**EPF_NODE_CLASS_MAPPINGS,
**HSMS_NODE_CLASS_MAPPINGS,
**LSURG_NODE_CLASS_MAPPINGS,
**VCMP_NODE_CLASS_MAPPINGS,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -174,7 +167,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**EPF_NODE_DISPLAY_NAME_MAPPINGS,
**HSMS_NODE_DISPLAY_NAME_MAPPINGS,
**LSURG_NODE_DISPLAY_NAME_MAPPINGS,
**VCMP_NODE_DISPLAY_NAME_MAPPINGS,
}
WEB_DIRECTORY = "./web/js"
+4 -60
View File
@@ -16,21 +16,7 @@ import logging
import torch
LOG = logging.getLogger("bfs.h3_history")
# H3's VAE encodes 17-frame clips into 5 tokens (1+4+4+4+4 frames) and drops the last 3 tokens of the whole video, so only
# 17k+5 frames encode without loss: 17 frames come back as 2 tokens = the FIRST 5 frames, leaving a 12-frame hole between
# the history and the shot it sits before
HISTORY_FRAMES = (5, 22, 39, 56)
def history_length(wanted: int, available: int) -> int:
"""The longest valid history (17k+5 frames) up to `wanted` and `available`; 0 when not even 5 fit. A wanted value
off the grid (old plans saved 17/34/51) is read as the next valid one up (22/39/56)."""
wanted, available = max(0, int(wanted)), max(0, int(available))
if wanted < 5:
return 0
wanted = 5 + -(-(wanted - 5) // 17) * 17
n = min(wanted, available)
return 0 if n < 5 else 5 + ((n - 5) // 17) * 17
HISTORY_FRAMES = (17, 34, 51) # whole H3 VAE groups: 1+4+4+4+4 frames per 5 latent steps
_PATCH = "_bfs_history_patch"
@@ -105,11 +91,11 @@ def install() -> bool:
def add_history(positive, vae, frames: torch.Tensor):
"""Appends the last 17k+5 frames of `frames` ([N,H,W,3]) from the previous shot as an H3 history keyframe.
"""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 = history_length(frames.shape[0], frames.shape[0])
if n < 5:
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
@@ -119,45 +105,3 @@ def add_history(positive, vae, frames: torch.Tensor):
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
def pin_head(latent: dict, vae, frames: torch.Tensor):
"""Continuity 'head': the end of the previous shot's result (17k+5 frames, [N,H,W,3]) encoded over the FIRST latent
steps of this shot's video, with the sampler's noise mask at 0 there, so those frames are kept exactly as they were
(no new swap on them) and the rest of the shot is generated continuing from them. BFS Shot Join cuts them off again.
Same masked-prefix idea as Ethanfel's Context Loop 'masked AV' continuation. Returns (latent, frames pinned)."""
import comfy.nested_tensor
n = history_length(frames.shape[0], frames.shape[0])
samples = latent["samples"]
nested = getattr(samples, "is_nested", False)
video = samples.tensors[0] if nested else samples
T, h, w = int(video.shape[2]), int(video.shape[3]), int(video.shape[4])
if n < 5:
return latent, 0
px = frames[-n:, ..., :3].float().cpu()
if tuple(px.shape[1:3]) != (h * 16, w * 16):
px = torch.nn.functional.interpolate(px.movedim(-1, 1), size=(h * 16, w * 16), mode="bilinear",
align_corners=False).movedim(1, -1)
with torch.no_grad():
enc = vae.encode(px).to(video)
t = int(enc.shape[2])
if t >= T:
LOG.warning("H3 head: %d pinned latent steps leave nothing to generate in a %d-step video", t, T)
return latent, 0
video = video.clone()
video[:, :, :t] = enc
old = latent.get("noise_mask")
vm = old.tensors[0] if getattr(old, "is_nested", False) else old
vm = torch.ones((1, 1, T, h, w), dtype=torch.float32) if vm is None else vm.float().clone()
vm = torch.broadcast_to(vm, (vm.shape[0], vm.shape[1], T, h, w)).clone()
vm[:, :, :t] = 0.0
out = dict(latent)
if nested:
audio = samples.tensors[1]
am = old.tensors[1] if getattr(old, "is_nested", False) else torch.ones_like(audio)
out["samples"] = comfy.nested_tensor.NestedTensor((video, audio))
out["noise_mask"] = comfy.nested_tensor.NestedTensor((vm.to(video.device), am))
else:
out["samples"], out["noise_mask"] = video, vm.to(video.device)
LOG.info("H3 head: first %d frames (%d latent steps) pinned from the previous shot", n, t)
return out, n
+305 -1563
View File
File diff suppressed because it is too large Load Diff
-146
View File
@@ -1,146 +0,0 @@
"""BFS Video Compare: two videos in one player (wipe, overlay or side by side, either one on top) plus a per-pixel
difference map. Made for BFS Shot Join's `guide` and `images` outputs, but any two IMAGE batches work.
The difference is measured here on the frames themselves (not on the browser's compressed preview): per pixel, the mean
absolute RGB difference, drawn black (equal) -> red -> yellow -> white (very different), scaled by `gain`. The player
gets three temporary MP4s (guide, target, difference) and the per-frame difference, so it can jump to the worst frames.
"""
import math
import os
import uuid
import torch
import torch.nn.functional as F
import folder_paths
try:
from . import bfs_video_io as VIO
except ImportError: # loaded as a plain module (the tests): the file next to this one
import importlib.util as _ilu
_vio = _ilu.spec_from_file_location("bfs_video_io", os.path.join(os.path.dirname(os.path.abspath(__file__)),
"bfs_video_io.py"))
VIO = _ilu.module_from_spec(_vio)
_vio.loader.exec_module(VIO)
CHUNK = 16
def _scale(x: torch.Tensor, h: int, w: int) -> torch.Tensor:
if tuple(x.shape[1:3]) == (h, w):
return x
return F.interpolate(x.permute(0, 3, 1, 2), size=(h, w), mode="bilinear", align_corners=False,
antialias=True).permute(0, 2, 3, 1).clamp(0, 1)
def heat(v: torch.Tensor) -> torch.Tensor:
"""[..., H, W] in 0..1 -> RGB: black (0), red, yellow, white (1)."""
return torch.stack([(3 * v).clamp(0, 1), (3 * v - 1).clamp(0, 1), (3 * v - 2).clamp(0, 1)], -1)
def difference(guide: torch.Tensor, target: torch.Tensor, gain: float = 4.0):
"""Both [N, H, W, 3] in 0..1 (same size). Returns (map [N, H, W, 3], mean abs difference per frame in 0..255,
mean squared error per frame in 0..1)."""
d = (guide[..., :3].float() - target[..., :3].float()).abs()
mae = d.mean(dim=(1, 2, 3)) * 255
mse = (d * d).mean(dim=(1, 2, 3))
return heat((d.mean(-1) * float(gain)).clamp(0, 1)), mae, mse
def psnr(mse: float) -> float:
return float("inf") if mse <= 0 else 10 * math.log10(1.0 / mse)
class BFSVideoCompare:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"guide": ("IMAGE", {"tooltip": "The original video (e.g. BFS Shot Join's 'guide' output)."}),
"target": ("IMAGE", {"tooltip": "The result to compare with it (e.g. BFS Shot Join's 'images')."}),
},
"optional": {
"fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 240.0, "step": 0.001, "tooltip":
"Frame rate of both videos (connect BFS Shot Join's 'fps')."}),
"audio": ("AUDIO", {"tooltip": "Soundtrack for the player (e.g. BFS Shot Join's 'audio')."}),
"gain": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 64.0, "step": 0.5, "tooltip":
"How strongly differences light up in the map: a mean RGB difference of 1/gain "
"(128/255 at 2) is already white. Raise it to see small changes."}),
"size": (["guide", "target"], {"default": "guide", "tooltip":
"Which video's size the other is scaled to before comparing. "
"The Join's guide is at comparison_height, so 'guide' is lighter."}),
"preview_height": ("INT", {"default": 720, "min": 0, "max": 4096, "step": 16, "tooltip":
"Height of the player's videos (0 = the compared size). Only the preview: "
"the difference is measured before it."}),
"label_guide": ("STRING", {"default": "guide"}),
"label_target": ("STRING", {"default": "target"}),
},
}
RETURN_TYPES = ("IMAGE", "STRING", "FLOAT")
RETURN_NAMES = ("difference", "report", "mean_difference")
OUTPUT_TOOLTIPS = ("The difference map, one frame per compared frame (black = equal, white = very different).",
"Frame count, sizes, mean difference, PSNR and the most / least different frames.",
"Mean absolute RGB difference over all frames, 0-255.")
OUTPUT_NODE = True
FUNCTION = "compare"
CATEGORY = "BFS/shot loop"
DESCRIPTION = ("Play two videos in one player (wipe, overlay or side by side; choose which is on top) and see "
"where they differ, pixel by pixel.")
def compare(self, guide, target, fps=24.0, audio=None, gain=2.0, size="guide", preview_height=720,
label_guide="guide", label_target="target"):
notes = []
n = min(guide.shape[0], target.shape[0])
if guide.shape[0] != target.shape[0]:
notes.append(f"lengths differ (guide {guide.shape[0]}, target {target.shape[0]} frames): "
f"compared the first {n}")
ref = guide if size == "guide" else target
H, W = int(ref.shape[1]), int(ref.shape[2])
for name, v in (("guide", guide), ("target", target)):
if tuple(v.shape[1:3]) != (H, W):
notes.append(f"{name} scaled from {v.shape[2]}x{v.shape[1]} to {W}x{H}")
ph = min(H, int(preview_height)) if preview_height and preview_height > 0 else H
PH, PW = max(16, ph // 2 * 2), max(16, int(round(W * ph / H)) // 2 * 2)
out_dir = os.path.join(folder_paths.get_temp_directory(), "bfs_compare", uuid.uuid4().hex[:12])
os.makedirs(out_dir, exist_ok=True)
names = {"guide": "guide.mp4", "target": "target.mp4", "difference": "difference.mp4"}
writers = {k: VIO.Mp4Writer(os.path.join(out_dir, f), PW, PH, float(fps), audio if k == "target" else None,
crf=14, who="BFS Video Compare") for k, f in names.items()}
maps, mae, mse = [], [], []
try:
for a in range(0, n, CHUNK):
e = min(a + CHUNK, n) # the longer video stops where the shorter one ends
g = _scale(guide[a:e, ..., :3].float(), H, W)
t = _scale(target[a:e, ..., :3].float(), H, W)
m, e, s = difference(g, t, gain)
maps.append(m)
mae.append(e)
mse.append(s)
for k, v in (("guide", g), ("target", t), ("difference", m)):
writers[k].write(_scale(v, PH, PW))
finally:
for w in writers.values():
w.close()
diff_map = torch.cat(maps, 0)
mae, mse = torch.cat(mae), torch.cat(mse)
mean = float(mae.mean())
worst, best = int(mae.argmax()), int(mae.argmin())
report = "\n".join([
f"{label_guide} vs {label_target}: {n} frames at {fps:g} fps, compared at {W}x{H}",
f"mean difference {mean:.2f}/255, PSNR {psnr(float(mse.mean())):.2f} dB",
f"most different: frame {worst} ({worst / fps:.2f} s) {float(mae[worst]):.2f}/255",
f"least different: frame {best} ({best / fps:.2f} s) {float(mae[best]):.2f}/255",
*notes])
print(f"[BFS Video Compare] {report}", flush=True)
sub = os.path.relpath(out_dir, folder_paths.get_temp_directory())
files = {k: {"filename": f, "subfolder": sub, "type": "temp"} for k, f in names.items()}
ui = {"bfs_compare": [{**files, "fps": float(fps), "frames": n, "width": PW, "height": PH,
"labels": [label_guide, label_target], "mae": [round(float(x), 2) for x in mae],
"report": report}]}
return {"ui": ui, "result": (diff_map, report, mean)}
NODE_CLASS_MAPPINGS = {"BFSVideoCompare": BFSVideoCompare}
NODE_DISPLAY_NAME_MAPPINGS = {"BFSVideoCompare": "BFS Video Compare"}
-217
View File
@@ -1,217 +0,0 @@
"""Video files for the BFS nodes, with or without an ffmpeg program.
The ffmpeg program is found on PATH, else the one bundled by imageio-ffmpeg; with neither (e.g. a Windows portable
ComfyUI), MP4s are written and read through PyAV, the library ComfyUI's own Save Video uses, so it is always there.
"""
import json
import math
import os
import re
import shutil
import subprocess
from fractions import Fraction
import numpy as np
import torch
import torch.nn.functional as F
_BIN: dict = {}
def ffmpeg_bin() -> str | None:
"""The ffmpeg program to run, or None."""
if "ffmpeg" not in _BIN:
exe = shutil.which("ffmpeg")
if exe is None:
try:
import imageio_ffmpeg
exe = imageio_ffmpeg.get_ffmpeg_exe()
except Exception: # noqa: BLE001 - optional
exe = None
_BIN["ffmpeg"] = exe
return _BIN["ffmpeg"]
def ffprobe_bin() -> str | None:
if "ffprobe" not in _BIN:
_BIN["ffprobe"] = shutil.which("ffprobe")
return _BIN["ffprobe"]
def _wav(audio: dict) -> str:
import tempfile
import wave
fd, path = tempfile.mkstemp(suffix=".wav")
os.close(fd)
wf = audio["waveform"][0].clamp(-1, 1)
with wave.open(path, "wb") as f:
f.setnchannels(int(wf.shape[0]))
f.setsampwidth(2)
f.setframerate(int(audio["sample_rate"]))
f.writeframes((wf.t().contiguous() * 32767).round().short().numpy().tobytes())
return path
def _has_audio(audio) -> bool:
try:
return audio is not None and audio["waveform"].ndim == 3 and audio["waveform"].shape[-1] > 0
except Exception: # noqa: BLE001
return False
class Mp4Writer:
"""Frames streamed into an MP4 (H.264, yuv420p, even size) with the soundtrack, one chunk at a time.
metadata: tags like Save Video's (prompt, workflow...), values JSON-encoded when not strings."""
def __init__(self, path: str, w: int, h: int, fps: float, audio: dict | None = None, crf: int = 17,
metadata: dict | None = None, who: str = "BFS"):
self.path, self.who, self.wav, self.meta, self.proc, self.av = path, who, None, None, None, None
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
exe = ffmpeg_bin()
if exe is None:
self._open_av(w, h, fps, audio, crf, metadata)
return
inputs = ["-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}", "-r", f"{fps:g}", "-i", "-"]
maps, outs = ["-map", "0:v"], []
if _has_audio(audio):
self.wav = _wav(audio)
inputs += ["-i", self.wav]
maps += ["-map", "1:a"]
outs += ["-c:a", "aac", "-b:a", "192k"]
if metadata: # through an ffmetadata file
import tempfile
fd, self.meta = tempfile.mkstemp(suffix=".txt")
esc = lambda v: re.sub(r"([=;#\\\n])", r"\\\1", v)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(";FFMETADATA1\n")
for k, v in metadata.items():
f.write(f"{esc(str(k))}={esc(v if isinstance(v, str) else json.dumps(v))}\n")
maps += ["-map_metadata", str(inputs.count("-i"))]
inputs += ["-f", "ffmetadata", "-i", self.meta]
cmd = [exe, "-v", "error", "-y", *inputs, *maps, *outs,
"-c:v", "libx264", "-preset", "medium", "-crf", str(int(crf)), "-pix_fmt", "yuv420p",
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-movflags", "+use_metadata_tags+faststart", path]
self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
def _open_av(self, w, h, fps, audio, crf, metadata):
import av
self.av = av.open(self.path, mode="w", options={"movflags": "use_metadata_tags+faststart"})
for k, v in (metadata or {}).items():
self.av.metadata[str(k)] = v if isinstance(v, str) else json.dumps(v)
self.vs = self.av.add_stream("h264", rate=Fraction(round(float(fps) * 1000), 1000))
self.vs.width, self.vs.height = w + w % 2, h + h % 2
self.vs.pix_fmt = "yuv420p"
self.vs.options = {"crf": str(int(crf)), "preset": "medium"}
self.audio = audio if _has_audio(audio) else None
if self.audio is not None:
ch = int(self.audio["waveform"].shape[1])
self.layout = {1: "mono", 2: "stereo"}.get(ch, "stereo")
self.as_ = self.av.add_stream("aac", rate=int(self.audio["sample_rate"]), layout=self.layout)
def write(self, frames: torch.Tensor) -> None:
data = (frames[..., :3].clamp(0, 1) * 255).round().to(torch.uint8).contiguous().numpy()
if self.proc is not None:
self.proc.stdin.write(data.tobytes())
return
import av
h, w = data.shape[1:3]
for fr in data:
if (h % 2) or (w % 2):
fr = np.pad(fr, ((0, h % 2), (0, w % 2), (0, 0)), mode="edge")
for p in self.vs.encode(av.VideoFrame.from_ndarray(fr, format="rgb24").reformat(format="yuv420p")):
self.av.mux(p)
def close(self) -> str:
if self.proc is not None:
self.proc.stdin.close()
err = self.proc.stderr.read().decode(errors="replace")
self.proc.wait()
self._cleanup()
if self.proc.returncode != 0:
raise RuntimeError(f"{self.who}: ffmpeg could not write {self.path}: {err.strip()[-400:]}")
return self.path
import av
try:
for p in self.vs.encode(None):
self.av.mux(p)
if self.audio is not None:
wf = self.audio["waveform"][0, :2].float().clamp(-1, 1).contiguous().numpy()
fr = av.AudioFrame.from_ndarray(wf, format="fltp", layout=self.layout)
fr.sample_rate, fr.pts = int(self.audio["sample_rate"]), 0
for p in self.as_.encode(fr):
self.av.mux(p)
for p in self.as_.encode(None):
self.av.mux(p)
finally:
self.av.close()
return self.path
def abort(self) -> None:
try:
if self.proc is not None:
self.proc.kill()
elif self.av is not None:
self.av.close()
except Exception: # noqa: BLE001
pass
self._cleanup()
def _cleanup(self) -> None:
for f in (self.wav, self.meta):
if f and os.path.exists(f):
os.remove(f)
def read_video_scaled(path: str, budget: float) -> tuple[torch.Tensor, float]:
"""A video file as IMAGE, scaled down (never up) so it takes at most `budget` bytes; streamed into one tensor.
Returns (frames, scale)."""
exe, probe = ffmpeg_bin(), ffprobe_bin()
if exe is None or probe is None:
return _read_video_av(path, budget)
info = json.loads(subprocess.run([probe, "-v", "error", "-select_streams", "v:0", "-count_packets", "-show_entries",
"stream=width,height,nb_read_packets", "-of", "json", path],
capture_output=True, check=True).stdout)["streams"][0]
w, h, n = int(info["width"]), int(info["height"]), int(info["nb_read_packets"])
s = min(1.0, math.sqrt(max(budget, 1.0) / max(1, n * h * w * 3 * 4)))
W, H = max(16, int(w * s) // 2 * 2), max(16, int(h * s) // 2 * 2)
out = torch.empty((n, H, W, 3), dtype=torch.float32)
proc = subprocess.Popen([exe, "-v", "error", "-i", path, "-vf", f"scale={W}:{H}:flags=area", "-f", "rawvideo",
"-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE)
fb, i = W * H * 3, 0
while i < n:
raw = proc.stdout.read(fb * 16)
if not raw:
break
k = len(raw) // fb
out[i:i + k] = torch.frombuffer(bytearray(raw[:k * fb]), dtype=torch.uint8).view(k, H, W, 3).float() / 255.0
i += k
proc.stdout.close()
proc.wait()
return out[:max(1, i)], s
def _read_video_av(path: str, budget: float) -> tuple[torch.Tensor, float]:
import av
with av.open(path) as c:
st = c.streams.video[0]
w, h = int(st.codec_context.width), int(st.codec_context.height)
n = int(st.frames or 0) or int(round(float(st.duration * st.time_base) * float(st.average_rate)) if st.duration
else 0)
s = min(1.0, math.sqrt(max(budget, 1.0) / max(1, max(n, 1) * h * w * 3 * 4)))
W, H = max(16, int(w * s) // 2 * 2), max(16, int(h * s) // 2 * 2)
chunks, buf = [], []
for fr in c.decode(st):
buf.append(torch.from_numpy(fr.to_ndarray(format="rgb24")))
if len(buf) == 16:
chunks.append(_shrink(torch.stack(buf), H, W))
buf = []
if buf:
chunks.append(_shrink(torch.stack(buf), H, W))
return (torch.cat(chunks) if chunks else torch.zeros(1, H, W, 3)), s
def _shrink(x: torch.Tensor, h: int, w: int) -> torch.Tensor:
x = x.float() / 255.0
if tuple(x.shape[1:3]) == (h, w):
return x
return F.interpolate(x.permute(0, 3, 1, 2), size=(h, w), mode="area").permute(0, 2, 3, 1)
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "bfsnodes"
description = "Custom ComfyUI nodes for reference-guided video: BFS Sampler (guide-driven IC-LoRAs, auto crop, mask inpainting, temporal chunking), LTX Multiple Controls / Identity Transfer / Edit Anything, Head-Swap and Anime2Real conditioning, MiniMax-H3 latent guides, RoPE layouts and loaders, LoRA Surgery, and Shot Planner / Shot Loop (tabbed panel): split long videos into shots at camera cuts, face-tracked cast, SAM 3 mask per shot with full frame, paste, mask, crop and crop + mask generation modes, optional translucent Colored Mark overlay for manually selected regions, 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.107.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",
+6 -389
View File
@@ -1,13 +1,11 @@
from __future__ import annotations
import importlib.util
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch
import torch
@@ -63,55 +61,12 @@ class GridTest(unittest.TestCase):
self.assertEqual((w % 32, h % 32), (0, 0))
self.assertGreater(w, h)
def test_planner_defaults_to_half_megapixel(self):
self.assertEqual(SL.DEFAULT_PLAN["megapixels"], 0.5)
class TextGenerateCompatibilityTest(unittest.TestCase):
def _generate(self, execute):
calls = []
def wrapped(*args, **kwargs):
result = execute(*args, **kwargs)
calls.append(kwargs)
return result
textgen = types.ModuleType("comfy_extras.nodes_textgen")
textgen.TextGenerate = type("TextGenerate", (), {"execute": staticmethod(wrapped)})
comfy_extras = types.ModuleType("comfy_extras")
comfy_extras.__path__ = []
modules = {"comfy_extras": comfy_extras, "comfy_extras.nodes_textgen": textgen}
with patch.dict(sys.modules, modules):
result = SL.vlm_generate(object(), "describe this", torch.zeros(1, 2, 2, 3), 32)
self.assertEqual(result, "generated")
return calls[0]
def test_old_textgenerate_signature_does_not_receive_mtp(self):
def execute(clip, prompt, max_length, sampling_mode, image=None):
return types.SimpleNamespace(args=("generated",))
kwargs = self._generate(execute)
self.assertNotIn("mtp", kwargs)
def test_new_textgenerate_signature_keeps_mtp_disabled(self):
def execute(clip, prompt, max_length, sampling_mode, image=None, mtp="auto"):
return types.SimpleNamespace(args=("generated",))
kwargs = self._generate(execute)
self.assertEqual(kwargs["mtp"], "off")
class PlanTest(unittest.TestCase):
def test_fixed_mode_splits_evenly_under_the_limit(self):
segs = SL.plan_segments(300, [], "fixed", 107, 24)
self.assertEqual([s["end"] - s["start"] for s in segs], [100, 100, 100])
def test_camera_cut_mode_splits_a_continuous_range_at_max_length(self):
segs = SL.plan_segments(360, [], "shots", 107, 1)
self.assertEqual(len(segs), 4)
self.assertTrue(all(s["end"] - s["start"] <= 107 for s in segs))
self.assertEqual(segs[-1]["end"], 360)
def test_shots_split_at_cuts_and_long_shots_are_divided(self):
segs = SL.plan_segments(300, [50, 80], "shots", 107, 10)
bounds = [(s["start"], s["end"]) for s in segs]
@@ -170,7 +125,7 @@ class JoinTest(unittest.TestCase):
shots = [_shot(0, 0, 20, count=2, queue=True, run_id="t1"), _shot(1, 20, 45, cut=True, count=2, queue=True, run_id="t1")]
first = join.join([self._frames(22, 0.1)], [shots[0]], [0])
self.assertIsInstance(first[0], _Blocker)
video, _, _, _, _, _ = join.join([self._frames(39, 0.9)], [shots[1]], [0])
video, _, _, _ = join.join([self._frames(39, 0.9)], [shots[1]], [0])
self.assertEqual(video.shape[0], 45)
self.assertAlmostEqual(float(video[0, 0, 0, 0]), 0.1, places=2)
@@ -220,28 +175,6 @@ class TimelineJoinTest(unittest.TestCase):
self.assertEqual(video.shape[0], 60)
self.assertAlmostEqual(float(video[30, 0, 0, 0]), 128 / 255, places=3)
def test_skipped_shot_keeps_its_sound(self):
# 60 frames at 24 fps, sample rate 240 -> 10 samples per frame; each sample holds its timeline frame number
sr = 240
timeline_audio = {"waveform": torch.arange(600, dtype=torch.float32).div(10).floor().view(1, 1, 600), "sample_rate": sr}
runs_only = {"waveform": torch.full((1, 1, 400), -1.0), "sample_rate": sr, "bfs_runs_only": True}
tl = dict(self._tl("original"), audio=timeline_audio)
shots = [_shot(0, 0, 20, gen=22), _shot(1, 40, 60, cut=True, gen=22)]
imgs = [torch.zeros(22, 8, 8, 3), torch.ones(22, 8, 8, 3)]
orig = SL._read_frames
SL._read_frames = lambda path, idx, size: [__import__("numpy").full((8, 8, 3), 128, "uint8") for _ in idx]
try:
_, a, _ = SL.BFSShotJoin()._join_all(imgs, shots, [0], [runs_only], tl)
# a soundtrack from elsewhere is still used as given
_, other, _ = SL.BFSShotJoin()._join_all(imgs, shots, [0], [dict(runs_only, bfs_runs_only=False)], tl)
finally:
SL._read_frames = orig
wf = a["waveform"][0, 0]
self.assertEqual(wf.shape[0], 600)
self.assertEqual([float(wf[i * 10]) for i in (0, 25, 30, 45, 59)], [0.0, 25.0, 30.0, 45.0, 59.0]) # in sync, skipped shot not silent
self.assertNotIn("bfs_runs_only", a)
self.assertEqual(float(other["waveform"][0, 0, 0]), -1.0)
class CastTest(unittest.TestCase):
# person 0 on frames 0-47 (big face), person 1 on 48-95, both small on 96-119
@@ -282,19 +215,15 @@ class ComparisonTest(unittest.TestCase):
dict(_shot(1, 20, 40, cut=True, gen=22), frames=torch.zeros(22, 32, 48, 3), ref=None, ref2=None,
prompt="")]
imgs = [torch.full((22, 32, 48, 3), 0.5), torch.full((22, 32, 48, 3), 0.7)]
video, _, _, comp, _, guide = SL.BFSShotJoin().join(imgs, shots, [0], comparison=[True], label=["steps 20"])
video, _, _, comp = SL.BFSShotJoin().join(imgs, shots, [0], comparison=[True], label=["steps 20"])
self.assertEqual(video.shape[0], 40)
self.assertEqual(tuple(guide.shape), (40, 32, 48, 3)) # the originals, frame for frame
self.assertEqual(float(guide.abs().max()), 0.0)
self.assertEqual(comp.shape[0], 40)
self.assertEqual(comp.shape[2] % 16, 0)
self.assertEqual(comp.shape[1] % 16, 0)
self.assertGreaterEqual(comp.shape[2], 48 * 2 + 32)
self.assertGreater(comp.shape[1], 32)
off = SL.BFSShotJoin().join(imgs, shots, [0])
self.assertEqual(off[3].shape[0], 1)
self.assertIsInstance(off[5], _Blocker) # no comparison: no originals kept
self.assertIn("comparison", off[5].message)
off = SL.BFSShotJoin().join(imgs, shots, [0])[3]
self.assertEqual(off.shape[0], 1)
class ContinuityTest(unittest.TestCase):
@@ -320,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"]), ("head", "first frame", "middle")) # head is the default
self.assertEqual((segs[0]["chain"], segs[1]["chain"], segs[1]["chain_frame"]), ("history", "first frame", "middle")) # history is the default
class MaskCropTest(unittest.TestCase):
@@ -479,39 +408,6 @@ class ConditioningWriterTest(unittest.TestCase):
self._run(task="style", instruction="anime")
class SimpleConditioningTest(unittest.TestCase):
def test_exposes_only_the_common_controls(self):
inputs = SL.BFSShotH3ConditioningSimple.INPUT_TYPES()
names = set(inputs["required"]) | set(inputs["optional"])
self.assertIn("with_audio", names)
self.assertIn("inpaint", names)
self.assertNotIn("duet", names)
self.assertNotIn("model", names)
self.assertNotIn("rope_gap", names)
self.assertNotIn("task", names)
def test_forwards_safe_advanced_defaults_and_hides_model_output(self):
seen = {}
original = SL.BFSShotH3Conditioning.condition
def fake(_self, *args, **kwargs):
seen["args"], seen["kwargs"] = args, kwargs
return "positive", "latent", "unchanged model", "prompt"
SL.BFSShotH3Conditioning.condition = fake
try:
out = SL.BFSShotH3ConditioningSimple().condition(
"shot", "clip", "vae", SL.BFSShotH3ConditioningSimple.GUIDE_MODES[0], True, "match",
audio_vae="audio vae", with_audio=True)
finally:
SL.BFSShotH3Conditioning.condition = original
self.assertEqual(out, ("positive", "latent", "prompt"))
self.assertEqual(seen["args"][5], "none")
self.assertTrue(seen["kwargs"]["with_audio"])
self.assertEqual(seen["kwargs"]["inpaint"], SL.INPAINT_MODES[0])
class TargetTest(unittest.TestCase):
def test_target_placeholder(self):
self.assertEqual(SL.fill_target("Replace {target} with <Subject 1>", "the man in a red coat"),
@@ -983,288 +879,9 @@ class ShotHistoryTest(unittest.TestCase):
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], 22) # 30 real frames -> 22, the longest 17k+5 that fits
self.assertEqual(hist.shape[0], 17) # 30 real frames -> one whole group of 17
SL._LAST_RESULT.clear()
def test_history_lengths_are_whole_vae_clips(self):
# 17 frames encode to 2 tokens (the first 5 frames): only 17k+5 reaches the boundary
self.assertEqual([SL.history_frame_count(w, 200) for w in (5, 17, 22, 34, 39, 51, 56)], [5, 22, 22, 39, 39, 56, 56])
self.assertEqual([SL.history_frame_count(56, a) for a in (4, 5, 21, 22, 38, 40)], [0, 5, 5, 22, 22, 39])
class ShotHeadTest(unittest.TestCase):
"""Continuity 'head': the previous shot's end starts the next shot, pinned in the latent, and the join cuts it."""
@staticmethod
def _segs(chain="head", n=22, cut=False, gap=0):
return [{"start": 0, "end": 60, "chain": "off", "cut_before": False},
{"start": 60 + gap, "end": 120, "chain": chain, "chain_history": n, "cut_before": cut}]
def test_head_length(self):
self.assertEqual(SL.head_length(self._segs(), 1, H3), 22)
self.assertEqual(SL.head_length(self._segs(n=56), 1, H3), 39) # capped at 39
self.assertEqual(SL.head_length(self._segs(n=17), 1, H3), 22) # old plans: next valid length up
self.assertEqual(SL.head_length(self._segs(), 0, H3), 0) # first shot
self.assertEqual(SL.head_length(self._segs(cut=True), 1, H3), 0) # a camera cut
self.assertEqual(SL.head_length(self._segs(gap=5), 1, H3), 0) # a skipped shot in between
self.assertEqual(SL.head_length(self._segs(chain="history"), 1, H3), 0)
self.assertEqual(SL.head_length(self._segs(), 1, "LTX / Wan (8n+1)"), 0)
def test_join_cuts_the_head(self):
img = torch.arange(30, dtype=torch.float32).view(30, 1, 1, 1).expand(30, 2, 2, 3)
shot = {"head": 5, "gen_length": 30, "frames": img.clone(), "crop": {"mask": torch.zeros(30, 2, 2), "box": [0, 0, 1, 1]}}
out, sh = SL.drop_head(img, shot)
self.assertEqual((out.shape[0], float(out[0, 0, 0, 0]), sh["gen_length"], sh["frames"].shape[0],
sh["crop"]["mask"].shape[0], sh["head"], shot["frames"].shape[0]), (25, 5.0, 25, 25, 25, 0, 30))
self.assertIs(SL.drop_head(img, {"head": 0})[0], img)
def test_remember_result_skips_the_head(self):
SL._LAST_RESULT.clear()
SL.remember_result({"index": 0, "count": 2, "head": 5}, torch.zeros(30, 2, 2, 3))
self.assertEqual(SL._LAST_RESULT["frames"].shape[0], 25)
SL._LAST_RESULT.clear()
def test_pin_head_keeps_the_first_latent_steps(self):
class NT:
def __init__(self, t):
self.tensors, self.is_nested = list(t), True
nt = types.ModuleType("comfy.nested_tensor")
nt.NestedTensor = NT
old = {k: sys.modules.get(k) for k in ("comfy", "comfy.nested_tensor")}
comfy = types.ModuleType("comfy")
comfy.nested_tensor = nt
sys.modules["comfy"], sys.modules["comfy.nested_tensor"] = comfy, nt
try:
import bfs_h3_history as HH
class VAE:
def encode(self, px):
n = px.shape[0]
self.size = tuple(px.shape[1:3])
return torch.full((1, 24, 5 * -(-n // 17) - 3, px.shape[1] // 16, px.shape[2] // 16), 7.0)
vae = VAE()
latent = {"samples": NT((torch.zeros(1, 24, 12, 4, 4), torch.zeros(1, 8, 50)))}
out, n = HH.pin_head(latent, vae, torch.rand(30, 32, 32, 3)) # 30 frames -> the last 22 -> 7 steps
v, m = out["samples"].tensors[0], out["noise_mask"].tensors[0]
self.assertEqual((n, vae.size), (22, (64, 64)))
self.assertTrue(bool((v[:, :, :7] == 7).all()) and bool((v[:, :, 7:] == 0).all()))
self.assertTrue(bool((m[:, :, :7] == 0).all()) and bool((m[:, :, 7:] == 1).all()))
self.assertTrue(bool((out["noise_mask"].tensors[1] == 1).all()))
# an existing inpaint mask stays as it is after the head
inp = torch.full((1, 1, 12, 4, 4), 0.5)
out2, _ = HH.pin_head(dict(latent, noise_mask=NT((inp, torch.ones(1, 8, 50)))), vae, torch.rand(22, 64, 64, 3))
m2 = out2["noise_mask"].tensors[0]
self.assertTrue(bool((m2[:, :, :7] == 0).all()) and bool((m2[:, :, 7:] == 0.5).all()) and bool((inp == 0.5).all()))
finally:
for k, v in old.items():
if v is None:
sys.modules.pop(k, None)
else:
sys.modules[k] = v
class MemoryStatusTest(unittest.TestCase):
def test_shot_tokens(self):
# 107 H3 frames -> 7 clips -> 32 latent steps; 960x544 -> 60 x 34 cells
self.assertEqual(SL.shot_tokens(107, 960, 544), 32 * 34 * 60)
self.assertEqual(SL.shot_tokens(22, 32, 32), 7 * 2 * 2)
self.assertGreater(SL.shot_tokens(90, 960, 544), SL.shot_tokens(73, 960, 544)) # a head makes the shot bigger
self.assertEqual(SL.shot_tokens(81, 32, 32, "LTX / Wan (8n+1)"), 21 * 2 * 2)
def test_record_and_status(self):
import json as _json
path = Path(_TMP) / SL._VRAM_FILE
path.unlink(missing_ok=True)
cuda = types.SimpleNamespace(is_available=lambda: True, is_initialized=lambda: True, current_device=lambda: 0,
get_device_name=lambda d: "Test GPU", max_memory_allocated=lambda: 12 * 2 ** 30,
reset_peak_memory_stats=lambda: None, mem_get_info=lambda: (8 * 2 ** 30, 32 * 2 ** 30))
with patch.object(SL.torch, "cuda", cuda):
SL.vram_record(5000)
SL.vram_record(3000) # smaller: the biggest stays
st = SL.vram_status()
self.assertEqual((st["gpu"], st["max_tokens"], st["max_peak_gb"], st["runs"], st["total_gb"], st["free_gb"]),
("Test GPU", 5000, 12.0, 2, 32.0, 8.0))
self.assertIn("Test GPU", _json.loads(path.read_text()))
SL.vram_record(4000) # no CUDA in use: nothing recorded, nothing started
self.assertEqual(len(_json.loads(path.read_text())["Test GPU"]["samples"]), 2)
class ShotStoreTest(unittest.TestCase):
def test_storage_round_trip(self):
d = tempfile.mkdtemp(dir=_TMP)
y = torch.linspace(0, 1, 48).view(1, 48, 1, 1)
frames = (((torch.sin(torch.arange(12).view(12, 1, 1, 1) + 9 * y) + 1) / 2).expand(12, 48, 64, 3) * 255).round().byte()
for k, mode in enumerate(("lossless", "raw", "smaller")):
SL.store_shot(d, k, frames, {"index": k, "fps": 24.0}, mode)
back, meta = SL.load_shot(d, k)
self.assertEqual((tuple(back.shape), meta["index"]), ((12, 48, 64, 3), k))
diff = int((back.int() - frames.int()).abs().max())
if mode == "smaller":
self.assertLess(diff, 40)
else:
self.assertEqual(diff, 0, mode) # lossless and raw are exact
self.assertTrue(os.path.exists(os.path.join(d, "shot_0000.mkv")))
self.assertFalse(os.path.exists(os.path.join(d, "shot_0001.mkv"))) # raw: the frames are in the .pt
def test_status_cascades_through_continuity(self):
d = tempfile.mkdtemp(dir=_TMP)
for i in range(3):
open(os.path.join(d, f"shot_{i:04d}.pt"), "wb").close()
st = {"done": [0, 1, 2], "sig": {"0": "a", "1": "b", "2": "c"}, "gen": {"0": "g0", "1": "g1", "2": "g2"},
"from": {"1": "g0", "2": "g1"}, "cur": {"sigs": ["a", "b", "c"], "prev": [False, True, False]}}
self.assertEqual(SL.shot_status(st, d), [True, True, True])
st["gen"]["0"] = "new" # shot 1 was redone: shot 2 continues from it
self.assertEqual(SL.shot_status(st, d), [True, False, True]) # shot 3 does not continue (cut)
st["cur"]["sigs"][2] = "changed" # a setting of shot 3 changed
self.assertEqual(SL.shot_status(st, d), [True, False, False])
def test_join_can_write_an_mp4(self):
import cv2
d = tempfile.mkdtemp(dir=_TMP)
imgs = [torch.full((22, 32, 32, 3), 0.2), torch.full((39, 32, 32, 3), 0.8)]
shots = [_shot(0, 0, 20, gen=22), _shot(1, 20, 45, cut=True, gen=39)]
audio = {"waveform": torch.zeros(1, 2, 48000 * 3), "sample_rate": 48000}
with patch.object(SL, "_next_output_path", lambda prefix: os.path.join(d, prefix + "_00001.mp4")):
out = SL.BFSShotJoin().join(imgs, shots, [0], audio=[audio], save_to=[SL.SAVE_TO[1]],
filename_prefix=["test"])
res = out["result"]
self.assertEqual(res[0].shape[0], 45) # the output still carries the video (read back)
self.assertAlmostEqual(float(res[0][30].mean()), 0.8, places=1)
self.assertTrue(res[4].endswith("test_00001.mp4"))
self.assertEqual(out["ui"]["images"][0]["filename"], "test_00001.mp4") # shown on the node
cap = cv2.VideoCapture(res[4]); n = 0
while cap.read()[0]:
n += 1
self.assertEqual(n, 45)
class SecondPassSizeTest(unittest.TestCase):
def _upscaler(self, w, h, mode, val, align=32):
# Minimax H3 Latent Upscaler (3D), from a latent of w/16 x h/16 cells
if mode == "scale": tw, th = w * val, h * val
elif mode == "dims": tw, th = val
else:
th = (val * 1024 * 1024 / (w / h)) ** 0.5; tw = th * (w / h)
tw, th = round(tw / align) * align, round(th / align) * align
return int(round(tw / 16) * 16), int(round(th / 16) * 16)
def test_matches_the_latent_upscaler(self):
for w, h in ((832, 480), (480, 832), (1024, 576), (704, 704)):
self.assertEqual(SL.second_pass_size(w, h, {"mode": "scale", "scale": 2.0}), self._upscaler(w, h, "scale", 2.0))
self.assertEqual(SL.second_pass_size(w, h, {"mode": "scale", "scale": 1.5, "align": 64}),
self._upscaler(w, h, "scale", 1.5, 64))
self.assertEqual(SL.second_pass_size(w, h, {"mode": "megapixels", "megapixels": 1.0}),
self._upscaler(w, h, "mega", 1.0))
self.assertEqual(SL.second_pass_size(832, 480, {"mode": "dims", "width": 1280, "height": 704}), (1280, 704))
self.assertEqual(SL.second_pass_size(832, 480, None), (1664, 960)) # defaults: x2, align 32
class JoinWithoutFfmpegTest(unittest.TestCase):
def test_mp4_mode_works_without_the_ffmpeg_program(self):
# issue #12: Windows portable, no ffmpeg on PATH, long video -> the RAM guard picks the mp4 mode
d = tempfile.mkdtemp(dir=_TMP)
imgs = [torch.full((22, 32, 32, 3), 0.2), torch.full((39, 32, 32, 3), 0.8)]
shots = [_shot(0, 0, 20, gen=22), _shot(1, 20, 45, cut=True, gen=39)]
audio = {"waveform": torch.zeros(1, 2, 48000 * 3), "sample_rate": 48000}
with patch.dict(SL.VIO._BIN, {"ffmpeg": None, "ffprobe": None}), \
patch.object(SL, "_ram_available", lambda: 1000), \
patch.object(SL, "_next_output_path", lambda prefix: os.path.join(d, prefix + "_00001.mp4")):
out = SL.BFSShotJoin().join(imgs, shots, [0], audio=[audio], comparison=[True])
res = out["result"]
self.assertTrue(os.path.getsize(res[4]) > 0 and os.path.exists(res[4][:-4] + "_comparison.mp4"))
self.assertEqual((res[0].shape[0], res[3].shape[0], res[5].shape[0]), (45, 45, 45))
self.assertAlmostEqual(float(res[0][30].mean()), 0.8, delta=0.05)
class JoinFileMetadataTest(unittest.TestCase):
def test_mp4_carries_prompt_and_workflow(self):
import json, subprocess
d = tempfile.mkdtemp(dir=_TMP)
wf = {"nodes": [{"id": 1, "widgets_values": ["a=b; c#d \\ e\nnew line"]}]}
pr = {"1": {"class_type": "BFSShotPlanner", "inputs": {"plan": "{}"}}}
with patch.object(SL, "_next_output_path", lambda prefix: os.path.join(d, prefix + "_00001.mp4")):
out = SL.BFSShotJoin().join([torch.rand(22, 32, 32, 3)], [_shot(0, 0, 20, gen=22)], [0], save_to=[SL.SAVE_TO[1]],
filename_prefix=["m"], prompt=[pr], extra_pnginfo=[{"workflow": wf}])
tags = json.loads(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format_tags", "-of", "json",
out["result"][4]], capture_output=True).stdout)["format"]["tags"]
self.assertEqual((json.loads(tags["workflow"]), json.loads(tags["prompt"])), (wf, pr))
class JoinRamGuardTest(unittest.TestCase):
def test_images_join_falls_back_to_mp4_when_ram_is_short(self):
d = tempfile.mkdtemp(dir=_TMP)
imgs = [torch.full((22, 32, 32, 3), 0.2), torch.full((39, 32, 32, 3), 0.8)]
shots = [_shot(0, 0, 20, gen=22), _shot(1, 20, 45, cut=True, gen=39)]
with patch.object(SL, "_ram_available", lambda: 1000), \
patch.object(SL, "_next_output_path", lambda prefix: os.path.join(d, prefix + "_00001.mp4")):
out = SL.BFSShotJoin().join(imgs, shots, [0], comparison=[True])
res = out["result"]
self.assertTrue(os.path.exists(res[4]) and os.path.exists(res[4][:-4] + "_comparison.mp4"))
self.assertEqual(res[0].shape[0], 45) # never an empty output node
self.assertTrue(res[0].shape[1] < 32 and res[3].shape[0] == 45) # scaled down to the (tiny) free RAM
self.assertTrue(os.path.exists(res[4][:-4] + "_guide.mp4") and res[5].shape[0] == 45)
normal = SL.BFSShotJoin().join(imgs, shots, [0]) # enough RAM: images as before
self.assertEqual((normal[0].shape[0], normal[4]), (45, ""))
def test_join_bytes(self):
self.assertEqual(SL._join_bytes(10, 100, 200, False, 360), 10 * 100 * 200 * 12)
self.assertGreater(SL._join_bytes(10, 100, 200, True, 1024), SL._join_bytes(10, 100, 200, True, 50))
class StoredComparisonTest(unittest.TestCase):
def test_originals_go_to_a_video_not_the_pt(self):
rid = "cmp_" + str(id(self))
y = torch.linspace(0, 1, 64).view(1, 64, 1, 1)
src = [((torch.sin(torch.arange(n).view(n, 1, 1, 1) + 7 * y) + 1) / 2).expand(n, 64, 64, 3).clone() for n in (22, 39)]
shots = [dict(_shot(0, 0, 20, count=2, queue=True, run_id=rid, gen=22), frames=src[0]),
dict(_shot(1, 20, 45, cut=True, count=2, queue=True, run_id=rid, gen=39), frames=src[1])]
SL.BFSShotJoin().join([src[0] * 0.5], [shots[0]], [0], comparison=[True], comparison_height=[64])
out = SL.BFSShotJoin().join([src[1] * 0.5], [shots[1]], [0], comparison=[True], comparison_height=[64])
d = SL.run_dir(rid)
self.assertLess(os.path.getsize(os.path.join(d, "shot_0000.pt")), 50_000) # info only
self.assertTrue(os.path.exists(os.path.join(d, "shot_0000_orig.mkv")))
video, comp = out[0], out[3]
self.assertEqual(video.shape[0], 45)
self.assertEqual(comp.shape[0], 45)
# the original column (left) shows the source, not the dark filler of a missing original
self.assertGreater(float(comp[:, comp.shape[1] // 2, 5:60].mean()), 0.2)
guide = out[5] # the stored originals, aligned
self.assertEqual(guide.shape[0], 45)
self.assertAlmostEqual(float(guide[:20].mean()), float(src[0][:20].mean()), delta=0.03)
self.assertAlmostEqual(float(guide[20:].mean()), float(src[1][:25].mean()), delta=0.03)
class ColorAnchorTest(unittest.TestCase):
def _shot(self, sat):
src = torch.full((12, 48, 48, 3), 0.5)
res = src.clone()
res[:, 12:36, 12:36] = torch.tensor([0.5 - sat, 0.5 + sat, 0.5 - sat]) # a green subject
res[:, 12:36, 12:36] += torch.linspace(-0.05, 0.05, 24).view(1, 24, 1, 1)
return res, src
def test_subject_chroma_goes_back_to_the_anchor(self):
r1, s1 = self._shot(0.15)
anchor = SL.color_stats(r1, s1)
r4, s4 = self._shot(0.30) # 4 shots later: twice as saturated
out, before = SL.anchor_colors(r4, s4, anchor)
after = SL.color_stats(out, s4)
self.assertGreater(abs(before["mu"][0] - anchor["mu"][0]), 5)
self.assertLess(abs(after["mu"][0] - anchor["mu"][0]), 1.5)
self.assertLess(abs(after["mu"][1] - anchor["mu"][1]), 1.5)
self.assertAlmostEqual(float(out[0, 2, 2, 0]), 0.5, places=2) # the background is untouched
self.assertIsNone(SL.color_stats(s1, s1)) # no subject: nothing to anchor
class TidyDetailsTest(unittest.TestCase):
def test_short_details_drop_the_echoed_prompt(self):
cfg = {"describe_preset": "short"}
for raw in ("is the elderly woman with glasses shown in", "elderly woman with glasses is the shown in <Picture 1>",
"<Subject 1> is the elderly woman with glasses shown in <Picture 1>.", "The elderly woman with glasses"):
self.assertEqual(SL.tidy_details(raw, cfg), "elderly woman with glasses", raw)
self.assertEqual(SL.tidy_details("man shown wearing a red cap", cfg), "man shown wearing a red cap")
# descriptions already saved in a plan are cleaned when they go into the prompt
self.assertEqual(SL.clean_slot("is the woman in red shown in <Picture 1>"), "woman in red")
class FillDetailsTest(unittest.TestCase):
def test_render_node_fills_details_with_its_vlm(self):
-107
View File
@@ -1,107 +0,0 @@
from __future__ import annotations
import importlib.util
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
import torch
ROOT = Path(__file__).resolve().parents[1]
_TMP = tempfile.mkdtemp(prefix="bfs_compare_test_")
_fp = types.ModuleType("folder_paths")
_fp.get_temp_directory = lambda: _TMP
sys.modules.setdefault("folder_paths", _fp)
SPEC = importlib.util.spec_from_file_location("bfs_video_compare", ROOT / "bfs_video_compare.py")
VC = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(VC)
class DifferenceTest(unittest.TestCase):
def test_equal_frames_are_black_and_score_zero(self):
x = torch.rand(3, 8, 8, 3)
m, mae, mse = VC.difference(x, x.clone())
self.assertEqual(float(m.abs().max()), 0.0)
self.assertEqual(float(mae.max()), 0.0)
self.assertEqual(VC.psnr(float(mse.mean())), float("inf"))
def test_map_lights_up_only_where_it_changed(self):
g = torch.zeros(1, 8, 8, 3)
t = g.clone()
t[:, :4, :4] = 1.0
m, mae, _ = VC.difference(g, t, gain=1.0)
self.assertEqual(m[0, 0, 0].tolist(), [1.0, 1.0, 1.0]) # fully different: white
self.assertEqual(m[0, 7, 7].tolist(), [0.0, 0.0, 0.0]) # unchanged: black
self.assertAlmostEqual(float(mae[0]), 255 / 4, places=3) # a quarter of the frame
def test_heat_scale(self):
self.assertEqual(VC.heat(torch.tensor(1 / 3)).tolist(), [1.0, 0.0, 0.0])
self.assertEqual(VC.heat(torch.tensor(2 / 3)).tolist()[:2], [1.0, 1.0])
class CompareNodeTest(unittest.TestCase):
def test_compares_at_the_guide_size_and_writes_three_previews(self):
import cv2
guide = torch.rand(20, 32, 48, 3)
target = torch.rand(18, 64, 96, 3) # bigger and shorter, like the Join's images vs its guide
audio = {"waveform": torch.zeros(1, 2, 48000), "sample_rate": 48000}
out = VC.BFSVideoCompare().compare(guide, target, fps=24.0, audio=audio, preview_height=32)
diff, report, mean = out["result"]
self.assertEqual(tuple(diff.shape), (18, 32, 48, 3))
self.assertGreater(mean, 0)
self.assertIn("lengths differ", report)
self.assertIn("target scaled from 96x64 to 48x32", report)
ui = out["ui"]["bfs_compare"][0]
self.assertEqual((ui["frames"], len(ui["mae"]), ui["width"], ui["height"]), (18, 18, 48, 32))
for k in ("guide", "target", "difference"):
path = os.path.join(_TMP, ui[k]["subfolder"], ui[k]["filename"])
cap = cv2.VideoCapture(path)
n = 0
while cap.read()[0]:
n += 1
self.assertEqual(n, 18, k)
class NoFfmpegTest(unittest.TestCase):
"""Windows portable installs often have no ffmpeg program: everything must go through PyAV."""
def setUp(self):
self._saved = dict(VC.VIO._BIN)
VC.VIO._BIN.update(ffmpeg=None, ffprobe=None)
def tearDown(self):
VC.VIO._BIN.clear()
VC.VIO._BIN.update(self._saved)
def test_writer_and_reader_work_without_ffmpeg(self):
import av
path = os.path.join(_TMP, "noffmpeg.mp4")
w = VC.VIO.Mp4Writer(path, 33, 21, 24.0, {"waveform": torch.zeros(1, 2, 48000), "sample_rate": 48000},
metadata={"prompt": {"1": 2}})
yy, xx = torch.meshgrid(torch.linspace(0, 1, 21), torch.linspace(0, 1, 33), indexing="ij")
x = torch.stack([torch.stack([xx, yy, (xx + yy + t / 30) % 1], -1) for t in range(30)]) # smooth, like video
w.write(x[:16])
w.write(x[16:])
w.close()
with av.open(path) as c:
self.assertEqual(len(c.streams.audio), 1)
self.assertEqual((c.streams.video[0].codec_context.width, c.streams.video[0].codec_context.height), (34, 22))
self.assertEqual(c.metadata.get("prompt"), '{"1": 2}')
frames, s = VC.VIO.read_video_scaled(path, 1e9)
self.assertEqual((tuple(frames.shape), s), ((30, 22, 34, 3), 1.0))
self.assertLess(float((frames[:, :21, :33] - x).abs().mean()), 0.05) # lossy, but the same pictures
small, s = VC.VIO.read_video_scaled(path, 30 * 22 * 34 * 3 * 4 / 4) # a quarter of the bytes
self.assertLess(small.shape[1], 22)
def test_compare_node_without_ffmpeg(self):
out = VC.BFSVideoCompare().compare(torch.rand(10, 32, 48, 3), torch.rand(10, 32, 48, 3), preview_height=32)
ui = out["ui"]["bfs_compare"][0]
self.assertTrue(os.path.getsize(os.path.join(_TMP, ui["guide"]["subfolder"], ui["guide"]["filename"])) > 0)
if __name__ == "__main__":
unittest.main()
-94
View File
@@ -1,94 +0,0 @@
"""Assemble the shots a BFS Shot Planner run stored on disk into one MP4, one shot at a time (low RAM).
For a run whose final join did not finish (ComfyUI killed, out of memory...). Every shot is stored by BFS Shot Join as
<run folder>/shot_0000.pt (+ shot_0000.mkv), shot_0001.pt ... already pasted back / uncropped, without its continuity head; this reads
them in order, trims each to its length, cross-fades joins that are not camera cuts (like BFS Shot Join), and streams
the frames to ffmpeg, so memory stays at about one shot whatever the video length.
python bfs_assemble_shots.py <run folder> out.mp4 [--audio source.mp4] [--crossfade 4] [--crf 17]
--audio: the source video; its sound is cut to the stored shots' time range (shot 1's start to the last shot's end).
Shots that did not run (disabled / filtered) are not stored, so they are not in the output.
"""
from __future__ import annotations
import argparse
import glob
import os
import subprocess
import sys
import torch
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("run", help="the run folder with shot_0000.pt ... (ComfyUI output/bfs_shotloop/<run id>; "
"temp/bfs_shotloop/<run id> before 1.104)")
ap.add_argument("out", help="output .mp4")
ap.add_argument("--audio", help="source video to take the sound from", default="")
ap.add_argument("--crossfade", type=int, default=4, help="frames blended at joins that are not cuts (0 = hard)")
ap.add_argument("--crf", type=int, default=17)
a = ap.parse_args()
files = sorted(glob.glob(os.path.join(a.run, "shot_*.pt")))
if not files:
print(f"no shot_*.pt in {a.run}", file=sys.stderr)
return 1
metas = [torch.load(f, map_location="cpu", weights_only=False)["shot"] for f in files[:1]]
m0 = metas[0]
count = int(m0.get("count") or len(files))
idx = sorted(int(os.path.basename(f)[5:9]) for f in files)
missing = [i for i in range(count) if i not in idx]
if missing:
print(f"warning: shots {[i + 1 for i in missing]} of {count} are not stored; the video skips them", file=sys.stderr)
fps = float(m0.get("fps") or 24.0)
proc, prev_tail, first_start, last_end, n_out = None, None, None, None, 0
for f in files:
d = torch.load(f, map_location="cpu", weights_only=False)
meta = d["shot"]
if "frames" in d: # raw frames (older runs, Shot storage = raw)
frames = d["frames"] # uint8 [N, H, W, 3]
else: # the frames are in shot_NNNN.mkv next to the .pt
n, h, w, _ = d["shape"]
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", os.path.join(a.run, d["video"]), "-f", "rawvideo",
"-pix_fmt", "rgb24", "-"], capture_output=True, check=True).stdout
frames = torch.frombuffer(bytearray(raw[:n * h * w * 3]), dtype=torch.uint8).view(n, h, w, 3)
L = int(meta["length"])
body = frames[:L]
if body.shape[0] < L: # shorter than planned: hold the last frame
body = torch.cat([body, body[-1:].expand(L - body.shape[0], -1, -1, -1)], 0)
if proc is None:
H, W = int(body.shape[1]), int(body.shape[2])
cmd = ["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}",
"-r", f"{fps:g}", "-i", "-"]
if a.audio:
t0 = float(meta["start"]) / fps
cmd += ["-ss", f"{t0:.4f}", "-i", a.audio, "-map", "0:v", "-map", "1:a?", "-c:a", "aac", "-shortest"]
cmd += ["-c:v", "libx264", "-crf", str(a.crf), "-pix_fmt", "yuv420p",
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", a.out]
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
elif body.shape[1:3] != (H, W):
body = torch.nn.functional.interpolate(body.permute(0, 3, 1, 2).float(), size=(H, W), mode="bilinear",
align_corners=False).round().clamp(0, 255).byte().permute(0, 2, 3, 1)
if a.crossfade > 0 and prev_tail is not None and not meta.get("cut_before"):
n = min(a.crossfade, prev_tail.shape[0], body.shape[0])
if n > 0:
w = torch.linspace(0, 1, n + 2)[1:-1].view(-1, 1, 1, 1)
body = body.clone()
body[:n] = (prev_tail[:n].float() * (1 - w) + body[:n].float() * w).round().byte()
proc.stdin.write(body.contiguous().numpy().tobytes())
prev_tail = frames[L:].clone() if frames.shape[0] > L else None
first_start = meta["start"] if first_start is None else first_start
last_end, n_out = meta["end"], n_out + L
print(f"shot {int(meta['index']) + 1}/{count}: {L} frames", flush=True)
del d, frames, body
proc.stdin.close()
proc.wait()
print(f"{a.out}: {n_out} frames ({n_out / fps:.2f} s at {fps:g} fps)")
return proc.returncode
if __name__ == "__main__":
sys.exit(main())
+47 -167
View File
@@ -12,10 +12,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 } from "./vendor/vue.esm-browser.prod.mjs";
import { h, rawH, t, uiLanguage, setUiLanguage, DEFAULTS, MASK_DEFAULTS, VLM_DEFAULTS, snapUp, snapDown, segKey, hasMask, pill } from "./shotloop/common.js";
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";
// history lengths H3's VAE encodes whole (17k+5); old plans saved 17/34/51, read as the next valid one up
const historyLen = n => (n = Number(n) || 22) <= 5 ? 5 : 5 + Math.ceil((n - 5) / 17) * 17;
import { timelineCard } from "./shotloop/view_timeline.js";
import { videoTab } from "./shotloop/view_video.js";
import { shotsTab } from "./shotloop/view_shots.js";
@@ -35,7 +33,7 @@ const store = { // per-browser UI conveniences (never the plan itself)
set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } },
};
const DEFAULT_W = 1180, MIN_W = 1040, MIN_H = 1000; // node size: wide enough for the four-column settings
const TABS = [["video", "🎞 Video"], ["shots", "🎬 Shots"], ["people", "👥 People / Filters / Masks"], ["prompts", "📝 Prompts & refs"], ["run", "▶ Run"]];
const TABS = [["video", "🎞 Video"], ["shots", "🎬 Shots"], ["people", "👥 People & masks"], ["prompts", "📝 Prompts & refs"], ["run", "▶ Run"]];
function Panel(io) {
const plan = reactive({ ...DEFAULTS });
@@ -56,19 +54,13 @@ function Panel(io) {
watch(sel, () => nextTick(() => rootEl.value?.querySelector(".shots .sc.sel")?.scrollIntoView({ block: "nearest", inline: "nearest" })));
const hover = ref(null); // {frame, x}
const prog = reactive({ done: 0, count: 0 });
const cache = reactive({ ok: [], stored: [], mb: 0, folder: "", temp: false, combined: null });
// what the server learned about memory on this GPU (BFS Shot Join records every shot it receives)
const vram = reactive({ gpu: "", total_gb: 0, free_gb: 0, max_tokens: 0, max_peak_gb: 0, runs: 0, ram_free_gb: 0 });
async function refreshVram() {
try { const r = await api.fetchApi("/bfs/shotloop/vram"); const j = await r.json(); if (!j.error) Object.assign(vram, j); } catch { /* optional */ }
}
const tlEl = ref(null);
const stats = ref([]); // per-shot content stats from /bfs/shotloop/filters
const people = ref([]); // cast from /bfs/shotloop/cast: [{id, thumb, share, first, last}]
const maskPrev = ref({}); // "start-end" -> {frames, box, coverage, empty} from /bfs/shotloop/mask
const showMasks = ref(true); // overlay the mask preview on the shot cards
const vlmSug = ref({}); // "start-end" -> {segment, shot, people, recommend, reason} from the VLM
const modal = reactive({ open: false, idx: -1, key: 0, points: [], text: "", region: 1, groups: {}, prev: null, busy: "" });
const modal = reactive({ open: false, idx: -1, key: 0, points: [], text: "", prev: null, busy: "" });
const segPeople = ref({}); // "start-end" -> {people, main} from /bfs/shotloop/plan
const targetCrop = ref({}); // "start-end" -> data URL of the subject the description was made from
const tab = ref(store.get("bfs.shotloop.tab", "shots"));
@@ -90,12 +82,8 @@ function Panel(io) {
plan.ref_details = { ...(p.ref_details || {}) };
if (!Array.isArray(plan.bounds)) plan.bounds = [];
if (!Array.isArray(plan.segs)) plan.segs = [];
Object.values(plan.cast).forEach(person => { if (person && typeof person === "object") { delete person.ref; delete person.ref2; delete person.guide_color; } });
plan.segs.forEach(meta => { if (meta) { delete meta.color_guide_person; delete meta.color_guide_person2; } });
};
// any change of the plan (a new video, a prompt, a setting...) is a new loop run on the server: show its progress
let progTimer = 0;
const save = () => { io.setPlan(JSON.stringify(plan)); clearTimeout(progTimer); progTimer = setTimeout(() => progress(), 400); };
const save = () => io.setPlan(JSON.stringify(plan));
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
@@ -121,27 +109,22 @@ 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 || "head", chainFrame: m.chain_frame || "first", chainHistory: historyLen(m.chain_history),
color_guide: !!m.color_guide, color_guide_color: m.color_guide_color || "",
color_guide_opacity: m.color_guide_opacity ?? 0.35,
color_guide_text2: m.color_guide_text2 || "", color_guide_color2: m.color_guide_color2 || "",
color_guide_points2: m.color_guide_points2 || [], color_guide_key2: m.color_guide_key2,
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;
});
// shots on the timeline before "Max shots" keeps the first ones: more than segs means some are left out
const allSegCount = computed(() => {
if (!n.value) return 0;
const { lo, hi } = range.value;
const b = [lo, ...plan.bounds.filter(x => x > lo && x < hi), hi].sort((a, c) => a - c);
return b.reduce((k, x, i) => k + (i && x > b[i - 1] ? 1 : 0), 0);
});
const whoIn = s => segPeople.value[segKey(s)] || null;
const castOf = id => plan.cast[String(id)] || {};
const included = id => !!personOf(id) && !castOf(id).ignore;
const linked = id => !!castOf(id).ref && !castOf(id).ignore;
// reference a shot gets from its main person when it has none of its own
const castRef = (s, field) => {
if (!plan.cast_assign) return "";
const who = whoIn(s); if (!who || who.main < 0 || castOf(who.main).ignore) return "";
return castOf(who.main)[field] || "";
};
const personOf = id => people.value.find(p => p.id === id) || null;
const statFor = s => stats.value.find(x => x.start === s.start && x.end === s.end) || null;
const filtersOn = computed(() => { const f = plan.filters; return !!(f.person || f.face || f.skip_dark || f.skip_static || f.max_persons > 0 || f.min_frames > 0); });
@@ -152,7 +135,7 @@ function Panel(io) {
const why = filtersOn.value ? (statFor(s)?.skip_reason || "") : "";
if (why) return why;
const who = whoIn(s);
if (plan.cast_only && who && !who.people.some(p => included(p))) return "no detected person";
if (plan.cast_only && who && !who.people.some(p => linked(p))) return "no linked person";
return "";
};
const active = computed(() => segs.value.filter(s => !skipWhy(s)));
@@ -172,45 +155,14 @@ const modeOf = s => (MODES.find(m => m[2] === !!s.crop && m[3] === !!s.inpaint &
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]; };
// the reference a shot uses: its own or the global one
const refOf = (s, k) => s[k] || plan["global_" + k] || "";
// the reference a shot really uses: its own, its main person's, or the global one
const refOf = (s, k) => s[k] || castRef(s, k) || plan["global_" + k] || "";
const promptOf = s => s.prompt || plan.global_prompt || "";
const detailsOf = s => {
const value = plan.ref_details?.[`${refOf(s, "ref")}|${refOf(s, "ref2")}`] || "";
return typeof value === "object" ? Object.values(value).some(Boolean) : !!value;
};
const detailsOf = s => plan.ref_details?.[`${refOf(s, "ref")}|${refOf(s, "ref2")}`] || "";
// things worth fixing before a run, per shot (only shots that run)
// ---- memory status: each shot's size (latent tokens = generated frames x resolution) against the biggest shot that
// already ran on this GPU. Not a GB figure: ComfyUI offloads weights as needed, so only what ran here is a reference.
const H3G = "H3 (17n+5)";
const headOf = (s, i) => plan.run !== "auto" && s.chain === "head" && i > 0 && !s.cut && (plan.grid || H3G) === H3G
? Math.min(s.chainHistory || 22, 39) : 0;
const shotTokens = (s, i) => {
const hd = headOf(s, i), gen = hd ? snapUp(s.len + hd, plan.grid) : s.gen;
const steps = (plan.grid || H3G) === H3G ? 5 * Math.ceil(gen / 17) - 3 : Math.ceil(gen / 4);
return Math.max(1, steps) * Math.ceil((size.h || 0) / 16) * Math.ceil((size.w || 0) / 16);
};
const memory = computed(() => {
const list = segs.value.map((s, i) => ({ i, tok: skipWhy(s) ? 0 : shotTokens(s, i), gen: s.gen + headOf(s, i) })).filter(x => x.tok > 0);
const top = list.reduce((a, b) => (b.tok > a.tok ? b : a), { i: -1, tok: 0, gen: 0 });
const ratio = vram.max_tokens ? top.tok / vram.max_tokens : 0;
const level = !top.tok || !size.w ? "" : !vram.max_tokens ? "info" : ratio <= 1.05 ? "ok" : ratio <= 1.3 ? "note" : "warn";
return { list, top, ratio, level };
});
// the final video as images (BFS Shot Join, save_to = images) is held in RAM whole: about 12 bytes per pixel per frame,
// ~3.5x more with the comparison. Long videos should be written straight to a file instead.
const joinRam = computed(() => {
const frames = segs.value.reduce((t, s) => t + (skipWhy(s) && plan.skip_fill === "drop" ? 0 : s.len), 0);
const gb = frames * (size.w || 0) * (size.h || 0) * 12 / 2 ** 30;
return { frames, gb, long: vram.ram_free_gb > 0 && gb * 3.5 > 0.6 * vram.ram_free_gb };
});
const checks = computed(() => {
const out = [];
if (joinRam.value.long && active.value.length)
out.push({ lvl: "warn", shot: 0, text: `long video: as images the final video needs ~${joinRam.value.gb.toFixed(1)} GB of RAM `
+ `(~${(joinRam.value.gb * 3.5).toFixed(1)} GB with the comparison) and ${vram.ram_free_gb} GB is free. Set BFS Shot Join `
+ `save_to = mp4 file: it writes the video straight to disk, shows it on the node and still gives the result to the next nodes` });
segs.value.forEach((s, i) => {
if (skipWhy(s)) return;
const add = (lvl, text) => out.push({ lvl, shot: i, text });
@@ -221,15 +173,11 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
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 (/\{details2?\}/.test(p) && !detailsOf(s) && !plan.vlm_cfg?.enabled) add("info", "the prompt uses {details} but its references have no description yet");
if (/\{details2?\}/.test(p) && (plan.vlm_cfg?.describe_preset || "short") !== "short")
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)");
if (vram.max_tokens && shotTokens(s, i) > 1.3 * vram.max_tokens)
add("warn", "much bigger than any shot that ran on this GPU (over 1.3×): it may run out of memory. Lower Megapixels or Max seconds / shot, use head 5, or set Between shots = free memory (Run tab)");
if (i > 0 && plan.run === "auto" && (s.chain === "head" || s.chain === "history") && !s.cut)
add("warn", `continuity '${s.chain}' does not work in the auto loop (the previous result does not exist yet): use Run = one run, shot by shot`);
});
return out;
});
@@ -267,11 +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 || "head", chain_frame: keep[i]?.chain_frame || "first", chain_history: historyLen(keep[i]?.chain_history),
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_text2: keep[i]?.color_guide_text2 || "", color_guide_color2: keep[i]?.color_guide_color2 || "",
color_guide_points2: keep[i]?.color_guide_points2 || [], color_guide_key2: keep[i]?.color_guide_key2 ?? 0 }));
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); }
@@ -300,6 +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 === "ref" || k === "ref2") remember(v);
save();
};
async function describeTarget(i) {
@@ -333,32 +282,13 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
if (s && maskPrev.value[segKey(s)]) { const mp = { ...maskPrev.value }; delete mp[segKey(s)]; maskPrev.value = mp; }
};
const setMaskCfg = (k, v) => { plan.mask_cfg = { ...plan.mask_cfg, [k]: v }; save(); maskPrev.value = {}; };
const openPoints = (i, region = 1) => {
const openPoints = i => {
const s = segs.value[i]; if (!s) return;
const g1 = { key: s.mask.key ?? Math.floor(s.len / 2), points: [...(s.mask.points || [])], text: s.mask.text || "" };
const g2 = { key: g1.key, points: [...(s.color_guide_points2 || [])], text: s.color_guide_text2 || "" };
Object.assign(modal, { open: true, idx: i, region: s.color_guide ? region : 1, groups: { 1: g1, 2: g2 },
key: (region === 2 && s.color_guide ? g2 : g1).key, points: [...((region === 2 && s.color_guide ? g2 : g1).points)],
text: (region === 2 && s.color_guide ? g2 : g1).text, prev: null, busy: "" });
};
const switchPointRegion = region => {
const old = modal.groups[modal.region] || {};
modal.groups[modal.region] = { key: modal.key, points: [...modal.points], text: modal.text };
const next = modal.groups[region] || { key: modal.key, points: [], text: "" };
modal.region = region; modal.points = [...next.points]; modal.text = next.text; modal.prev = null;
};
const commitPointGroups = i => {
const m = meta(i), g1 = modal.groups[1] || {}, g2 = modal.groups[2] || {};
m.mask = { ...(m.mask || {}), points: [...(modal.region === 1 ? modal.points : g1.points || [])],
key: modal.region === 1 ? modal.key : g1.key, text: modal.region === 1 ? modal.text : g1.text };
if (segs.value[i]?.color_guide) {
m.color_guide_points2 = [...(modal.region === 2 ? modal.points : g2.points || [])];
m.color_guide_key2 = modal.region === 2 ? modal.key : (g1.key ?? modal.key);
m.color_guide_text2 = modal.region === 2 ? modal.text : g2.text || "";
}
Object.assign(modal, { open: true, idx: i, key: s.mask.key ?? Math.floor(s.len / 2), points: [...(s.mask.points || [])],
text: s.mask.text || "", prev: null, busy: "" });
};
const savePoints = () => {
const i = modal.idx; commitPointGroups(i); save();
const m = meta(modal.idx); m.mask = { ...(m.mask || {}), points: modal.points, key: modal.key, text: modal.text }; save();
if (modal.prev) maskPrev.value = { ...maskPrev.value, [segKey(segs.value[modal.idx])]: modal.prev };
modal.open = false;
};
@@ -373,11 +303,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
});
maskPrev.value = {}; save();
};
const savePointsAll = () => {
if (segs.value[modal.idx]?.color_guide) { commitPointGroups(modal.idx); save(); }
else pointsTo(modal.points, modal.key, modal.text, modal.idx);
modal.open = false;
};
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 = () => {
@@ -395,15 +321,14 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
};
const clearTrack = () => { plan.track = null; maskPrev.value = {}; save(); };
const clearMask = i => {
const m = meta(i); m.mask = {}; m.color_guide_points2 = []; m.color_guide_key2 = 0; m.color_guide_text2 = "";
m.crop = false; m.inpaint = false; m.paste = 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", mask_video: "mask video", crop: "mode",
target: "{target}", chain: "continuity", color_guide: "Colored Mark" };
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);
@@ -417,9 +342,9 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
if (fields.target) m.target = src.target;
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_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_points2 = [...(src.color_guide_points2 || [])]; m.color_guide_key2 = src.color_guide_key2;
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; }
});
@@ -438,12 +363,13 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
} catch (e) { error.value = String(e.message || e); }
busy.value = "";
}
// reference sets used by the plan (global and per shot): one description each
// reference sets used by the plan (global, every shot's, every cast person's): one description each
const refSets = computed(() => {
const out = new Map();
const add = (a, b, where) => { if (!a && !b) return; const k = `${a || ""}|${b || ""}`; if (!out.has(k)) out.set(k, { a, b, where: [] }); out.get(k).where.push(where); };
add(plan.global_ref, plan.global_ref2, "global");
segs.value.forEach((x, i) => add(x.ref || plan.global_ref, x.ref2 || plan.global_ref2, `#${i + 1}`));
Object.entries(plan.cast || {}).forEach(([id, c]) => { if (!c.ignore) add(c.ref, c.ref2, `person ${id}`); });
return [...out.entries()].map(([key, v]) => ({ key, ...v }));
});
async function describeRefs() {
@@ -530,33 +456,12 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
inp.onchange = () => inp.files[0] && upload(inp.files[0], cb); inp.click();
};
async function progress(reset = false) {
if (!plan.video || plan.run === "auto") { prog.done = 0; prog.count = 0; Object.assign(cache, { ok: [], stored: [], mb: 0 }); return; }
if (!plan.video || plan.run !== "queue") return;
try {
const r = await api.fetchApi("/bfs/shotloop/progress", { method: "POST", body: JSON.stringify({ plan: { ...plan }, plan_raw: io.getPlan(), reset }) });
const j = await r.json();
if (!j.error) { prog.done = j.done; prog.count = j.count || active.value.length; Object.assign(cache, j); }
const j = await r.json(); if (!j.error) { prog.done = j.done; prog.count = j.count || active.value.length; }
} catch { /* ignore */ }
}
// ---- stored shots: redo some, or combine what is stored into an MP4 (low memory, on the server)
async function redo(shots, cascade = true) {
try {
const r = await api.fetchApi("/bfs/shotloop/redo", { method: "POST", body: JSON.stringify({ plan: { ...plan }, shots, cascade }) });
const j = await r.json(); if (j.error) error.value = j.error; else { Object.assign(cache, j); prog.done = j.done; }
} catch (e) { error.value = `${e}`; }
}
async function combine() {
busy.value = "Combining the stored shots into an MP4";
try {
const r = await api.fetchApi("/bfs/shotloop/combine", { method: "POST", body: JSON.stringify({ plan: { ...plan },
crossfade: plan.combine_crossfade ?? 4, crf: plan.combine_crf ?? 17 }) });
const j = await r.json();
if (j.error) error.value = j.error;
else cache.combined = { ...j, url: api.apiURL(`/view?filename=${encodeURIComponent(j.filename)}&subfolder=${encodeURIComponent(j.subfolder)}&type=output`) };
} catch (e) { error.value = `${e}`; }
busy.value = "";
}
// a shot of the panel (any, skipped ones too) -> its cache state: "ok" stored and up to date, "run" runs next time
const cacheOf = s => { const k = active.value.indexOf(s); return k < 0 || !cache.ok.length ? "" : cache.ok[k] ? "ok" : "run"; };
// ---- editing
const setVideo = v => {
@@ -576,8 +481,6 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
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);
// a split you make by hand always shows: "Max shots" (e.g. 1 in a one-shot workflow) only limits detected shots
if (plan.max_parts > 0 && allSegCount.value >= plan.max_parts) plan.max_parts = allSegCount.value + 1;
plan.bounds = [...plan.bounds, f].sort((a, c) => a - c);
plan.segs.splice(i + 1, 0, { ...(plan.segs[i] || {}) });
sel.value = i + 1; save();
@@ -661,13 +564,13 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
const seek = f => { const v = vid.value; if (v) { v.currentTime = f / plan.fps + 1e-3; play.frame = f; } };
// ---- queue loop events
const onProg = e => { prog.done = e.detail.done; prog.count = e.detail.count; refreshVram(); };
const onProg = e => { prog.done = e.detail.done; prog.count = e.detail.count; };
const onStatus = e => { if (busy.value || modal.busy) Object.assign(status, e.detail || {}); };
let clock = 0;
const onVlm = e => { if (e.detail?.video === plan.video) mergeSug(e.detail.segs); };
const onNext = e => { onProg(e); if (plan.run === "queue" && plan.auto_continue) setTimeout(() => app.queuePrompt(0, 1), 300); };
onMounted(() => {
load(); refreshFiles(); analyze(); progress(); refreshVram(); raf = requestAnimationFrame(loop);
load(); refreshFiles(); analyze(); progress(); raf = requestAnimationFrame(loop);
if (!plan.video) tab.value = "video";
api.addEventListener("bfs-shotloop-progress", onProg); api.addEventListener("bfs-shotloop-next", onNext);
api.addEventListener("bfs-shotloop-vlm", onVlm);
@@ -677,29 +580,6 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
onBeforeUnmount(() => { cancelAnimationFrame(raf); api.removeEventListener("bfs-shotloop-progress", onProg); api.removeEventListener("bfs-shotloop-next", onNext); api.removeEventListener("bfs-shotloop-vlm", onVlm); api.removeEventListener("bfs-shotloop-status", onStatus); clearInterval(clock); });
io.expose({ reload: () => { load(); analyze(); progress(); } });
// 🧠 the memory status at the top: the plan's biggest shot against the biggest one that ran on this GPU
const kTok = n => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : `${n}`);
function memTip(m) {
const gpu = vram.gpu ? `${vram.gpu} · ${vram.total_gb} GB (${vram.free_gb} GB free now)` : "no CUDA GPU seen by the server";
const top = m.top.i >= 0 ? `Biggest shot in this plan: #${m.top.i + 1}, ${m.top.gen} frames at ${size.w}×${size.h} = ${kTok(m.top.tok)} tokens` : "";
const ref = vram.max_tokens
? `Biggest shot that ran on this GPU: ${kTok(vram.max_tokens)} tokens, peak ${vram.max_peak_gb} GB (${vram.runs} run(s) recorded)`
: "No shot has run on this GPU yet: after the first one, this status compares every shot with what already ran here.";
const say = { ok: "✓ Every shot is within what already ran on this GPU.",
note: "A bit bigger than anything that ran here: it will probably fit (ComfyUI offloads weights), maybe slower.",
warn: "⚠ Much bigger than anything that ran here: it may run out of memory. Lower Megapixels or Max seconds / shot, use head 5, or set Between shots = free memory (Run tab).",
info: "" }[m.level];
return [gpu, top, ref, say, "Size = generated frames (with the continuity head) × resolution; skipped shots are not counted."].filter(Boolean).join("\n");
}
function memPill() {
const m = memory.value;
if (!m.level) return null;
const text = { ok: "🧠 memory ✓", note: `🧠 ${m.ratio.toFixed(2)}× biggest run`, warn: `🧠 ⚠ ${m.ratio.toFixed(1)}× biggest run · may OOM`,
info: "🧠 no memory data yet" }[m.level];
return h("span", { class: ["pill", { ok: "ok", warn: "warn" }[m.level] || ""], style: "cursor:pointer", title: memTip(m),
onClick: () => { tab.value = "run"; refreshVram(); } }, text);
}
// ---- references
// the last 10 references picked, remembered across workflows (per browser)
const RECENT_KEY = "bfs.shotloop.recentRefs";
@@ -714,18 +594,19 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
};
// 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 Colored Mark settings, never to image references
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 and per shot)
// 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 forget the recent list?")) return;
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(() => {
@@ -737,10 +618,10 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
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, clearRecent, clearAllRefs,
n, range, setRange, maxLen, segs, allSegCount, active, pxPerFrame, tlWidth, filtersOn, refSets, usedRefs, checks, tour, memory, vram, refreshVram, cache, cacheOf, redo, combine,
MODES, modeOf, modeName, viewUrl, save, meta, setMeta, setMode, setPlan, whoIn, castOf, included, personOf, statFor, skipWhy, refOf, promptOf, checksFor,
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, switchPointRegion, savePoints, savePointsAll, saveTrack, clearTrack, pointsTo, clearMask, copyFrom, analyseVLM, describeRefs, setDetail, setVlmCfg,
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,
};
@@ -758,12 +639,11 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
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) }, [
rawH("option", { value: "en" }, "English"), rawH("option", { value: "pt" }, "Português"),
rawH("option", { value: "es" }, "Español"), rawH("option", { value: "zh" }, "中文"),
h("option", { value: "en" }, "English"), h("option", { value: "pt" }, "Português"),
h("option", { value: "es" }, "Español"), h("option", { value: "zh" }, "中文"),
]),
]),
memPill(),
pill({ queue: "queue loop", recursive: "shot by shot" }[plan.run] || "auto loop"),
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"),
]);
-238
View File
@@ -1,238 +0,0 @@
/**
* BFS Video Compare: one player for two videos.
*
* Wipe (drag the divider), overlay (drag to fade) or side by side, with any of guide / target / difference on top
* or below. The strip under the scrubber is the difference per frame, measured by the node on the real frames:
* click it to jump to a frame. The node sends three temporary MP4s and that list when it runs.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const SOURCES = ["guide", "target", "difference"];
const MODES = [["wipe", "wipe ↔"], ["wipe-v", "wipe ↕"], ["overlay", "overlay"], ["side", "side by side"]];
function styles() {
if (document.getElementById("bfs-vcmp-css")) return;
const el = document.createElement("style");
el.id = "bfs-vcmp-css";
el.textContent = `
.bfsvc{display:flex;flex-direction:column;gap:6px;height:100%;box-sizing:border-box;padding:6px;
font:12px/1.4 var(--font-family,system-ui,sans-serif);color:var(--descrip-text,#c9c9cf);
background:var(--comfy-input-bg,#1b1b1f);border-radius:8px}
.bfsvc .stage{position:relative;flex:1;min-height:160px;background:#0e0e11;border-radius:6px;overflow:hidden;
cursor:ew-resize;touch-action:none;user-select:none}
.bfsvc .stage.v{cursor:ns-resize}.bfsvc .stage.o{cursor:col-resize}.bfsvc .stage.s{cursor:default;display:grid;
grid-template-columns:1fr 1fr;gap:4px}
.bfsvc video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none}
.bfsvc .stage.s video{position:relative}
.bfsvc .line{position:absolute;background:#fff;box-shadow:0 0 0 1px #0008;pointer-events:none}
.bfsvc .tag{position:absolute;top:6px;padding:1px 7px;border-radius:4px;background:#000a;color:#fff;font-size:11px;
pointer-events:none}
.bfsvc .row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.bfsvc select,.bfsvc button{background:#131317;border:1px solid #34343d;color:#e6e6ea;border-radius:4px;
padding:2px 6px;font:inherit;cursor:pointer}
.bfsvc button:hover,.bfsvc select:hover{border-color:#3a86ff}
.bfsvc input[type=range]{flex:1;accent-color:#3a86ff;min-width:80px}
.bfsvc canvas{width:100%;height:26px;cursor:pointer;border-radius:3px;background:#131317;display:block}
.bfsvc .info{font-family:ui-monospace,monospace;font-size:11px;white-space:nowrap}
.bfsvc .empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#8b8b93;
text-align:center;padding:12px}
`;
document.head.appendChild(el);
}
const url = f => api.apiURL(`/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || "")}`
+ `&type=${f.type || "temp"}&t=${Date.now()}`);
function el(tag, attrs = {}, kids = []) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "style") e.style.cssText = v; else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
else e.setAttribute(k, v);
}
for (const c of [].concat(kids)) e.append(c);
return e;
}
function buildPlayer(host) {
const st = { data: null, top: "target", bottom: "guide", mode: "wipe", pos: 0.5, playing: false, muted: true, frame: 0 };
const vTop = el("video", { playsinline: "", preload: "auto", loop: "" });
const vBot = el("video", { playsinline: "", preload: "auto", loop: "" });
vTop.muted = vBot.muted = true;
const line = el("div", { class: "line" });
const tagTop = el("div", { class: "tag" }), tagBot = el("div", { class: "tag" });
const empty = el("div", { class: "empty" }, "Run the node to load the guide and the target.");
const stage = el("div", { class: "stage", tabindex: "0" }, [vBot, vTop, line, tagTop, tagBot, empty]);
const sel = (opts, val, on) => {
const s = el("select", {}, opts.map(([v, l]) => { const o = el("option", { value: v }, l); if (v === val) o.selected = true; return o; }));
s.addEventListener("change", () => on(s.value));
return s;
};
const srcOpts = () => SOURCES.map(s => [s, s === "guide" ? (st.data?.labels?.[0] || "guide") : s === "target" ? (st.data?.labels?.[1] || "target") : "difference"]);
let selTop, selBot;
const selMode = sel(MODES, st.mode, v => { st.mode = v; layout(); });
const refreshSelects = () => {
const t = sel(srcOpts(), st.top, v => { st.top = v; load(); }), b = sel(srcOpts(), st.bottom, v => { st.bottom = v; load(); });
selTop ? selTop.replaceWith(t) : null; selBot ? selBot.replaceWith(b) : null;
selTop = t; selBot = b;
};
refreshSelects();
const btnSwap = el("button", { title: "Swap top and bottom", onclick: () => { [st.top, st.bottom] = [st.bottom, st.top]; refreshSelects(); load(); } }, "⇅ swap");
const btnPlay = el("button", { title: "Play / pause (space)", onclick: () => toggle() }, "▶");
const btnPrev = el("button", { title: "Previous frame (←)", onclick: () => step(-1) }, "◀|");
const btnNext = el("button", { title: "Next frame (→)", onclick: () => step(1) }, "|▶");
const btnMute = el("button", { title: "Sound (from the target)", onclick: () => { st.muted = !st.muted; sound(); } }, "🔇");
const btnFull = el("button", { title: "Full screen", onclick: () => (document.fullscreenElement ? document.exitFullscreen() : host.requestFullscreen?.())?.catch?.(() => {}) }, "⛶");
const scrub = el("input", { type: "range", min: "0", max: "0", value: "0", step: "1" });
scrub.addEventListener("input", () => seek(+scrub.value));
const info = el("span", { class: "info" }, "");
const strip = el("canvas", { title: "Difference per frame: click to jump" });
strip.addEventListener("pointerdown", e => {
const r = strip.getBoundingClientRect(), n = st.data?.frames || 0;
if (n) seek(Math.min(n - 1, Math.max(0, Math.floor((e.clientX - r.left) / r.width * n))));
});
host.append(el("div", { class: "bfsvc" }, [
el("div", { class: "row" }, [el("span", {}, "top"), selTop, btnSwap, el("span", {}, "bottom"), selBot, selMode, btnFull]),
stage,
el("div", { class: "row" }, [btnPrev, btnPlay, btnNext, scrub, btnMute, info]),
strip,
]));
const fps = () => st.data?.fps || 24;
const frameOf = t => Math.min((st.data?.frames || 1) - 1, Math.max(0, Math.floor(t * fps() + 1e-3)));
const timeOf = f => (f + 0.5) / fps();
const videos = () => [vBot, vTop];
function sound() {
btnMute.textContent = st.muted ? "🔇" : "🔊";
for (const v of [vTop, vBot]) v.muted = true;
if (!st.muted) { const v = st.bottom === "target" ? vBot : st.top === "target" ? vTop : null; if (v) v.muted = false; }
}
function layout() {
const m = st.mode, p = st.pos;
stage.className = "stage" + (m === "wipe-v" ? " v" : m === "overlay" ? " o" : m === "side" ? " s" : "");
vTop.style.clipPath = m === "wipe" ? `inset(0 ${(1 - p) * 100}% 0 0)` : m === "wipe-v" ? `inset(0 0 ${(1 - p) * 100}% 0)` : "none";
vTop.style.opacity = m === "overlay" ? String(p) : "1";
line.style.display = m === "wipe" || m === "wipe-v" ? "block" : "none";
if (m === "wipe") line.style.cssText = `display:block;left:calc(${p * 100}% - 1px);top:0;bottom:0;width:2px`;
if (m === "wipe-v") line.style.cssText = `display:block;top:calc(${p * 100}% - 1px);left:0;right:0;height:2px`;
stage.prepend(vTop); stage.prepend(vBot); // under the divider and tags; side by side: bottom left, top right
const name = s => s === "guide" ? (st.data?.labels?.[0] || "guide") : s === "target" ? (st.data?.labels?.[1] || "target") : "difference";
tagTop.textContent = name(st.top) + (m === "overlay" ? ` ${Math.round(p * 100)}%` : "");
tagBot.textContent = name(st.bottom);
tagTop.style.cssText = m === "side" ? "right:6px" : "left:6px";
tagBot.style.cssText = m === "side" ? "left:6px" : m === "wipe-v" ? "left:6px;top:auto;bottom:6px" : "right:6px";
if (m === "overlay") tagBot.style.cssText = "right:6px";
}
function drag(e) {
if (st.mode === "side" || !st.data) return;
const r = stage.getBoundingClientRect();
st.pos = Math.min(1, Math.max(0, st.mode === "wipe-v" ? (e.clientY - r.top) / r.height : (e.clientX - r.left) / r.width));
layout();
}
stage.addEventListener("pointerdown", e => { stage.setPointerCapture(e.pointerId); drag(e); });
stage.addEventListener("pointermove", e => { if (stage.hasPointerCapture(e.pointerId)) drag(e); });
stage.addEventListener("keydown", e => {
if (e.key === " ") { toggle(); e.preventDefault(); }
else if (e.key === "ArrowLeft") { step(-1); e.preventDefault(); }
else if (e.key === "ArrowRight") { step(1); e.preventDefault(); }
});
function drawStrip() {
const d = st.data, ctx = strip.getContext("2d");
const w = strip.width = Math.max(50, strip.clientWidth * devicePixelRatio), h = strip.height = 26 * devicePixelRatio;
ctx.clearRect(0, 0, w, h);
if (!d?.mae?.length) return;
// from the least to the most different frame, so the changes along the video stand out
const mx = Math.max(...d.mae), mn = Math.min(...d.mae), span = Math.max(mx - mn, 1e-6), n = d.mae.length;
ctx.fillStyle = "#e0573a";
d.mae.forEach((v, i) => { const bh = 2 + (v - mn) / span * (h - 4); ctx.fillRect(i / n * w, h - bh, Math.max(1, w / n), bh); });
ctx.fillStyle = "#3a86ff";
ctx.fillRect(st.frame / n * w, 0, Math.max(2, w / n), h);
}
function show(f) {
st.frame = f;
scrub.value = String(f);
const d = st.data;
const t = (f / fps());
info.textContent = d ? `frame ${f}/${d.frames - 1} ${t.toFixed(2)}s diff ${(d.mae?.[f] ?? 0).toFixed(1)}/255` : "";
drawStrip();
}
function seek(f) {
if (!st.data) return;
if (st.playing) toggle();
for (const v of videos()) v.currentTime = timeOf(f);
show(f);
}
const step = k => seek(Math.min(st.data ? st.data.frames - 1 : 0, Math.max(0, st.frame + k)));
function toggle() {
if (!st.data) return;
st.playing = !st.playing;
btnPlay.textContent = st.playing ? "⏸" : "▶";
if (st.playing) { vTop.currentTime = vBot.currentTime; for (const v of videos()) v.play().catch(() => {}); tick(); }
else for (const v of videos()) v.pause();
}
function tick() { // the bottom video is the clock; the top one follows it within half a frame
if (!st.playing) return;
if (Math.abs(vTop.currentTime - vBot.currentTime) > 0.5 / fps()) vTop.currentTime = vBot.currentTime;
show(frameOf(vBot.currentTime));
requestAnimationFrame(tick);
}
function load() {
const d = st.data;
if (!d) return;
const f = st.frame, was = st.playing;
if (was) toggle();
let pending = 2;
const ready = () => { if (--pending === 0) { for (const v of videos()) v.currentTime = timeOf(f); show(f); sound(); if (was) toggle(); } };
for (const [v, s] of [[vTop, st.top], [vBot, st.bottom]]) {
v.addEventListener("loadeddata", ready, { once: true });
v.src = url(d[s]);
v.load();
}
layout();
}
return {
setData(d) {
st.data = d;
empty.style.display = "none";
scrub.max = String(Math.max(0, d.frames - 1));
refreshSelects();
st.frame = Math.min(st.frame, d.frames - 1);
load();
},
layout, drawStrip,
};
}
app.registerExtension({
name: "BFSNodes.VideoCompare",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "BFSVideoCompare") return;
const created = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = created?.apply(this, arguments);
styles();
const host = el("div", { style: "width:100%;height:100%;min-height:320px" });
this._bfsCompare = buildPlayer(host);
this.addDOMWidget("bfs_video_compare", "div", host, { serialize: false, hideOnZoom: false, getMinHeight: () => 320 });
this.setSize([Math.max(this.size[0], 560), Math.max(this.size[1], 620)]);
new ResizeObserver(() => this._bfsCompare?.drawStrip()).observe(host);
return r;
};
const executed = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
executed?.apply(this, arguments);
const d = message?.bfs_compare?.[0];
if (d) this._bfsCompare?.setData(d);
};
},
});
+53 -158
View File
@@ -19,9 +19,8 @@ every shot inside that range.
| **BFS Shot Planner** | Pick a video, split it on a timeline, set a reference and prompt per shot. Outputs the shots as a ComfyUI **list**. |
| **BFS Shot Unpack** | Opens one shot into plain values: guide frames, reference, second reference, prompt, length, first frame, audio, size, index. Use it with any model. |
| **BFS Shot Repack** | Puts edited pieces back into a shot: Unpack's `shot` output goes to Repack's `shot`, the edited piece (e.g. the reference with its background removed) to its input. Unconnected inputs keep their values; timing, cuts and audio are unchanged. |
| **BFS Shot H3 Conditioning** | The common MiniMax H3 path with the important controls only: aligned Add Guide and/or native video reference, references, optional audio conditioning, the Planner's inpaint mode and source-subject blur. |
| **BFS Shot H3 Conditioning (Advanced)** | The full conditioning node, preserving the previous node and old workflows. Adds duet canvas, shifted RoPE, prompt writer, setting reference and experimental mask-guide controls. |
| **BFS Shot Join** | Concatenates the decoded shots in order, trims each to its true length, cross-fades soft joins and returns the soundtrack. With *comparison* on it also returns a side-by-side video (original shot \| references \| result) with the shot's info and your *label* on top and its prompt below, ready for Create Video. *save to = mp4 file* writes the video (and the comparison) straight to MP4 files instead, one shot in memory at a time (long videos); `video_path` gives the file. |
| **BFS Shot H3 Conditioning** | Ready-made MiniMax H3 conditioning for one shot, built with the native nodes (Reference to Video + Add Guide). Optional *duet*: the shot's clip pinned in a side panel (canvas or shifted RoPE; route the model through the node for the shift); the join cuts the panel off. Optional prompt writer: pick a *task* (character swap, style, setting, appearance, lighting / weather, custom) and an *instruction*; with a VLM on its *vlm* input it writes each shot's duet prompt from the shot and its references, otherwise a template for the task (*planner prompt*, the default, keeps the planner's prompt). The written text comes out on *prompt*. Optional *setting_ref* (TSC's tip): one more reference, the shot's middle frame with the person covered in TV static, so the model sees the place in full detail; it is the last `<Picture n>` (write `{setting}` for its tag, otherwise a sentence is added to subject_definitions). |
| **BFS Shot Join** | Concatenates the decoded shots in order, trims each to its true length, cross-fades soft joins and returns the soundtrack. With *comparison* on it also returns a side-by-side video (original shot \| references \| result) with the shot's info and your *label* on top and its prompt below, ready for Create Video. |
| **BFS Shot H3 Duet** | Renders one shot with H3: the shot's clip pinned beside the video (duet, no LoRA), the shot as an aligned guide (for body-swap LoRAs), or both. |
## The panel
@@ -36,9 +35,9 @@ The planner's panel has five tabs, in the order you work. The timeline stays on
|---|---|
| 🎞 **Video** | pick or upload the video, audio, how it is split into shots (cuts or fixed length), generation size |
| 🎬 **Shots** | one card per shot (scroll sideways, **← →** move between shots), the preview player and the selected shot's editor: reference & prompt, who is replaced (mask, generation mode, `{target}`), continuity, **Copy to other shots** |
| 👥 **People / Filters / Masks** | the cast found by face, shot filters and the global mask settings |
| 👥 **People & masks** | the cast found by face (a reference per person) and the global mask settings |
| 📝 **Prompts & refs** | the global reference and prompt, the VLM and the reference descriptions (`{details}`) |
| ▶ **Run** | **Checks** (what to fix before running, with a button to each shot) and how the shots run (shot by shot / queue loop / auto loop) |
| ▶ **Run** | **Checks** (what to fix before running, with a button to each shot), auto / queue loop, filters, test limits |
Shot cards show badges for what each shot has: 🎯 mask, 🎭 / ✂ generation mode, 🧑 `{target}`, ⛓ continuity and ⚠ problems.
The header's ⚠ count opens the checks.
@@ -55,97 +54,13 @@ decode) runs once per shot, and shot *i* always gets guide *i*, reference *i* an
Single values such as the model and VAE are shared by every run. The join takes the whole list
back (it is an `INPUT_IS_LIST` node) and puts the video together.
Three run modes:
Two run modes:
- **One run, shot by shot (default)**: one Queue renders every shot **in order inside the same run**. Each shot is
stored on disk; then BFS Shot Join expands the part of the graph between the planner and itself (ComfyUI node
expansion) for the next shot, so that shot is conditioned *after* the previous one exists and continuity
(*head* / *history*) works. No browser re-queue (the tab can be closed), Save Video fires once with the full video,
and after a failure a new Queue resumes from the stored shots (*Reset loop* starts over). Previews connected inside
the shot's part of the graph show every shot. Needs exactly one BFS Shot Planner before the join. Same idea as the
recursive loop of Ethanfel's MiniMax H3 Context Loop.
- **Auto loop (one run)**: every shot in one queue run. Simple; holds all decoded shots in memory.
- **Queue loop (one shot per run)**: each run generates the next pending shot and stores it on
disk. The join blocks every node after it until the last shot, then outputs the full video,
so a Save Video node only fires once. With *Auto-queue the next shot* on (default), the panel re-queues
the workflow after each shot, so one click still renders the whole video (keep the ComfyUI tab open: the panel
does the re-queue). *Reset loop* starts over. Changing the plan starts a new run. It resumes from the stored
shots after a failure, and continuity *head* / *history* needs it: each shot reads the result of the one before.
- **Auto loop (all shots at once)**: every shot in one queue run, each node runs for every shot before the next node.
Holds all decoded shots in memory. Continuity *head* / *history* does not work here (all shots are conditioned
before any is sampled).
### Memory
- **Status at the top (🧠)** and a **Memory** card in the Run tab. BFS Shot Join records every shot it receives on
this GPU: its size (latent tokens = generated frames, with the continuity head, × resolution) and the peak VRAM of
its run. Before running, every planned shot is compared with the **biggest shot that already ran on this GPU**:
*✓* within it, *a bit bigger* (it will probably fit, maybe slower), or *⚠ may run out of memory* (also listed in
Checks). There is no fixed "GB per frame" for H3 (ComfyUI offloads weights as needed), so nothing is guessed: until
a first shot has run, the status says *no memory data yet*. The record lives in ComfyUI's `user/bfs_shotloop_vram.json`.
- **Between shots** (Run tab, shot by shot and queue loop): *keep models loaded* (fastest) or *free memory*, which
unloads every model and empties the GPU after each shot so the next one starts clean (low VRAM, or a later shot
that runs out of memory); models load again for every shot, so it is slower.
- What makes a shot lighter: lower **Megapixels**, a shorter **Max seconds / shot**, **head 5** instead of 22 (the
head adds its frames to every shot), **Crop** (the generation is the subject's box), and ComfyUI's own options
(`--lowvram`, fp8 / GGUF weights, tiled VAE decode).
- **The final join** (after the last shot) is where long videos used to run out of **RAM** (not VRAM: the process is
killed, e.g. `Terminated` under WSL). It now reads one stored shot at a time into the final video. Measured on 11
shots at 960×544 (~50 s): **+15.8 GB before, +8.2 GB now** with *save to = images* (that is the video itself, which an
IMAGE output must hold), and **+2.1 GB with *save to = mp4 file***, which streams the video to ffmpeg and stays at
about one shot whatever the length (also with the comparison, which is streamed 16 frames at a time: +2.1 GB
measured at *comparison_height* 1024, against ~32 GB as images). For long videos use *mp4 file* (BFS Shot Join), or
the panel's **Combine**.
- **RAM guard:** before an *images* join, BFS Shot Join asks ComfyUI's cache to drop earlier outputs and estimates
what the video (and the comparison) need; when that does not fit in the free RAM it writes MP4 files instead and
says so in the console, instead of the whole ComfyUI being killed.
- **The outputs are never empty:** with *mp4 file* (chosen or by the RAM guard) the `images` and `comparison` outputs
still carry the video, read back from the written files at a size that fits the free RAM (the console says the
scale; the files keep the full size), the join shows the files on its node, and `video_path` gives the path.
- **Default: save_to = images** (the result comes out inside ComfyUI, for the next nodes). The panel's Checks warn when
the video is long for this machine (as images it needs about 12 bytes per pixel per frame, ~3.5x with the
comparison, against the free RAM) and advise *mp4 file* for that video.
- **comparison_height** is the height of each column: the comparison is three columns wide plus text bars, so it
costs about 3× the video at the same height. 360–512 is plenty to review a long video.
### Stored shots (cache, redo, combine)
In the **shot by shot** and **queue loop** runs every generated shot is stored, and the Run tab's **Stored shots**
card manages them.
- **Shot cache:** *Keep* (default) stores them in ComfyUI's output folder, `output/bfs_shotloop/<run>`: they survive a
restart, so a stopped run resumes and a single shot can be redone. *Temp* uses ComfyUI's temp folder, which ComfyUI
**empties at every start** (before 1.104 every run was stored there).
- **Shot storage** (the format of each stored shot, measured on 145 frames at 1280×720):
| Storage | Size | Quality |
|---|---|---|
| **Lossless** (FFV1, default) | ~34 MB | exact: the final video is unchanged |
| Smaller (H.264 RGB, crf 8) | ~21 MB | visually lossless (PSNR ~52 dB) |
| Smallest (H.264 RGB, crf 14) | ~9 MB | light loss (PSNR ~48 dB) |
| Raw frames (`.pt`) | ~401 MB | exact; needs no ffmpeg |
The final video of a stored run is assembled from the stored shots, and the continuity head reads them: a lossy
storage reaches the output (the panel warns). Without ffmpeg a shot falls back to raw frames. With *comparison* on,
the shot's source frames for the comparison's original column are kept as `shot_NNNN_orig.mkv` (visually lossless:
they are only shown); the `.pt` itself holds only the shot's info (a few KB).
- **One folder per source video.** Each shot remembers the settings it was made with (its range, prompt, references,
mask, crop, continuity, the generation size, the global prompt / references and the references or prompt connected
to the planner). The next Queue **runs only the shots that are not stored or whose settings changed**, plus the
shots that **continue from a shot that was made again** (head, history, previous shot as reference / first
frame), so every seam matches. Shot cards show 💾 (stored, up to date) or ⏳ (runs next time).
- **Model, LoRA, seed, sampler:** the planner does not see them. After changing them, redo the shots (↻) or *Redo all*,
or *Reset loop* (deletes the stored shots of this video).
- **↻ Redo** a shot (Stored shots card): it runs again on the next Queue, and the shots that continue from it run
after it. Tick *↻ redoes only that shot* to keep the next shots as they are (the seam after it may then not match).
- **🎬 Combine stored shots:** puts the stored shots together into an MP4 in the output folder **now, without
generating** (one shot in memory at a time), on the plan's timeline: the soundtrack, the original video where a
shot is skipped, and also where a shot is not stored yet or out of date, so it previews a run in progress. A link
to the file appears in the card.
- When every shot is stored, a Queue still runs the last shot again so the join can output the video: use Combine to
only assemble.
- **Recovering a run outside ComfyUI** (e.g. ComfyUI was killed during the final join):
`python tools/bfs_assemble_shots.py output/bfs_shotloop/<run> video.mp4 --audio input/<source video>` (ComfyUI's
Python, ffmpeg on the PATH) does the same as Combine with one shot in memory; it reads the old raw `.pt` shots too.
so a Save Video node only fires once. With *Auto-queue the next shot* on, the panel re-queues
the workflow after each shot. *Reset loop* starts over. Changing the plan starts a new run.
## Guide frames and lengths
@@ -163,9 +78,9 @@ 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 selected range as a single shot, whatever its
- **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).
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
@@ -174,10 +89,11 @@ camera cuts.
**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.
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* in the Video tab caps how many shots are used.
107 frames for H3). *Max shots* and *Max total seconds* cap a long source.
## Audio
@@ -195,7 +111,7 @@ that will not run. The panel shows the shot's start, end and current time (and f
playhead follows on the timeline, and clicking the timeline seeks. The source video streams from
the input folder, so only what you play is loaded.
## Shot filters (People / Filters / Masks tab)
## Filters
Skip shots that should not run, decided per shot from a few sampled frames:
@@ -225,14 +141,18 @@ default it uses OpenCV Zoo's **YuNet** (detection) + **SFace** (recognition), Ap
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). The *ignore* checkbox excludes a detected identity from cast-based filters. Every shot card shows the people detected in it, with the main person highlighted.
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
person (most screen time) highlighted and linked people outlined in green.
| option | effect |
|---|---|
| Shots use their main person's reference | a shot without its own reference takes its main person's (shown with a dashed green outline) |
| Split where the main person changes | adds a boundary where the largest face switches to another person for at least a second, then re-splits |
| Only run shots with a detected person | shots with no detected, non-ignored person are skipped |
| Only run shots with a linked person | shots where no linked, non-ignored person appears are skipped ("no linked person") |
Face identities are used for filtering and optional shot splitting. References and marker colors are selected globally or per shot; they are not assigned to detected identities.
A reference set on the shot itself always wins; the global reference is the last fallback.
## References and prompts
@@ -249,7 +169,7 @@ without repeats, so a plan where every shot shares one reference returns a singl
## Mask & crop (SAM 3)
Generate only part of the frame. In a shot's editor (*Select region*), type what to segment in English
Generate only part of the frame. In a shot's editor (*Who is replaced*), type what to segment in English
(`person in white`, `woman with red hair`, `red car`; up to 32 tokens, commas for several things) or click
**🎯 Points** to pick positive / negative points on any frame of the shot, then **👁 Preview** (red = mask,
yellow = crop box). Then pick how the shot is generated:
@@ -276,9 +196,22 @@ rows at *opacity* × the noise level, so they start from the original partly vis
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.)
**🎨 Colored Mark** is an optional translucent overlay on the source video. Enable it before entering a SAM 3 description or opening **🎯 Points**; the selected color applies to that shot's marked region. This is only a visual marker: it does not create an inpainting mask or change **Full frame**, **Mask only** or crop behavior. The default colors are set under each global reference in **Prompts & refs**; choose a per-shot color when needed. The overlay keeps movement and facial expression visible.
**🎨 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.
To mark two regions in one shot, use the main description or points for **Ref 1**, then open **Mark a second region** and describe the second region for **Ref 2**. Give the two references different marker colors and enable *use_ref_2* on BFS Shot H3 Conditioning. The second region currently uses a text description.
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.
@@ -313,7 +246,7 @@ Crop + mask), `{target}`, the setting picture and the comparison overlay. Three
| 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 / Filters / Masks → **🎞 Mask video (whole video)** | the whole video, matched by time | SAM 3 for shots without a mask |
| 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.
@@ -338,8 +271,8 @@ Credit to **Neko (Nekodificador)** and **AbleJones**, whose workflow and nodes t
ComfyUI builds without native H3 generation masks need a per-row mask patch on the model
(e.g. ComfyUI-MiniMaxH3-PerRowMasking).
The **People / Filters / Masks** tab holds the global mask settings, with defaults that work as they are: fill holes on,
temporal expand 2 frames (less flicker), expand 16 px, feather 12 px, padding 15 %, paste the whole box with feathered edges, blockify off
The **People & masks** tab holds the global mask settings, with defaults that work as they are: fill holes on,
temporal expand 2 frames (less flicker), expand 16 px, feather 12 px, padding 15 %, paste by mask, blockify off
(16 aligns the mask to H3's latent grid), threshold 0.5, 4 objects. *Show masks on shots* overlays the preview
on the shot cards. SAM 3 uses the official `sam3.1_multiplex_fp16.safetensors` in `models/checkpoints`,
downloaded from Comfy-Org/sam3.1 the first time.
@@ -360,19 +293,10 @@ an animal or a cartoon character (it says what the subject is first). In the **P
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 and every shot's. They are saved in
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 (no click needed).
**Image max side (VLM)** controls the longest side of reference and shot images prepared for VLM analysis (1024 px by
default). Aspect ratio is preserved; the model's own image processor may resize them further. Increase it when fine
reference details matter, or lower it to reduce analysis cost. This does not change the generated video size.
When a shot has two references, the planner expands a one-reference template at run time to include `<Subject 2>` and
`{details2}` for `<Picture 2>`. The Describe refs panel provides a separate description field for each reference.
Existing one-reference templates remain unchanged in shots with only one reference. You can also use `{details2}`
explicitly in any template.
**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
@@ -394,13 +318,13 @@ Every setting in the panel has a hover tooltip (ⓘ). The main ones:
| 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 |
| Timeline fps | the frame rate the planner works on (generation and audio use it) |
| Max shots | test on the first shots only |
| Generation size | Megapixels (default), source video resolution, or custom width and height; dimensions snap to the size multiple |
| Run | one run, shot by shot (default) / queue loop (one shot per run) / auto loop (all shots at once) |
| Max shots / Max total seconds | test on the first shots or seconds only |
| Megapixels / Size multiple | generation size at the source's aspect ratio, snapped to the multiple |
| Run | auto loop (all shots in one run) or queue loop (one shot per run, stored on disk) |
| Skipped shots in the output | keep the original video there or remove those shots |
| Mask: padding / expand / feather / temporal expand / blockify | crop context, paste-back growth and softness, flicker hold, square blocks |
| 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 / image max side | how much the VLM sees, its answer limit, and the longest side of images sent for analysis |
| 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)
@@ -463,21 +387,22 @@ references (good across camera cuts, keeps the person consistent), and *previous
at frame 0 of the shot (for continuous action without a cut). Pick which frame: the previous result's first,
middle or last. **Copy to other shots** with *continuity* ticked applies it to every shot after the first.
It needs the previous result to exist: it works **shot by shot** and in the **queue loop** (the planner reads the stored result) and in
It needs the previous result to exist: it works in the **queue loop** (the planner reads the stored result) and in
the **auto loop with BFS Shot H3 Duet** (it renders shot by shot and keeps the last result). BFS Shot Unpack also
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 5, 22, 39 or 56 frames of the previous shot's
*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).
- Plans saved before keep what they had; *head* (below) is now the default for new shots.
- **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.
@@ -486,39 +411,9 @@ that moves the video, its audio and the other guides past the history; ComfyUI f
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).
- **Shot by shot / 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). History can use 5, 22, 39 or 56 frames; longer windows use more memory.
- **Only 17k+5 frames work.** H3's VAE encodes 17-frame clips into 5 latent steps and drops the last 3 steps of the
whole video, so 17 frames come back as just their *first 5*: the old 17 / 34 / 51 options left a 12-frame (0.5 s)
hole between the history and the shot, seen as a jump or drift at every join. Plans saved with 17 / 34 / 51 are
read as 22 / 39 / 56.
### Head: the shot starts on the previous result (default)
*Continuity* = **previous shot's end as this shot's start** generates the shot from **5, 22 or 39 frames earlier**:
those first frames are the previous shot's result, encoded **into this shot's latent** and kept by the sampler
(noise mask 0), so they are **not generated or swapped again**, and the new frames continue straight from them.
BFS Shot Join cuts them off, so the timing is unchanged. Same masked-prefix continuation as the Context Loop's
*masked AV* mode, whose tested default is 22.
- **On by default, 22 frames**, for every shot after the first. Set *Continuity = off* (or *history*) to change it.
- **Same take only:** skipped at a camera cut, after a skipped shot, and on grids other than H3's.
- **Shot by shot or queue loop.** In the auto loop the previous result does not exist yet when the shot is conditioned,
so the planner generates the shot without a head (and says so in the console).
- Works with full frame, *Frame + paste*, *Mask only* / *Crop + mask* (the head is kept whatever the mask says), *Crop*
(the head is cut with this shot's box) and the duet canvas. Not with duet *shifted RoPE*. Pinned in
**BFS Shot H3 Conditioning**; other workflows still generate and cut the extra frames.
- Costs those frames of generation per shot (a 60-frame shot is generated at 90 frames instead of 73).
- **Keep colors** (BFS Shot Join, on by default). A shot that starts on the previous result comes out a little more
saturated right after it, and the next shot inherits that: measured on a 5-shot take, the new subject's saturation
went 75 → 88 → 105 → 124, with almost no change *inside* each shot (the jump is at every join). With *keep_colors*
the first shot of each take records its subject's colours, and every shot that continues from the one before has
its subject's chroma (CIELAB a, b: one transform per shot, so no flicker) matched to them before it is stored; the
next shot then starts from the anchored colours and nothing accumulates (same take: 69–73 on every shot). The
subject is where the result differs from the source video (soft edge); the background and the lightness are not
touched. A camera cut or a shot without continuity starts a new take (new anchor). *keep_colors_strength* blends it
(1 = fully). It applies to stored runs (shot by shot, queue loop), where continuity exists.
- **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)
@@ -586,7 +481,7 @@ Then save:
**🎯 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 / Filters / Masks tab shows the track and removes it. The first run
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
+26 -195
View File
@@ -19,27 +19,6 @@ const TEXT = {
"▶ 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", "范围"],
"Timeline": ["Linha do tempo", "Línea de tiempo", "时间轴"], "zoom": ["zoom", "zoom", "缩放"],
"Click a shot to select it (← → move between shots) · drag the white handles to move a boundary · double-click the shot bar to split · Delete removes the selected shot's cut": [
"Clique em um plano para selecioná-lo (← → navega entre planos) · arraste as alças brancas para mover uma divisão · dê dois cliques na barra para dividir · Delete remove o corte do plano selecionado",
"Haz clic en un plano para seleccionarlo (← → cambia de plano) · arrastra los controles blancos para mover un límite · haz doble clic en la barra para dividir · Delete elimina el corte del plano seleccionado",
"点击镜头以选中(← → 切换镜头)· 拖动白色控点移动分界 · 双击镜头条进行拆分 · 按 Delete 删除所选镜头的切点",
],
"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", "按镜头切换或固定时长,并符合模型帧步长"],
"Range length, seconds (0 = to the end)": ["Duração do trecho, em segundos (0 = até o fim)", "Duración del tramo, en segundos (0 = hasta el final)", "片段时长(秒,0 = 到结尾)"],
"refresh the input folder": ["atualizar a pasta de entrada", "actualizar la carpeta de entrada", "刷新输入文件夹"],
"Pick or upload a video to start": ["Escolha ou envie um vídeo para começar", "Elige o sube un vídeo para empezar", "选择或上传视频以开始"],
"use the video’s audio": ["usar o áudio do vídeo", "usar el audio del vídeo", "使用视频音频"],
"The outputs carry a silent track of the right length, so Create Video works": ["As saídas incluem uma faixa silenciosa com a duração correta para funcionar em Create Video", "Las salidas incluyen una pista silenciosa con la duración correcta para que funcione Create Video", "输出会带有时长正确的静音音轨,以便 Create Video 正常工作"],
"Audio of the planner's and the join's outputs": ["Áudio das saídas do Planner e do Join", "Audio de las salidas del Planner y del Join", "Planner 和 Join 输出的音频"],
"the range ends after the player's frame": ["o trecho termina depois do frame atual do player", "el tramo termina después del fotograma actual del reproductor", "片段在播放器当前帧之后结束"],
"Play or scrub the video (or click the timeline), then ⇤ / ⇥. {…} frames ({…} s) of {…}: only this part is split, analysed, generated and joined.": ["Reproduza ou avance o vídeo (ou clique na linha do tempo) e use ⇤ / ⇥. {…} frames ({…} s) de {…}: somente este trecho será dividido, analisado, gerado e unido.", "Reproduce o desplaza el vídeo (o haz clic en la línea de tiempo) y usa ⇤ / ⇥. {…} fotogramas ({…} s) de {…}: solo se dividirá, analizará, generará y unirá este tramo.", "播放或拖动视频(也可点击时间轴),然后按 ⇤ / ⇥。共 {…}/{…} 帧({…} 秒):仅拆分、分析、生成并合并此片段。"],
"15 s in one shot": ["15 s em um único plano", "15 s en un solo plano", "单镜头 15 秒"],
"from the start frame · {…} frames": ["a partir do frame inicial · {…} frames", "desde el fotograma inicial · {…} fotogramas", "从起始帧开始 · {…} 帧"],
"limit how many detected shots are used": ["limita quantos planos detectados serão usados", "limita cuántos planos detectados se usan", "限制使用的检测镜头数量"],
"Auto split replaces the boundaries; per-shot settings stay with the shot at the same position.": ["A divisão automática substitui os limites; as configurações por plano permanecem na mesma posição.", "La división automática reemplaza los límites; los ajustes por plano se mantienen en la misma posición.", "自动拆分会替换分界;逐镜头设置保留在相同位置。"],
"before the start: not planned": ["antes do início: fora do plano", "antes del inicio: no incluido", "起点之前:不处理"],
"after the end: not planned": ["depois do fim: fora do plano", "después del final: no incluido", "终点之后:不处理"],
"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)", "单个镜头(整个范围)"],
@@ -47,27 +26,17 @@ const TEXT = {
"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", "尺寸倍数"],
"Size mode": ["Modo de tamanho", "Modo de tamaño", "尺寸模式"], "Megapixel target": ["Alvo em megapixels", "Objetivo en megapíxeles", "百万像素目标"],
"Original video": ["Vídeo original", "Vídeo original", "原视频"], "Custom dimensions": ["Dimensões personalizadas", "Dimensiones personalizadas", "自定义尺寸"],
"Output size": ["Tamanho de saída", "Tamaño de salida", "输出尺寸"],
"Custom width": ["Largura personalizada", "Ancho personalizado", "自定义宽度"], "Custom height": ["Altura personalizada", "Alto personalizado", "自定义高度"],
"Image max side (VLM)": ["Lado máximo da imagem (VLM)", "Lado máximo de imagen (VLM)", "VLM 图像最长边"],
"VLM image sizing": ["Redimensiona as imagens enviadas ao VLM, mantendo a proporção. O processador do modelo ainda pode redimensioná-las.", "Ajusta las imágenes enviadas al VLM manteniendo la proporción. El procesador del modelo puede volver a ajustarlas.", "调整发送给 VLM 的图像并保持比例。模型处理器仍可能再次缩放。"],
"Original resolution is rounded to the size multiple for model compatibility.": ["A resolução original é arredondada ao múltiplo definido para compatibilidade com o modelo.", "La resolución original se redondea al múltiplo indicado para compatibilidad con el modelo.", "原始分辨率会取整到尺寸倍数,以兼容模型。"],
"✂ 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})"],
"Ref 1 description ({details})": ["Descrição da ref. 1 ({details})", "Descripción de ref. 1 ({details})", "参考图 1 描述({details})"],
"Ref 2 description ({details2})": ["Descrição da ref. 2 ({details2})", "Descripción de ref. 2 ({details2})", "参考图 2 描述({details2})"],
"no description yet: Describe refs, write your own, or leave it to the VLM at run time": ["sem descrição: use Descrever referências, escreva uma descrição ou deixe o VLM gerar durante a execução", "sin descripción: pulsa Describir referencias, escribe una o deja que el VLM la genere al ejecutar", "暂无描述:点击“描述参考图”、自行填写,或在运行时由 VLM 生成"],
"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", "📝 描述参考图"],
"🎨 Colored Mark": ["🎨 Marcação colorida", "🎨 Marca de color", "🎨 彩色标记"], "Colored Mark": ["Marcação colorida", "Marca de color", "彩色标记"], "Ref 1 color": ["Cor da ref. 1", "Color de ref. 1", "参考图 1 的颜色"],
"🎨 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 描述第二个人"],
@@ -80,7 +49,8 @@ const TEXT = {
"🎭 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", "蒙版和裁剪设置"],
"Green": ["Verde", "Verde", "绿色"], "Purple": ["Roxo", "Morado", "紫色"],
"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)", "自动循环(一次运行)"],
@@ -98,10 +68,10 @@ const TEXT = {
"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.", "检查每个镜头,设置参考图和提示词,并选择结果的应用方式。"],
"Track detected face identities to filter or split shots. Set masks here or per shot.": ["Rastreie identidades faciais para filtrar ou dividir planos. Configure as máscaras aqui ou por plano.", "Sigue identidades faciales para filtrar o dividir planos. Configura máscaras aquí o por plano.", "跟踪人脸身份以筛选或拆分镜头。可在此处或逐镜头设置蒙版。"],
"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 the selected region in the source video as a translucent visual marker. It does not create or enable an inpainting mask.": ["Colorir a região selecionada no vídeo de origem como uma marca translúcida. Isso não cria nem ativa uma máscara de inpainting.", "Colorea la región seleccionada en el vídeo de origen como una marca translúcida. No crea ni activa una máscara de inpainting.", "在源视频的选定区域叠加半透明颜色标记,不会创建或启用修补蒙版。"],
"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", "选择或上传视频以开始"],
@@ -112,14 +82,16 @@ const TEXT = {
"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", "裁剪镜头会使用此尺寸"],
"Split where the main person changes": ["Dividir quando a pessoa principal mudar", "Dividir cuando cambie la persona principal", "主要人物变化时拆分"],
"Only run shots with a detected person": ["Executar apenas planos com pessoa detectada", "Ejecutar solo planos con una persona detectada", "仅运行检测到人物的镜头"],
"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 with no detected, non-ignored person are skipped": ["planos sem pessoa detectada e não ignorada são pulados", "se omiten los planos sin una persona detectada y no ignorada", "跳过没有检测到未忽略人物的镜头"],
"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 face identities to filter or split shots": ["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", "跟踪人脸、关联参考图,并按出镜人物拆分镜头"],
"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", "反转"],
@@ -128,11 +100,11 @@ const TEXT = {
"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 face identities to filter or split shots": ["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", "跟踪人脸、关联参考图,并按出镜人物分配镜头"],
"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 global references (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)", "空白项使用人物参考图(角色)或全局参考图(提示词和参考图选项卡)"],
"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 会将其贴回。"],
@@ -156,9 +128,9 @@ const TEXT = {
"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", "中文"],
"Video": ["Vídeo", "Vídeo", "视频"], "Shots": ["Planos", "Planos", "镜头"], "Prompts": ["Prompts", "Prompts", "提示词"],
"Marker color · Ref 1": ["Cor do marcador · Ref. 1", "Color de marca · Ref. 1", "标记颜色 · 参考图 1"], "Marker color · Ref 2": ["Cor do marcador · Ref. 2", "Color de marca · Ref. 2", "标记颜色 · 参考图 2"],
"These are the default marker colors for the global references. Turn on Colored Mark on a shot to apply them; shot-level colors can override them.": ["Estas são as cores padrão das referências globais. Ative Marcação colorida no plano para usá-las; cada plano pode substituí-las.", "Estos son los colores predeterminados de las referencias globales. Activa Marca de color en un plano para aplicarlos; cada plano puede cambiarlos.", "这些是全局参考图的默认标记颜色。启用镜头的彩色标记即可应用;镜头也可以单独覆盖。"],
"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", "内置"],
@@ -207,142 +179,20 @@ const TEXT = {
"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 回复的最大长度。长描述可调高。"],
"skipped by hand": ["ignorado manualmente", "omitido manualmente", "手动跳过"], "no detected person": ["nenhuma pessoa detectada", "no se detectó ninguna persona", "未检测到人物"],
"longer than {…} frames: split it": ["mais longo que {…} frames: divida o plano", "supera {…} fotogramas: divide el plano", "超过 {…} 帧:请拆分镜头"],
"{…} needs a mask (text, points or a mask video; or the planner's mask input)": ["{…} precisa de uma máscara (texto, pontos, vídeo de máscara ou entrada de máscara do Planner)", "{…} necesita una máscara (texto, puntos, vídeo de máscara o entrada de máscara del planificador)", "{…} 需要蒙版(文本、点、蒙版视频或 Planner 的蒙版输入)"],
"the mask preview found nothing": ["a prévia da máscara não encontrou nada", "la vista previa de la máscara no encontró nada", "蒙版预览未找到目标"],
"the prompt uses {target} but the shot has no target description": ["o prompt usa {target}, mas o plano não tem uma descrição do alvo", "el prompt usa {target}, pero el plano no tiene una descripción del objetivo", "提示词使用了 {target},但镜头没有目标描述"],
"the prompt uses {details} but its references have no description yet": ["o prompt usa {details}, mas as referências ainda não têm descrição", "el prompt usa {details}, pero las referencias aún no tienen descripción", "提示词使用了 {details},但参考图还没有描述"],
"{details} sits inside a sentence of the prompt: use the 'short' describe preset (Prompts & refs), the other presets write a paragraph": ["{details} está dentro de uma frase do prompt: use a descrição 'short' (Prompts e referências); os outros modos escrevem um parágrafo", "{details} está dentro de una frase del prompt: usa la opción 'short' (Prompts y referencias); las demás opciones escriben un párrafo", "{details} 位于提示词句子中:请使用“short”描述模式(Prompts & refs),其他模式会生成段落"],
"no prompt (fine when the node's prompt input is connected)": ["sem prompt (tudo certo se a entrada prompt do node estiver conectada)", "sin prompt (no pasa nada si la entrada prompt del nodo está conectada)", "没有提示词(若节点的 prompt 输入已连接则无需处理)"],
"no reference (fine when the node's ref_image input is connected)": ["sem referência (tudo certo se a entrada ref_image do node estiver conectada)", "sin referencia (no pasa nada si la entrada ref_image del nodo está conectada)", "没有参考图(若节点的 ref_image 输入已连接则无需处理)"],
"Analysing video…": ["Analisando o vídeo…", "Analizando el vídeo…", "正在分析视频…"], "Detecting cuts…": ["Detectando cortes…", "Detectando cortes…", "正在检测切点…"], "Detecting people and faces…": ["Detectando pessoas e rostos…", "Detectando personas y rostros…", "正在检测人物和人脸…"],
"Finding people by face…": ["Localizando pessoas pelos rostos…", "Buscando personas por sus rostros…", "正在通过人脸查找人物…"], "Selecting the person (SAM 3) and describing them (VLM)…": ["Selecionando a pessoa (SAM 3) e descrevendo-a (VLM)…", "Seleccionando a la persona (SAM 3) y describiéndola (VLM)…", "正在选择人物(SAM 3)并生成描述(VLM)…"],
"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.": ["O recorte do alvo pelo SAM 3 foi criado abaixo, mas nenhum VLM está carregado para descrevê-lo. Conecte o Qwen3-VL à entrada vlm do Shot Planner e execute a fila uma vez; depois clique novamente em Descrever alvo. Você também pode escrever a descrição manualmente.", "SAM 3 creó el recorte del objetivo (se muestra abajo), pero no hay ningún VLM cargado para describirlo. Conecta Qwen3-VL a la entrada vlm de Shot Planner y ejecuta el flujo una vez; después vuelve a pulsar Describir objetivo. También puedes escribir la descripción.", "SAM 3 已生成目标抠图(见下方),但尚未加载 VLM 来描述它。将 Qwen3-VL 连接到 Shot Planner 的 vlm 输入并运行一次队列,然后再次点击“描述目标”。也可以手动填写描述。"],
"Segmenting with SAM 3… (the first time downloads/loads the model)": ["Segmentando com SAM 3… (na primeira vez, o modelo será baixado/carregado)", "Segmentando con SAM 3… (la primera vez se descarga/carga el modelo)", "正在使用 SAM 3 分割…(首次运行会下载并加载模型)"],
"open the checks (Run tab)": ["abrir as verificações (aba Executar)", "abrir las comprobaciones (pestaña Ejecutar)", "打开检查项(Run 标签页)"], "Open the full guide (every setting explained, with examples)": ["Abrir o guia completo (com explicações e exemplos de cada opção)", "Abrir la guía completa (explicaciones y ejemplos de cada opción)", "打开完整指南(包含各设置的说明和示例)"],
"click = keep (green) · right-click or shift+click = exclude (red) · click a point to remove it": ["clique = manter (verde) · clique direito ou Shift+clique = excluir (vermelho) · clique em um ponto para removê-lo", "clic = conservar (verde) · clic derecho o Mayús+clic = excluir (rojo) · clic en un punto para quitarlo", "单击 = 保留(绿色)· 右键或 Shift+单击 = 排除(红色)· 单击已有点可将其删除"],
"click to remove": ["clique para remover", "haz clic para quitar", "点击删除"], "text prompt (used when there are no points)": ["texto de seleção (usado quando não há pontos)", "texto de selección (se usa cuando no hay puntos)", "文本提示(没有标记点时使用)"],
"Save → all shots": ["Salvar → todos os planos", "Guardar → todos los planos", "保存 → 所有镜头"], "Save (this shot)": ["Salvar (este plano)", "Guardar (este plano)", "保存(此镜头)"],
"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.": ["Rastreie esta seleção com SAM 3 pelo vídeo inteiro (ou trecho), avançando e retrocedendo a partir deste frame. Cada plano sem seleção própria usa sua parte do rastreamento. Digite também uma descrição curta (por exemplo, 'mulher de blusa rosa') para reencontrar o sujeito após os cortes.", "Sigue esta selección con SAM 3 por todo el vídeo (o tramo), hacia delante y atrás desde este fotograma. Cada plano sin selección propia usa su parte del seguimiento. Añade también una descripción breve (por ejemplo, 'mujer con blusa rosa') para volver a encontrar al sujeto después de los cortes.", "使用 SAM 3 从此帧开始向前和向后跟踪所选对象,覆盖整个视频(或片段)。没有单独选区的镜头会使用对应的跟踪结果。再输入简短描述(如“穿粉色上衣的女性”),以便在切镜后重新找到主体。"],
"Colored Mark adds a translucent color overlay to a selected region in the source video. It does not enable inpainting. Choose the color before marking the region with points or a description.": ["Marcação colorida aplica uma sobreposição translúcida à região selecionada no vídeo de origem. Ela não ativa inpainting. Escolha a cor antes de marcar a região com pontos ou descrição.", "Marca de color aplica una superposición translúcida a la región seleccionada del vídeo original. No activa el inpainting. Elige el color antes de marcar con puntos o una descripción.", "彩色标记会在源视频的选定区域上叠加半透明颜色,不会启用修补。请先选颜色,再用点或描述标记区域。"],
"choose which shots run": ["escolher quais planos serão executados", "elegir qué planos se ejecutan", "选择要运行的镜头"],
"Fill enclosed gaps so each region is solid": ["Preencher espaços fechados para deixar cada região sólida", "Rellenar huecos cerrados para que cada región sea sólida", "填充封闭空隙,使区域完整"], "Use everything except the segmented object": ["Usar tudo, exceto o objeto segmentado", "Usar todo excepto el objeto segmentado", "使用除已分割对象以外的所有区域"], "Overlay the mask preview on the shot cards": ["Sobrepor a prévia da máscara nos cartões dos planos", "Superponer la vista previa de la máscara en las tarjetas de los planos", "在镜头卡片上叠加蒙版预览"],
"runs the detectors on a few frames of every shot": ["executa os detectores em alguns frames de cada plano", "ejecuta los detectores en algunos fotogramas de cada plano", "在每个镜头的若干帧上运行检测器"],
"Global prompt (a connected `prompt` input overrides it)": ["Prompt global (a entrada `prompt` conectada tem prioridade)", "Prompt global (la entrada `prompt` conectada tiene prioridad)", "全局提示词(若连接了 `prompt` 输入,则以输入为准)"],
"Used by shots without their own. Connected ref_image / ref_image_2 / prompt inputs on the node override these. Placeholders:": ["Usado nos planos sem valores próprios. As entradas ref_image / ref_image_2 / prompt conectadas ao node têm prioridade. Marcadores:", "Se usa en los planos sin valores propios. Las entradas ref_image / ref_image_2 / prompt conectadas al nodo tienen prioridad. Marcadores:", "镜头没有单独设置时使用。节点上连接的 ref_image / ref_image_2 / prompt 输入优先。占位符:"],
"description of the shot's references ·": ["descrição das referências do plano ·", "descripción de las referencias del plano ·", "镜头参考图描述 ·"], "who is replaced (per shot) ·": ["quem será substituído (por plano) ·", "quién se sustituye (por plano) ·", "被替换对象(逐镜头)·"], "the VLM's description of the shot ·": ["descrição do plano pelo VLM ·", "descripción del plano generada por el VLM ·", "VLM 对镜头的描述 ·"], "the setting picture (H3 Conditioning).": ["imagem de cenário (H3 Conditioning)。", "imagen del entorno (H3 Conditioning).", "场景参考图(H3 Conditioning)。"],
"At run time the planner asks the VLM about the shots it runs and applies the options below": ["Durante a execução, o Planner consulta o VLM sobre os planos processados e aplica as opções abaixo", "Durante la ejecución, el planificador consulta al VLM sobre los planos y aplica las opciones siguientes", "运行时,Planner 会让 VLM 分析要处理的镜头并应用以下选项"],
"Shots without a mask text or points get the VLM's segment suggestion": ["Planos sem texto de máscara ou pontos recebem uma sugestão de segmentação do VLM", "Los planos sin texto de máscara ni puntos reciben una sugerencia de segmentación del VLM", "没有蒙版文本或标记点的镜头会使用 VLM 的分割建议"],
"Write {shot} in a prompt: it becomes the VLM's description of that shot (camera, framing, action)": ["Use {shot} no prompt para inserir a descrição do plano pelo VLM (câmera, enquadramento e ação)", "Escribe {shot} en el prompt para insertar la descripción del plano generada por el VLM (cámara, encuadre y acción)", "在提示词中写入 {shot},即可插入 VLM 对镜头的描述(摄影机、构图和动作)"],
"Mask text for shots without one, skip where the VLM says skip": ["Texto de máscara para planos sem seleção; ignora os que o VLM indicar", "Texto de máscara para planos sin selección; omite los que el VLM marque", "为未设置选区的镜头填入蒙版文本,并跳过 VLM 建议跳过的镜头"],
"looks at every shot and suggests the mask, a description and whether to run it": ["analisa cada plano e sugere a máscara, uma descrição e se deve executá-lo", "analiza cada plano y sugiere la máscara, una descripción y si debe ejecutarse", "分析每个镜头并建议蒙版、描述以及是否运行"],
"Try another description: a new seed (and some temperature if it is 0), then describe again": ["Tentar outra descrição: usar uma nova seed (e aumentar a temperatura se estiver em 0) e descrever novamente", "Probar otra descripción: usar una nueva seed (y subir la temperatura si está en 0) y volver a describir", "重新生成描述:更换 seed(若温度为 0 则适当提高),再分析一次"],
"0 = always the same answer (greedy); 0.5-1 = varied answers, picked by the seed": ["0 = resposta sempre igual (greedy); 0,5–1 = respostas variadas, controladas pela seed", "0 = respuesta siempre igual (greedy); 0,5–1 = respuestas variadas según la seed", "0 = 始终生成相同答案(贪心);0.5–1 = 由 seed 控制的多样答案"],
"Used when the temperature is above 0: another seed, another description": ["Usada quando a temperatura é maior que 0: outra seed gera outra descrição", "Se usa cuando la temperatura es mayor que 0: otra seed genera otra descripción", "温度大于 0 时使用:更换 seed 可生成不同描述"],
"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.": ["O VLM recebe a frase do prompt global que contém {details} (ou a frase com {target} ao usar 🧑 Descrever alvo), com o marcador em branco, e escreve uma descrição que se encaixa nela.", "El VLM recibe la frase del prompt global que contiene {details} (o la frase con {target} al usar 🧑 Describir objetivo), con el marcador en blanco, y escribe una descripción que encaje.", "VLM 会读取包含 {details} 的全局提示词句子(使用 🧑 描述目标时则读取含 {target} 的镜头提示词),并在占位处生成适配的描述。"],
"Add a reference (global, per shot or per shot) first.": ["Adicione uma referência global ou por plano primeiro.", "Añade primero una referencia global o por plano.", "请先添加全局参考图或镜头参考图。"],
"✓ Nothing to fix in the shots that run.": ["✓ Nenhum ajuste necessário nos planos que serão executados.", "✓ No hay nada que corregir en los planos que se ejecutarán.", "✓ 将运行的镜头无需修正。"],
"Keep original video": ["Manter o vídeo original", "Conservar el vídeo original", "保留原视频"],
"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, 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, con el vídeo completo.", "每次运行生成并保存一个镜头。BFS Shot Join 后的节点仅在最后一个镜头运行,并接收完整视频。"],
"go to this shot": ["ir para este plano", "ir a este plano", "跳转到此镜头"], "Pick a video in the Video tab.": ["Escolha um vídeo na aba Vídeo.", "Elige un vídeo en la pestaña Vídeo.", "请在 Video 标签页中选择视频。"],
"split this shot in two": ["dividir este plano em dois", "dividir este plano en dos", "将此镜头一分为二"], "merge with the next shot": ["unir ao próximo plano", "unir con el siguiente plano", "与下一个镜头合并"],
"Forget the recent pictures (the ones this plan still uses stay)": ["Esquecer as imagens recentes (as usadas neste plano serão mantidas)", "Olvidar las imágenes recientes (se conservan las que usa este plan)", "清除最近使用的图片(本计划仍在使用的图片会保留)"],
"Remove EVERY reference from this plan (global and all shots) and the recent list": ["Remover TODAS as referências deste plano (global e de todos os planos) e da lista recente", "Eliminar TODAS las referencias de este plan (globales y de todos los planos) y de la lista reciente", "从此计划(全局和所有镜头)及最近列表中移除所有参考图"],
"clear this shot's references and prompt: it uses the global ones": ["limpar as referências e o prompt deste plano para usar os globais", "borrar las referencias y el prompt de este plano para usar los globales", "清除此镜头的参考图和提示词,改用全局设置"],
"+ Mark a second region (optional)": ["+ Marcar uma segunda região (opcional)", "+ Marcar una segunda región (opcional)", "+ 标记第二个区域(可选)"], "Mark a second region · Ref 2": ["Marcar segunda região · Ref. 2", "Marcar segunda región · Ref. 2", "标记第二个区域 · 参考图 2"], "Describe second region": ["Descrever segunda região", "Describir la segunda región", "描述第二个区域"], "e.g. the person on the left": ["ex.: a pessoa à esquerda", "p. ej., la persona de la izquierda", "例如:左侧的人物"],
"mask: what to segment, in English (woman in pink top, black dog…) · Enter = preview": ["máscara: o que segmentar, em inglês (woman in pink top, black dog…) · Enter = prévia", "máscara: qué segmentar, en inglés (woman in pink top, black dog…) · Enter = vista previa", "蒙版:用英文描述分割对象(woman in pink top、black dog…)· Enter = 预览"],
"Pick positive / negative points on a frame of this shot (more precise than text when several people look alike)": ["Marque pontos positivos/negativos em um frame do plano (mais preciso que texto quando várias pessoas são parecidas)", "Marca puntos positivos/negativos en un fotograma del plano (más preciso que el texto si varias personas se parecen)", "在此镜头的一帧上标记正向/负向点(多人相似时比文本更准确)"],
"Segment this shot (the points win over the text)": ["Segmentar este plano (os pontos têm prioridade sobre o texto)", "Segmentar este plano (los puntos tienen prioridad sobre el texto)", "分割此镜头(标记点优先于文本)"], "Segment this shot with the text and show a few frames": ["Segmentar este plano pelo texto e mostrar alguns frames", "Segmentar este plano con el texto y mostrar algunos fotogramas", "根据文本分割此镜头并预览若干帧"],
"Colored Mark needs a region selection: enter a description, add points, or choose a mask video, then preview it.": ["A Marcação colorida precisa de uma região: digite uma descrição, adicione pontos ou escolha um vídeo de máscara e gere a prévia.", "La Marca de color necesita una región: escribe una descripción, añade puntos o elige un vídeo de máscara y genera la vista previa.", "彩色标记需要选定区域:输入描述、添加标记点或选择蒙版视频,然后预览。"],
"nothing found: the shot runs without a mask": ["nada encontrado: o plano será executado sem máscara", "no se encontró nada: el plano se ejecutará sin máscara", "未找到目标:此镜头将不使用蒙版运行"],
"The whole frame is regenerated. The mask, if any, only feeds {target} / the setting picture.": ["O frame inteiro é regenerado. Se houver máscara, ela só alimenta {target} ou a imagem de cenário.", "Se regenera todo el fotograma. Si hay máscara, solo se usa para {target} o la imagen del entorno.", "整帧都会重新生成。若有蒙版,仅用于 {target} 或场景参考图。"],
"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.": ["Descreva em inglês o NOVO sujeito no resultado para o SAM 3: o BFS Shot Join também cola seu contorno quando ele é maior que o antigo. Ex.: person, dog, cat, car. Vazio = person.", "Describe en inglés el NUEVO sujeto del resultado para SAM 3: BFS Shot Join también pega su silueta cuando supera la anterior. Ej.: person, dog, cat, car. Vacío = person.", "用英文描述结果中的新主体,供 SAM 3 使用:若新轮廓更大,BFS Shot Join 也会粘贴其轮廓。例如 person、dog、cat、car。留空 = person。"],
"Write {target} in the prompt: it becomes this description, so the model knows WHICH person to replace (useful with several people on screen).": ["Use {target} no prompt para inserir esta descrição e indicar ao modelo QUEM substituir (útil quando há várias pessoas na tela).", "Escribe {target} en el prompt para insertar esta descripción e indicar a quién debe sustituir el modelo (útil si hay varias personas).", "在提示词中使用 {target} 插入此描述,明确告诉模型要替换谁(画面中有多人时很有用)。"],
"Uses this shot's selection (🎯 Points… on the person — click the BODY, not only the face, so the outfit is described — or the mask text): SAM 3 cuts the person out and the VLM describes them": ["Usa a seleção deste plano (🎯 Pontos… no corpo, não apenas no rosto, para descrever a roupa; ou o texto da máscara): o SAM 3 recorta a pessoa e o VLM a descreve.", "Usa la selección de este plano (🎯 Puntos… en el cuerpo, no solo en la cara, para describir la ropa; o el texto de máscara): SAM 3 recorta a la persona y el VLM la describe.", "使用此镜头的选区(🎯 在人物身体上标点,不要只点脸,这样才能描述服装;或使用蒙版文本):SAM 3 抠出人物,VLM 生成描述。"],
"Skip this shot": ["Ignorar este plano", "Omitir este plano", "跳过此镜头"], "unparsed answer:": ["resposta não interpretada:", "respuesta sin procesar:", "未解析的回答:"],
"previous shot as reference": ["plano anterior como referência", "plano anterior como referencia", "将上一镜头作为参考图"], "previous shot as first frame": ["plano anterior como primeiro frame", "plano anterior como primer fotograma", "将上一镜头作为首帧"], "previous shot's end as history (long take)": ["final do plano anterior como histórico (plano contínuo)", "final del plano anterior como historial (toma continua)", "将上一镜头的结尾作为历史(连续长镜头)"],
"its first frame": ["seu primeiro frame", "su primer fotograma", "其首帧"], "its last frame": ["seu último frame", "su último fotograma", "其末帧"], "its middle frame": ["seu frame do meio", "su fotograma central", "其中间帧"],
"Pick a video in the Video tab.": ["Escolha um vídeo na aba Vídeo.", "Elige un vídeo en la pestaña Vídeo.", "请在 Video 标签页中选择视频。"],
"✓ copied to {…} shot(s)": ["✓ copiado para {…} plano(s)", "✓ copiado a {…} plano(s)", "✓ 已复制到 {…} 个镜头"], "show mask previews": ["mostrar prévias das máscaras", "mostrar vistas previas de las máscaras", "显示蒙版预览"],
"max total seconds": ["duração total máxima", "duración total máxima", "总时长上限"],
"Remove every reference picture from this plan (global, every shot) and forget the recent list?": ["Remover todas as imagens de referência deste plano (global e de todos os planos) e limpar a lista recente?", "¿Eliminar todas las imágenes de referencia de este plan (globales y de todos los planos) y borrar la lista reciente?", "移除此计划中的所有参考图(全局及各镜头)并清空最近列表?"],
"Colored Mark": ["Marcação colorida", "Marca de color", "彩色标记"], "Default Colored Mark for this global reference": ["Cor padrão da Marcação colorida para esta referência global", "Color predeterminado de Marca de color para esta referencia global", "此全局参考图的默认彩色标记颜色"],
"write {details} in any prompt: each shot gets the description of its own references": ["Use {details} em qualquer prompt para inserir a descrição das referências de cada plano", "Escribe {details} en cualquier prompt para insertar la descripción de las referencias de cada plano", "在提示词中使用 {details},即可插入各镜头对应参考图的描述"],
"e.g. the person on the left": ["ex.: a pessoa à esquerda", "p. ej., la persona de la izquierda", "例如:左侧的人物"], "Keeps movement and expression visible.": ["Mantém os movimentos e as expressões visíveis.", "Mantiene visibles el movimiento y la expresión.", "保留动作和表情的可见度。"],
"What to segment, in English: a short phrase (noun + one or two traits), e.g. 'woman in pink top'; commas for several things. Enough on its own - no points needed: SAM 3 finds and tracks it through the shot (every match, up to Max objects). Press Enter to preview. Points, when set, take priority.": ["O que segmentar, em inglês: uma frase curta (substantivo e uma ou duas características), por exemplo, 'woman in pink top'; use vírgulas para vários objetos. Não precisa de pontos: o SAM 3 encontra e rastreia o objeto no plano (até Máx. objetos). Pressione Enter para pré-visualizar. Os pontos têm prioridade.", "Qué segmentar, en inglés: una frase breve (sustantivo y uno o dos rasgos), p. ej., 'woman in pink top'; usa comas para varios objetos. No hacen falta puntos: SAM 3 lo encuentra y sigue durante el plano (hasta Máx. objetos). Pulsa Enter para previsualizar. Los puntos tienen prioridad.", "用英文描述要分割的对象:简短短语(名词加一两个特征),例如“woman in pink top”;多个对象用逗号分隔。无需标记点,SAM 3 会在镜头中查找并跟踪(最多为最大对象数)。按 Enter 预览。设置了标记点时以标记点为准。"],
"Remove this shot's mask (points, text, mask video) and mode - for shots where it picked the wrong thing": ["Remover a máscara (pontos, texto ou vídeo) e o modo deste plano — útil quando a seleção está errada", "Quitar la máscara (puntos, texto o vídeo) y el modo de este plano; útil si seleccionó el objeto equivocado", "移除此镜头的蒙版(点、文本或蒙版视频)及模式;适用于选错对象时"],
"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.": ["Vídeo em preto e branco (branco = sujeito), por exemplo, rotoscopiado em outra ferramenta. Substitui o SAM 3 apenas neste plano; o primeiro frame do vídeo corresponde ao primeiro frame do plano.", "Vídeo en blanco y negro (blanco = sujeto), por ejemplo, rotoscopiado en otra herramienta. Sustituye SAM 3 solo en este plano; su primer fotograma corresponde al primer fotograma del plano.", "黑白蒙版视频(白色表示主体),例如在其他工具中制作的 rotoscoping。它仅在此镜头替代 SAM 3,视频首帧对应镜头首帧。"],
"— none: SAM 3 (text / points) —": ["— nenhum: SAM 3 (texto / pontos) —", "— ninguno: SAM 3 (texto / puntos) —", "— 无:SAM 3(文本/标记点)—"], "— none: the global mask video (People / Filters / Masks) —": ["— nenhum: vídeo de máscara global (Pessoas / Filtros / Máscaras) —", "— ninguno: vídeo de máscara global (Personas / Filtros / Máscaras) —", "— 无:全局蒙版视频(人物/筛选/蒙版)—"],
"upload a mask video for this shot": ["enviar um vídeo de máscara para este plano", "subir un vídeo de máscara para este plano", "为此镜头上传蒙版视频"], "back to SAM 3 / the global mask": ["voltar ao SAM 3 / máscara global", "volver a SAM 3 / máscara global", "切换回 SAM 3/全局蒙版"],
"This shot uses its mask video: the text and points are ignored.": ["Este plano usa o vídeo de máscara; o texto e os pontos são ignorados.", "Este plano usa su vídeo de máscara; se ignoran el texto y los puntos.", "此镜头使用自己的蒙版视频,文本和标记点将被忽略。"],
"No mask of its own: this shot uses the global mask video (People / Filters / Masks tab).": ["Sem máscara própria: este plano usa o vídeo de máscara global (aba Pessoas / Filtros / Máscaras).", "Sin máscara propia: este plano usa el vídeo de máscara global (pestaña Personas / Filtros / Máscaras).", "没有单独蒙版:此镜头使用全局蒙版视频(People / Filters / Masks 标签页)。"],
"No mask of its own: this shot uses its part of the whole-video track (People / Filters / Masks tab).": ["Sem máscara própria: este plano usa sua parte do rastreamento do vídeo inteiro (aba Pessoas / Filtros / Máscaras).", "Sin máscara propia: este plano usa su parte del seguimiento de todo el vídeo (pestaña Personas / Filtros / Máscaras).", "没有单独蒙版:此镜头使用整段视频跟踪中对应的部分(People / Filters / Masks 标签页)。"],
"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.": ["Opacidade da máscara de geração: valor do branco dentro da máscara (1 = branco total, padrão). O H3 interpreta o cinza como intensidade de regeneração: em 0,85, a região começa com 85% de ruído e preserva cerca de 15% do original. Valores de 0,8–0,9 mantêm pose, contorno e iluminação; valores muito baixos copiam a pessoa original.", "Opacidad de la máscara de generación: valor del blanco dentro de la máscara (1 = blanco total, predeterminado). H3 interpreta el gris como intensidad de regeneración: con 0,85, la zona empieza con un 85 % de ruido y conserva cerca del 15 % del original. Valores de 0,8–0,9 mantienen pose, silueta e iluminación; valores demasiado bajos copian a la persona original.", "生成蒙版的不透明度:蒙版白色区域的数值(1 = 全白,为默认值)。H3 将灰度解释为重绘强度:0.85 表示区域从 85% 噪声开始,约保留 15% 原图。0.8–0.9 可保留姿势、轮廓和光线;过低会复制原人物。"],
"keeps ~{…}% of the original inside the mask": ["preserva ~{…}% do original dentro da máscara", "conserva ~{…}% del original dentro de la máscara", "保留蒙版内约 {…}% 的原图"], "regenerates the masked area completely": ["regenera totalmente a região mascarada", "regenera por completo la zona enmascarada", "完全重绘蒙版区域"],
"set BFS Shot H3 Conditioning → inpaint to ": ["configure BFS Shot H3 Conditioning → inpaint como ", "configura BFS Shot H3 Conditioning → inpaint como ", "将 BFS Shot H3 Conditioning → inpaint 设置为 "],
"its outline on the result is pasted too, so a bigger silhouette is not cut": ["o contorno também é colado no resultado para não cortar uma silhueta maior", "también se pega su silueta en el resultado para no recortar una figura más grande", "结果中也会贴回其轮廓,避免裁掉更大的主体"],
"who is replaced, e.g. the young woman in a pink crop top (empty = the person)": ["quem será substituído, por exemplo, a jovem de top rosa (vazio = a pessoa)", "quién se sustituye, p. ej., la joven con top rosa (vacío = la persona)", "要替换谁,例如穿粉色短上衣的年轻女性(留空 = 人物)"],
"copy (the {shot} placeholder in the prompt gets it automatically at run time)": ["copiar (o marcador {shot} do prompt recebe isso durante a execução)", "copiar (el marcador {shot} del prompt lo inserta automáticamente al ejecutar)", "复制(运行时会自动填入提示词中的 {shot})"],
"SAM 3 mask (text or points), crop, {target}": ["Máscara SAM 3 (texto ou pontos), recorte, {target}", "Máscara SAM 3 (texto o puntos), recorte, {target}", "SAM 3 蒙版(文本或点)、裁剪、{target}"],
"How much of the previous result's end goes before this shot. 5 frames is a short context; longer windows carry more motion and use more memory.": ["Quantos frames finais do resultado anterior são colocados antes deste plano. 5 frames dão pouco contexto; janelas maiores carregam mais movimento e usam mais memória.", "Cuántos fotogramas finales del resultado anterior se colocan antes de este plano. 5 fotogramas dan poco contexto; ventanas más largas conservan más movimiento y usan más memoria.", "将上一结果的多少末尾帧放在此镜头之前。5 帧是较短上下文;更长窗口保留更多动作,也占用更多内存。"],
"Which frame of the previous shot's result": ["Qual frame do resultado do plano anterior", "Qué fotograma del resultado del plano anterior", "上一镜头结果中的哪一帧"],
"the first shot uses only its references": ["o primeiro plano usa apenas suas referências", "el primer plano solo usa sus referencias", "第一个镜头仅使用自己的参考图"],
"⚠ a camera cut starts this shot: history is skipped (it only carries a continuing take)": ["⚠ este plano começa após um corte de câmera: o histórico é ignorado (ele só funciona em uma tomada contínua)", "⚠ este plano empieza tras un corte de cámara: se omite el historial (solo sirve para una toma continua)", "⚠ 此镜头从镜头切换开始:跳过历史帧(历史帧只适用于连续长镜头)"],
"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.": ["o final do resultado do plano ANTERIOR continua neste plano (loop em fila: planos executados em ordem). Funciona com todos os modos de máscara, recorte e tela duet.", "el final del resultado del plano ANTERIOR continúa en este (bucle en cola: los planos se ejecutan en orden). Funciona con todos los modos de máscara, recorte y lienzo duet.", "上一镜头结果的末尾延续到此镜头(队列循环按顺序运行)。支持所有蒙版模式、裁剪和 duet 画布。"],
"uses a frame of the PREVIOUS shot's result (queue loop, or auto loop with BFS Shot H3 Duet)": ["usa um frame do resultado do plano ANTERIOR (loop em fila ou loop automático com BFS Shot H3 Duet)", "usa un fotograma del resultado del plano ANTERIOR (bucle en cola o automático con BFS Shot H3 Duet)", "使用上一镜头结果中的一帧(队列循环,或搭配 BFS Shot H3 Duet 的自动循环)"],
"the points, on each shot's frame at the same relative position (a subject that stays in place)": ["os pontos em cada plano, na mesma posição relativa do frame (para um sujeito que permanece no lugar)", "los puntos en cada plano, en la misma posición relativa del fotograma (para un sujeto que no se desplaza)", "在每个镜头相同相对位置的帧上放置这些点(适用于位置不变的主体)"],
"the shot's mask video (it covers only its own shot: usually you want a different one per shot)": ["o vídeo de máscara do plano (cobre apenas este plano; normalmente cada plano precisa de um vídeo diferente)", "el vídeo de máscara del plano (solo cubre este plano; normalmente se usa uno distinto por plano)", "此镜头的蒙版视频(仅覆盖此镜头;通常每个镜头需要不同的视频)"],
"the generation mode (Full frame / Frame + paste / Mask only / Crop / Crop + mask) and its mask strength (shots with a mask)": ["o modo de geração (Quadro inteiro / Quadro + colar / Somente máscara / Recorte / Recorte + máscara) e a força da máscara", "el modo de generación (Fotograma completo / Fotograma + pegar / Solo máscara / Recorte / Recorte + máscara) y la intensidad de máscara", "生成模式(整帧/整帧+贴回/仅蒙版/裁剪/裁剪+蒙版)及蒙版强度"],
"the {target} description": ["a descrição de {target}", "la descripción de {target}", "{target} 的描述"], "continuity (not on the first shot)": ["continuidade (exceto no primeiro plano)", "continuidad (excepto en el primer plano)", "连续性(第一个镜头除外)"],
"shots after this": ["planos depois deste", "planos posteriores a este", "此镜头之后的镜头"], "replaces the old “→ all” buttons": ["substitui os antigos botões “→ todos”", "sustituye los antiguos botones “→ todos”", "替代旧的“→ 全部”按钮"],
"remove (use the global reference)": ["remover (usar a referência global)", "quitar (usar la referencia global)", "移除(使用全局参考图)"], "previous shot (←)": ["plano anterior (←)", "plano anterior (←)", "上一镜头(←)"],
"Override the content filters for this shot": ["Substituir os filtros de conteúdo neste plano", "Anular los filtros de contenido para este plano", "覆盖此镜头的内容筛选设置"],
"Colored Mark keeps two independent point sets; switch regions to edit each person. Both use the frame selected below.": ["Com Marcação colorida, cada pessoa tem seu próprio conjunto de pontos. Alterne as regiões para editar cada pessoa; ambas usam o frame selecionado abaixo.", "Marca de color mantiene un conjunto de puntos independiente por persona. Cambia de región para editar cada una; ambas usan el fotograma seleccionado abajo.", "彩色标记为每个人保留独立的点集。切换区域即可编辑;两组都使用下方选定的帧。"],
"Describe second region": ["Descreva a segunda região", "Describe la segunda región", "描述第二个区域"],
"🎯 Mark with points": ["🎯 Marcar com pontos", "🎯 Marcar con puntos", "🎯 用标记点选择"],
"🎯 Points 2": ["🎯 Pontos 2", "🎯 Puntos 2", "🎯 标记点 2"],
"Region 1 · color": ["Região 1 · cor", "Región 1 · color", "区域 1 · 颜色"],
"Region 2 · color": ["Região 2 · cor", "Región 2 · color", "区域 2 · 颜色"],
"click = keep (green) · right-click or shift+click = exclude (red) · click a point to remove it": ["clique = incluir (verde) · clique direito ou shift+clique = excluir (vermelho) · clique em um ponto para removê-lo", "clic = incluir (verde) · clic derecho o shift+clic = excluir (rojo) · clic en un punto para quitarlo", "单击 = 保留(绿色)· 右键或 shift+单击 = 排除(红色)· 单击标记点可删除"],
};
export function t(value) {
const s = String(value ?? ""), lang = uiLanguage.value;
if (lang === "en") return s;
const trimmed = s.trim(), entry = TEXT[s] || TEXT[trimmed];
if (entry) return `${s.slice(0, s.indexOf(trimmed))}${entry[{ pt: 0, es: 1, zh: 2 }[lang]]}${s.slice(s.indexOf(trimmed) + trimmed.length)}`;
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(/^Region (\d+) · color$/))) return `${lang === "pt" ? "Região" : lang === "es" ? "Región" : "区域"} ${m[1]} · ${lang === "zh" ? "颜色" : lang === "pt" ? "cor" : "color"}`;
if ((m = s.match(/^Region (\d+) \((\d+)\)$/))) return `${lang === "pt" ? "Região" : lang === "es" ? "Región" : "区域"} ${m[1]} (${m[2]})`;
if ((m = s.match(/^🎯 Points 2 \((\d+)\)$/))) return `🎯 ${lang === "pt" ? "Pontos" : lang === "es" ? "Puntos" : "标记点"} 2 (${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]} 条备注`;
if ((m = s.match(/^generate at (\d+)×(\d+)$/))) return lang === "pt" ? `gerar em ${m[1]}×${m[2]}` : lang === "es" ? `generar a ${m[1]}×${m[2]}` : `生成尺寸 ${m[1]}×${m[2]}`;
if ((m = s.match(/^timeline (\S+) fps · (\d+) frames$/))) return lang === "pt" ? `linha do tempo ${m[1]} fps · ${m[2]} frames` : lang === "es" ? `línea de tiempo ${m[1]} fps · ${m[2]} fotogramas` : `时间轴 ${m[1]} fps · ${m[2]} 帧`;
if ((m = s.match(/^from the start frame · (\d+) frames$/))) return lang === "pt" ? `a partir do frame inicial · ${m[1]} frames` : lang === "es" ? `desde el fotograma inicial · ${m[1]} fotogramas` : `从起始帧开始 · ${m[1]} 帧`;
if ((m = s.match(/^🔊 audio · (.+) · (\d+) ch$/))) return lang === "pt" ? `🔊 áudio · ${m[1]} · ${m[2]} canais` : lang === "es" ? `🔊 audio · ${m[1]} · ${m[2]} canales` : `🔊 音频 · ${m[1]} · ${m[2]} 声道`;
if ((m = s.match(/^🔇 (.+) → silent track$/))) return lang === "pt" ? `🔇 ${m[1]} → faixa silenciosa` : lang === "es" ? `🔇 ${m[1]} → pista silenciosa` : `🔇 ${m[1]} → 静音音轨`;
if ((m = s.match(/^Sensitivity ([\d.]+)$/))) return lang === "pt" ? `Sensibilidade ${m[1]}` : lang === "es" ? `Sensibilidad ${m[1]}` : `灵敏度 ${m[1]}`;
if ((m = s.match(/^= (\d+) frames \(([\d.]+) s\)$/))) return lang === "pt" ? `= ${m[1]} frames (${m[2]} s)` : lang === "es" ? `= ${m[1]} fotogramas (${m[2]} s)` : `= ${m[1]} 帧(${m[2]} 秒)`;
if ((m = s.match(/^generate (\d+) frames$/))) return lang === "pt" ? `gerar ${m[1]} frames` : lang === "es" ? `generar ${m[1]} fotogramas` : `生成 ${m[1]} 帧`;
if ((m = s.match(/^(\d+) too long \(> (\d+) f\)$/))) return lang === "pt" ? `${m[1]} acima do limite (> ${m[2]} frames)` : lang === "es" ? `${m[1]} demasiado largos (> ${m[2]} fotogramas)` : `${m[1]} 个镜头过长(> ${m[2]} 帧)`;
if ((m = s.match(/^boundary @ (\d+)$/))) return lang === "pt" ? `divisão no frame ${m[1]}` : lang === "es" ? `límite en el fotograma ${m[1]}` : `分界位于第 ${m[1]} 帧`;
if ((m = s.match(/^frame (\d+) · (.+)$/))) return lang === "pt" ? `frame ${m[1]} · ${m[2]}` : lang === "es" ? `fotograma ${m[1]} · ${m[2]}` : `第 ${m[1]} 帧 · ${m[2]}`;
if ((m = s.match(/^(\d+)\/(\d+) shots · ([\d.]+)s$/))) return lang === "pt" ? `${m[1]}/${m[2]} planos · ${m[3]} s` : lang === "es" ? `${m[1]}/${m[2]} planos · ${m[3]} s` : `${m[1]}/${m[2]} 个镜头 · ${m[3]} 秒`;
if ((m = s.match(/^(\d+) warning\(s\)$/))) return lang === "pt" ? `${m[1]} aviso(s)` : lang === "es" ? `${m[1]} aviso(s)` : `${m[1]} 条警告`;
if ((m = s.match(/^first seen (.+) \(click to seek\)$/))) return lang === "pt" ? `visto primeiro em ${m[1]} (clique para ir até lá)` : lang === "es" ? `primera aparición ${m[1]} (haz clic para ir)` : `首次出现于 ${m[1]}(点击跳转)`;
if ((m = s.match(/^(\d+) people · (\d+) included$/))) return lang === "pt" ? `${m[1]} pessoas · ${m[2]} incluídas` : lang === "es" ? `${m[1]} personas · ${m[2]} incluidas` : `${m[1]} 人物 · 已包含 ${m[2]} 人`;
if ((m = s.match(/^(\d+) point\(s\) at frame (\d+)$/))) return lang === "pt" ? `${m[1]} ponto(s) no frame ${m[2]}` : lang === "es" ? `${m[1]} punto(s) en el fotograma ${m[2]}` : `第 ${m[2]} 帧有 ${m[1]} 个标记点`;
if ((m = s.match(/^mask covers ([\d.]+)%(.+)$/))) return lang === "pt" ? `máscara cobre ${m[1]}%${m[2]}` : lang === "es" ? `la máscara cubre ${m[1]}%${m[2]}` : `蒙版覆盖 ${m[1]}%${m[2]}`;
if ((m = s.match(/^last (\d+) frames \(([\d.]+) s\)$/))) return lang === "pt" ? `últimos ${m[1]} frames (${m[2]} s)` : lang === "es" ? `últimos ${m[1]} fotogramas (${m[2]} s)` : `最后 ${m[1]} 帧(${m[2]} 秒)`;
if ((m = s.match(/^Mask opacity (.+)$/))) return lang === "pt" ? `Opacidade da máscara ${m[1]}` : lang === "es" ? `Opacidad de máscara ${m[1]}` : `蒙版不透明度 ${m[1]}`;
if ((m = s.match(/^Play or scrub the video \(or click the timeline\), then ⇤ \/ ⇥\. (\d+) frames \(([\d.]+) s\) of (\d+): only this part is split, analysed, generated and joined\.$/)))
return lang === "pt" ? `Reproduza ou avance o vídeo (ou clique na linha do tempo) e use ⇤ / ⇥. ${m[1]} frames (${m[2]} s) de ${m[3]}: somente este trecho será dividido, analisado, gerado e unido.` : lang === "es" ? `Reproduce o desplaza el vídeo (o haz clic en la línea de tiempo) y usa ⇤ / ⇥. ${m[1]} fotogramas (${m[2]} s) de ${m[3]}: solo se dividirá, analizará, generará y unirá este tramo.` : `播放或拖动视频(也可点击时间轴),然后按 ⇤ / ⇥。${m[1]} 帧(${m[2]} 秒),共 ${m[3]} 帧:仅拆分、分析、生成并合并此片段。`;
return s;
}
function localizeProps(props) {
@@ -365,28 +215,23 @@ export function h(type, props, children) {
if (typeof props === "string" || Array.isArray(props)) return vueH(type, localizeChildren(props));
return vueH(type, p);
}
// Use for literal labels that must keep their native spelling (for example language names).
export const rawH = (...args) => vueH(...args);
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, 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.5, multiple: 32, generation_size_mode: "megapixels", custom_width: 1024, custom_height: 1024,
second_pass: { enabled: false, mode: "scale", scale: 2, width: 1280, height: 704, megapixels: 1, align: 32 },
detector: "adaptive", run: "recursive", between_shots: "keep", shot_cache: "output", shot_storage: "lossless", auto_continue: true,
skip_fill: "original", audio_mode: "auto", ref_details: {}, mask_cfg: {}, vlm_cfg: {}, cast: {}, cast_split: false, cast_only: false,
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,
skip_static: false, static_level: 0.004, min_frames: 0, samples: 6 },
};
// global mask settings (same defaults as the server's DEFAULT_MASK)
export const MASK_DEFAULTS = { threshold: 0.5, max_objects: 4, invert: false, fill_holes: true, temporal_expand: 2, blockify: 0,
padding: 0.15, expand: 16, feather: 12, paste: "box" };
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: "short", describe_custom: "", fit_prompt: true, temperature: 0, seed: 0,
image_max_side: 1024 };
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 = {
@@ -398,12 +243,10 @@ export const TIPS = {
"Min seconds / shot": "Shorter shots merge into a neighbour when the merge still fits the maximum.",
"Timeline fps": "The planner works on this frame rate (the source is resampled). Generated video and audio use it too.",
"Max shots": "Only the first N shots (0 = all). Handy for quick tests.",
"Megapixels": "Generation size: the source aspect ratio at this pixel area (0.5 ≈ 960×544 for 16:9). Cropped shots use it for the crop.",
"Max total seconds": "Only the first N seconds of the video (0 = all).",
"Megapixels": "Generation size: the source aspect ratio at this pixel area (0.15 ≈ 512×288). Cropped shots use it for the crop.",
"Size multiple": "Width and height snap to this multiple (32 for H3 and most video models).",
"Shot cache": "Where the generated shots are stored. Keep: ComfyUI's output folder (output/bfs_shotloop): they survive a restart, so a stopped run resumes and a single shot can be redone. Temp: ComfyUI empties it at every start.",
"Shot storage": "How each stored shot is saved. Lossless (FFV1): exact, ~12x smaller than raw frames. Smaller / Smallest (H.264 RGB): about 20x / 40x smaller with a slight loss, which reaches the final video and the continuity head. Raw: exact, biggest, needs no ffmpeg.",
"Between shots": "Keep models loaded: fastest. Free memory: after each shot, unload every model and empty the GPU, so the next shot starts clean (for low VRAM or when a later shot runs out of memory); models load again for every shot, so it is slower.",
"Run": "One run, shot by shot: one Queue renders every shot in order, storing each on disk (continuity works, resumes after a failure). Queue loop: one shot per run, the panel re-queues (keep the tab open). Auto loop: every shot at once in memory (no continuity).",
"Run": "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.",
"Skipped shots in the output": "Shots that do not run: keep the original video there, or remove them (with their audio).",
"Crop padding": "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.",
"Expand": "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).",
@@ -458,15 +301,3 @@ export const section = (title, body, { sub = "", right = null, open = null, cls
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()]);
};
// second pass size, the same arithmetic as bfs_shot_loop.second_pass_size (and Minimax H3 Latent Upscaler (3D)):
// target pixels, rounded to `align`, then to whole 16 px latent cells
export const secondPassSize = (w, h, sp) => {
if (!w || !h) return { w: 0, h: 0 };
let tw, th;
if (sp.mode === "dims") { tw = +sp.width; th = +sp.height; }
else if (sp.mode === "megapixels") { const px = +sp.megapixels * 1024 * 1024; th = Math.sqrt(px / (w / h)); tw = th * (w / h); }
else { tw = w * +sp.scale; th = h * +sp.scale; }
const al = Math.max(1, Math.round(+sp.align || 1)), r = x => Math.round(x / al) * al, c16 = x => Math.max(16, Math.round(x / 16) * 16);
return { w: c16(r(tw)), h: c16(r(th)) };
};
-2
View File
@@ -196,8 +196,6 @@ const CSS = `
.bsl .mimg img{width:100%;border-radius:6px}
.bsl .pt{position:absolute;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;border:2px solid #fff;cursor:pointer}
.bsl .pt.pos{background:#3ccf6b}.bsl .pt.neg{background:#e8455a}
.bsl .pt.cg.pos{background:var(--point-color,#00ff00)}
.bsl .pt.r2{box-shadow:0 0 0 2px #9b30ff}
`;
export function styles() {
+9 -29
View File
@@ -15,32 +15,13 @@ export function busyBar(c, msg) {
}
export function pointsModal(c) {
const { modal, segs, plan, api, setMeta } = c;
const { modal, segs, plan, api } = c;
const ms = segs.value[modal.idx];
if (!modal.open || !ms) return null;
const pointOverlays = ms.color_guide
? [1, 2].flatMap(region => (region === modal.region ? modal.points : modal.groups[region]?.points || []).map((p, k) => h("div", {
class: ["pt", "cg", region === 2 && "r2", p.label ? "pos" : "neg"],
style: `left:${p.x * 100}%;top:${p.y * 100}%;--point-color:${region === 2 ? (ms.color_guide_color2 || plan.global_color_guide_ref2 || "#9b30ff") : (ms.color_guide_color || plan.global_color_guide_ref1 || "#00ff00")}`,
title: `Region ${region}${region === modal.region ? " · click to remove" : ""}`,
onPointerdown: e => { if (region !== modal.region) return; e.stopPropagation(); modal.points = modal.points.filter((_, j) => j !== k); modal.prev = null; },
})))
: modal.points.map((p, k) => h("div", { class: ["pt", p.label ? "pos" : "neg"], style: `left:${p.x * 100}%;top:${p.y * 100}%`,
title: "click to remove", onPointerdown: e => { e.stopPropagation(); modal.points = modal.points.filter((_, j) => j !== k); modal.prev = null; } }));
return h(Teleport, { to: "body" }, h("div", { class: "bsl", style: "background:none;padding:0" }, h("div", { class: "mmodal",
onKeydown: e => e.stopPropagation(), onPointerdown: e => { if (e.target === e.currentTarget) modal.open = false; } }, [
h("div", { class: "mbox" }, [
h("div", { class: "row", style: "margin-bottom:6px" }, [h("span", { class: "sect" }, `Shot #${modal.idx + 1} · points`),
ms.color_guide ? h("label", { class: "row", style: "gap:6px;align-items:center" }, [h("span", { class: "hint" }, `Region ${modal.region} · color`),
h("select", { value: modal.region === 2 ? (ms.color_guide_color2 || plan.global_color_guide_ref2 || "#9b30ff") : (ms.color_guide_color || plan.global_color_guide_ref1 || "#00ff00"), style: "width:auto", onChange: e => {
setMeta(modal.idx, modal.region === 2 ? "color_guide_color2" : "color_guide_color", e.target.value);
if (modal.prev) c.previewMask(modal.idx, { points: modal.points, key: modal.key, text: modal.text,
preview_color: e.target.value }, true);
} },
[["#00ff00", "Green"], ["#9b30ff", "Purple"], ["#ff2020", "Red"], ["#168cff", "Blue"], ["#ffe600", "Yellow"], ["#ff00d4", "Magenta"], ["#00e5c0", "Cyan"]].map(([v, n]) => h("option", { value: v, disabled: ms.color_guide && v === (modal.region === 2 ? (ms.color_guide_color || "#00ff00") : (ms.color_guide_color2 || "#9b30ff")) }, n)))]) : null,
ms.color_guide ? h("div", { class: "row" }, [hint("Colored Mark keeps two independent point sets; switch regions to edit each person. Both use the frame selected below."),
h("button", { class: modal.region === 1 ? "pri" : "", onClick: () => c.switchPointRegion(1) }, `Region 1 (${modal.groups[1]?.points?.length || 0})`),
h("button", { class: modal.region === 2 ? "pri" : "", onClick: () => c.switchPointRegion(2) }, `Region 2 (${modal.groups[2]?.points?.length || 0})`)]) : null,
hint("click = keep (green) · right-click or shift+click = exclude (red) · click a point to remove it")]),
h("div", { class: "mimg", onContextmenu: e => e.preventDefault(), onPointerdown: e => {
const box = e.currentTarget.getBoundingClientRect();
@@ -49,7 +30,8 @@ export function pointsModal(c) {
modal.prev = null;
} }, [
h("img", { draggable: false, src: api.apiURL(`/bfs/shotloop/frame?video=${encodeURIComponent(plan.video)}&fps=${plan.fps}&f=${ms.start + modal.key}&w=900`) }),
...pointOverlays,
...modal.points.map((p, k) => h("div", { class: ["pt", p.label ? "pos" : "neg"], style: `left:${p.x * 100}%;top:${p.y * 100}%`,
title: "click to remove", onPointerdown: e => { e.stopPropagation(); modal.points = modal.points.filter((_, j) => j !== k); modal.prev = null; } })),
]),
h("div", { class: "row", style: "margin-top:6px" }, [
hint(`frame ${ms.start + modal.key} (${modal.key + 1}/${ms.len})`),
@@ -61,18 +43,16 @@ export function pointsModal(c) {
onChange: e => { modal.text = e.target.value; } }),
h("button", { onClick: () => { modal.points = []; modal.prev = null; } }, "Clear points"),
h("button", { class: "pri", disabled: !!modal.busy || !(modal.points.length || modal.text),
onClick: () => c.previewMask(modal.idx, { points: modal.points, key: modal.key, text: modal.text,
preview_color: modal.region === 2 ? (ms.color_guide_color2 || plan.global_color_guide_ref2 || "#9b30ff")
: (ms.color_guide_color || plan.global_color_guide_ref1 || "#00ff00") }, true) }, "👁 Segment"),
onClick: () => c.previewMask(modal.idx, { points: modal.points, key: modal.key, text: modal.text }, true) }, "👁 Segment"),
]),
modal.busy ? busyBar(c, modal.busy) : null,
modal.prev ? h("div", { class: "mstrip" }, [...modal.prev.frames.map(f => h("img", { src: f.src, title: `frame ${f.f}` })),
hint(modal.prev.empty ? "nothing found" : `covers ${(modal.prev.coverage * 100).toFixed(1)}%`)]) : null,
h("div", { class: "row", style: "margin-top:8px;justify-content:flex-end" }, [
h("button", { onClick: () => { modal.open = false; } }, "Cancel"),
!ms.color_guide ? 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") : null,
h("button", { disabled: !modal.points.length || ms.color_guide,
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)"),
@@ -84,9 +64,9 @@ export function pointsModal(c) {
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", "Track detected face identities to filter or split shots. Set masks here or per shot."],
["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", "Colored Mark", "Colored Mark adds a translucent color overlay to a selected region in the source video. It does not enable inpainting. Choose the color before marking the region with points or a description."],
["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."],
];
+23 -33
View File
@@ -1,15 +1,19 @@
// 👥 People / Filters / Masks tab: cast, shot filters, and global SAM 3 mask settings.
// 👥 People & masks tab: the cast found by face (each person linked to a reference) and the global SAM 3 mask settings.
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 } = c;
const { plan, people, busy, usedRefs } = c;
const fps = plan.fps;
const castN = people.value.filter(p => c.included(p.id)).length;
const castN = people.value.filter(p => c.linked(p.id)).length;
const opt = (k, label, title) => check(plan[k], v => c.setCastOpt(k, v), label, title);
return section("Cast", [
h("div", { class: "row", style: "gap:14px;margin-bottom:6px" }, [
opt("cast_assign", "Shots use their main person's reference", "a shot without its own reference takes the reference of the person with the most screen time in it"),
opt("cast_split", "Split where the main person changes", "adds a boundary when the biggest face switches to someone else (re-splits the plan)"),
opt("cast_only", "Only run shots with a detected person", "shots with no detected, non-ignored person are skipped"),
opt("cast_only", "Only run shots with a linked person", "shots where no linked person appears are skipped"),
]),
people.value.length ? h("div", { class: "cast" }, people.value.map(p => {
const cc = c.castOf(p.id);
@@ -17,6 +21,18 @@ 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"]),
cc[k] ? h("div", { class: "rbtns" }, [h("button", { title: "remove", onClick: () => c.setCast(p.id, k, "") }, "✕")]) : null,
]))),
usedRefs.value.length ? h("div", { class: "recent sm" }, usedRefs.value.map(n => h("img", { src: c.viewUrl(n), title: `${n} (click = ref, shift+click = ref 2)`,
onClick: e => c.setCast(p.id, e.shiftKey ? "ref2" : "ref", n) }))) : null,
check(cc.ignore, v => c.setCast(p.id, "ignore", v), "ignore"),
]);
})) : h("div", { class: "hint" }, "Nobody found yet."),
@@ -24,8 +40,8 @@ function castSection(c) {
h("button", { class: "pri", disabled: !!busy.value, onClick: () => c.findPeople(true) }, people.value.length ? "↻ Re-analyse people" : "👥 Find people"),
hint(people.value.length ? "faces sampled every 0.5 s and grouped by identity (InsightFace, CPU)" : "detects faces across the video and groups them into people"),
]),
], { sub: "track face identities to filter or split shots",
right: people.value.length ? pill(`${people.value.length} people · ${castN} included`) : pill("not analysed") });
], { sub: "track faces, link each person to a reference, split and assign shots by who is on screen",
right: people.value.length ? pill(`${people.value.length} people · ${castN} linked`) : pill("not analysed") });
}
function maskSection(c) {
@@ -76,32 +92,6 @@ function maskSection(c) {
counts.crop + counts.cropmask ? pill(`${counts.crop + counts.cropmask} cropped`, "warn") : null]) });
}
function filtersSection(c) {
const { plan, stats, segs, busy, filtersOn } = c;
const F = plan.filters;
const chk = (k, label) => check(F[k], v => c.setFilter(k, v), label);
const fnum = (k, step, label, help) => fld(label, h("input", { type: "number", step, min: 0, value: F[k], onChange: e => c.setFilter(k, parseFloat(e.target.value) || 0) }), help);
const skippedN = segs.value.filter(s => c.skipWhy(s)).length;
return section("Shot filters", [
h("div", { class: "row", style: "gap:14px;margin-bottom:6px" }, [
chk("person", "Needs a person"), chk("face", "Needs a face"), chk("skip_dark", "Skip dark / fades"), chk("skip_static", "Skip static shots"),
]),
h("div", { class: "grid" }, [
fnum("min_person_area", 0.01, "Min person size (0-1 of frame)", "e.g. 0.03 skips wide shots"),
fnum("max_persons", 1, "Max people (0 = any)", "skip crowds"),
fnum("min_frames", 1, "Min frames (0 = off)"),
fnum("samples", 1, "Frames sampled / shot"),
fnum("dark_level", 0.01, "Dark below (0-1)"),
fnum("static_level", 0.001, "Static below"),
]),
h("div", { class: "row", style: "margin-top:8px" }, [
h("button", { class: "pri", disabled: !!busy.value, onClick: c.analyzeContent }, "👤 Analyse people & faces"),
hint(stats.value.length ? `stats for ${stats.value.length} shots · YOLO person/face from models/ultralytics` : "runs the detectors on a few frames of every shot"),
]),
], { open: filtersOn.value || stats.value.length > 0, sub: "choose which shots run",
right: filtersOn.value ? pill(`${skippedN} skipped`, "warn") : pill("off") });
}
export function peopleTab(c) {
return h("div", { class: "card" }, [castSection(c), filtersSection(c), maskSection(c)]);
return h("div", { class: "card" }, [castSection(c), maskSection(c)]);
}
+12 -20
View File
@@ -11,10 +11,10 @@ function globalSection(c) {
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" ? "Marker color · Ref 1" : "Marker color · Ref 2"),
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 Colored Mark for this global reference",
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))),
]),
@@ -31,7 +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 are the default marker colors for the global references. Turn on Colored Mark on a shot to apply them; shot-level colors can override them."),
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" });
}
@@ -50,9 +50,6 @@ function vlmSection(c) {
h("div", { class: "grid" }, [
fld("Frames per shot", h("input", { type: "number", min: 1, max: 8, step: 1, value: VC.frames, onChange: e => c.setVlmCfg("frames", parseInt(e.target.value) || 3) })),
fld("Max tokens", h("input", { type: "number", min: 64, max: 2048, step: 32, value: VC.max_tokens, onChange: e => c.setVlmCfg("max_tokens", parseInt(e.target.value) || 1024) })),
fld("Image max side (VLM)", select(String(VC.image_max_side || 1024),
[448, 672, 896, 1024, 1280, 1536, 2048].map(n => [String(n), `${n} px`]),
v => c.setVlmCfg("image_max_side", parseInt(v) || 1024)), "VLM image sizing"),
]),
h("textarea", { style: "margin-top:6px;min-height:44px", placeholder: "Extra instruction for the VLM (optional), e.g. 'segment the woman, not the man'",
value: VC.instruction || "", onChange: e => c.setVlmCfg("instruction", e.target.value) }),
@@ -83,20 +80,15 @@ function vlmSection(c) {
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…'",
onChange: e => c.setVlmCfg("describe_custom", e.target.value) }) : null,
refSets.value.length ? h("div", { class: "dlist" }, refSets.value.map(x => {
const saved = plan.ref_details?.[x.key] || "";
const input = (field, placeholder) => h("textarea", { style: "min-height:44px", value:
typeof saved === "object" ? (saved[field] || "") : field === "details" ? saved : "",
placeholder, onChange: e => c.setDetail(x.key, { ...(typeof saved === "object" ? saved : { details: saved }), [field]: e.target.value }) });
return h("div", { class: "drow" }, [
h("div", { class: "dthumbs" }, [x.a ? h("img", { src: c.viewUrl(x.a) }) : null, x.b ? h("img", { src: c.viewUrl(x.b) }) : null]),
h("div", { style: "flex:1" }, [
hint(x.where.length > 4 ? `${x.where.slice(0, 4).join(", ")} +${x.where.length - 4}` : x.where.join(", ")),
x.b ? h("div", { class: "grid" }, [input("details", "Ref 1 description ({details})"), input("details2", "Ref 2 description ({details2})")])
: input("details", "no description yet: Describe refs, write your own, or leave it to the VLM at run time"),
]),
]);
})) : hint("Add a reference (global, per shot or per shot) first."),
refSets.value.length ? h("div", { class: "dlist" }, refSets.value.map(x => h("div", { class: "drow" }, [
h("div", { class: "dthumbs" }, [x.a ? h("img", { src: c.viewUrl(x.a) }) : null, x.b ? h("img", { src: c.viewUrl(x.b) }) : null]),
h("div", { style: "flex:1" }, [
hint(x.where.length > 4 ? `${x.where.slice(0, 4).join(", ")} +${x.where.length - 4}` : x.where.join(", ")),
h("textarea", { style: "min-height:44px", value: plan.ref_details?.[x.key] || "",
placeholder: "no description yet: Describe refs, write your own, or leave it to the VLM at run time",
onChange: e => c.setDetail(x.key, e.target.value) }),
]),
]))) : hint("Add a reference (global, per shot or per person) first."),
], { sub: "write {details} in any prompt: each shot gets the description of its own references" }),
];
}
+32 -98
View File
@@ -1,5 +1,5 @@
// ▶ Run tab: checks before running and how the loop runs (auto / queue).
import { h, fld, check, section, pill, hint, select, DEFAULTS, secondPassSize } from "./common.js";
// ▶ Run tab: checks before running, how the loop runs (auto / queue), which shots run (filters) and test limits.
import { h, fld, check, section, pill, hint, select } from "./common.js";
function checksSection(c) {
const { checks, tab } = c;
@@ -22,119 +22,53 @@ function checksSection(c) {
function loopSection(c) {
const { plan, prog, active } = c;
const num = (k, step, min) => h("input", { type: "number", step, min, value: plan[k], onChange: e => c.setPlan(k, parseFloat(e.target.value) || 0) });
return section("Run", [
h("div", { class: "grid" }, [
fld("Run", select(plan.run, [["recursive", "One run, shot by shot (recommended)"], ["queue", "Queue loop (one shot per run)"],
["auto", "Auto loop (all shots at once)"]], v => c.setPlan("run", v, c.progress))),
plan.run !== "auto" ? fld("Between shots", select(plan.between_shots || "keep", [["keep", "Keep models loaded (fastest)"],
["free", "Free memory: unload models (low VRAM)"]], v => c.setPlan("between_shots", v))) : null,
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), "from the start of the range"),
]),
plan.run !== "auto" ? h("div", { style: "margin-top:8px" }, [
h("div", { class: "row", style: "margin-bottom:4px" }, [h("b", plan.run === "queue" ? "Queue loop" : "Shot by shot"), h("span", { class: "grow" }),
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" }),
hint(`${prog.done}/${prog.count || active.value.length} shots done`)]),
h("div", { class: "prog" }, [h("div", { style: `width:${(100 * prog.done / Math.max(1, prog.count || active.value.length)).toFixed(1)}%` })]),
h("div", { class: "row", style: "margin-top:6px" }, [
plan.run === "queue" ? check(plan.auto_continue, v => c.setPlan("auto_continue", v), "Auto-queue the next shot") : null,
check(plan.auto_continue, v => c.setPlan("auto_continue", v), "Auto-queue the next shot"),
h("button", { onClick: () => c.progress(false) }, "↻ Status"), h("button", { class: "dng", onClick: () => c.progress(true) }, "⟲ Reset loop"),
]),
hint(plan.run === "queue"
? "Each run generates one shot and stores it. Nodes after BFS Shot Join only run on the last shot, with the full video."
: "One Queue renders every shot in order inside the same run: each shot is stored, then BFS Shot Join starts the next one, so continuity can read the shot before. After a failure, Queue again to resume from the stored shots.",
"display:block;margin-top:4px"),
hint("Each run generates one shot and stores it. Nodes after BFS Shot Join only run on the last shot, with the full video.", "display:block;margin-top:4px"),
]) : null,
], { sub: `${active.value.length} shot(s) will run` });
}
function memorySection(c) {
const { memory, vram, size } = c;
const m = memory.value;
if (!m.level) return null;
const k = n => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
const pct = vram.max_tokens ? Math.min(100, 100 * m.top.tok / (1.3 * vram.max_tokens)) : 0;
return section("Memory", [
function filtersSection(c) {
const { plan, stats, segs, busy, filtersOn } = c;
const F = plan.filters;
const chk = (k, label) => check(F[k], v => c.setFilter(k, v), label);
const fnum = (k, step, label, help) => fld(label, h("input", { type: "number", step, min: 0, value: F[k], onChange: e => c.setFilter(k, parseFloat(e.target.value) || 0) }), help);
const skippedN = segs.value.filter(s => c.skipWhy(s)).length;
return section("Filters", [
h("div", { class: "row", style: "gap:14px;margin-bottom:6px" }, [
chk("person", "Needs a person"), chk("face", "Needs a face"), chk("skip_dark", "Skip dark / fades"), chk("skip_static", "Skip static shots"),
]),
h("div", { class: "grid" }, [
fld("GPU", vram.gpu ? `${vram.gpu} · ${vram.total_gb} GB` : "—"),
fld("Biggest shot in this plan", m.top.i >= 0 ? `#${m.top.i + 1} · ${m.top.gen} frames · ${size.w}×${size.h} · ${k(m.top.tok)} tokens` : "—"),
fld("Biggest that ran here", vram.max_tokens ? `${k(vram.max_tokens)} tokens · peak ${vram.max_peak_gb} GB` : "nothing recorded yet"),
fnum("min_person_area", 0.01, "Min person size (0-1 of frame)", "e.g. 0.03 skips wide shots"),
fnum("max_persons", 1, "Max people (0 = any)", "skip crowds"),
fnum("min_frames", 1, "Min frames (0 = off)"),
fnum("samples", 1, "Frames sampled / shot"),
fnum("dark_level", 0.01, "Dark below (0-1)"),
fnum("static_level", 0.001, "Static below"),
]),
vram.max_tokens ? h("div", { class: "prog", style: "margin-top:6px", title: "100% = 1.3× the biggest shot that ran on this GPU" },
[h("div", { style: `width:${pct.toFixed(1)}%;${m.level === "warn" ? "background:var(--warn,#d97706)" : ""}` })]) : null,
hint({ ok: "✓ Every shot is within what already ran on this GPU.",
note: "A bit bigger than anything that ran here: it will probably fit, maybe slower.",
warn: "⚠ Much bigger than anything that ran here: it may run out of memory. Lower Megapixels or Max seconds / shot, use head 5, or set Between shots = free memory.",
info: "No shot has run on this GPU yet: after the first one, every shot is compared with what already ran here." }[m.level],
"display:block;margin-top:4px"),
], { sub: { ok: "within what ran here", note: "a bit bigger", warn: "may run out of memory", info: "no data yet" }[m.level] });
}
function cacheSection(c) {
const { plan, cache, active } = c;
if (plan.run === "auto") return null;
const n = active.value.length, ok = cache.ok || [];
const done = ok.filter(Boolean).length;
const lossy = plan.shot_storage === "smaller" || plan.shot_storage === "smallest";
return section("Stored shots", [
h("div", { class: "grid" }, [
fld("Shot cache", select(plan.shot_cache || "output", [["output", "Keep (output folder)"], ["temp", "Temp (lost when ComfyUI restarts)"]],
v => c.setPlan("shot_cache", v, c.progress))),
fld("Shot storage", select(plan.shot_storage || "lossless", [["lossless", "Lossless (FFV1, exact)"], ["smaller", "Smaller (H.264 RGB, visually lossless)"],
["smallest", "Smallest (H.264 RGB, light loss)"], ["raw", "Raw frames (.pt, biggest)"]], v => c.setPlan("shot_storage", v))),
h("div", { class: "row", style: "margin-top:8px" }, [
h("button", { class: "pri", disabled: !!busy.value, onClick: c.analyzeContent }, "👤 Analyse people & faces"),
hint(stats.value.length ? `stats for ${stats.value.length} shots · YOLO person/face from models/ultralytics` : "runs the detectors on a few frames of every shot"),
]),
lossy ? hint("⚠ The final video is assembled from the stored shots (and the continuity head reads them): a lossy storage "
+ "slightly changes the output. Lossless keeps it exact.", "display:block;margin-top:4px;color:var(--warn,#d97706)") : null,
h("div", { class: "row", style: "margin-top:8px;flex-wrap:wrap;gap:4px" }, ok.length ? ok.map((g, k) => {
const i = c.segs.value.indexOf(active.value[k]);
return h("span", { class: ["pill", g ? "ok" : ""], title: g ? "stored and up to date" : "runs on the next Queue" }, [
`#${(i >= 0 ? i : k) + 1} ${g ? "💾" : "⏳"}`,
g ? h("button", { class: "chip", style: "margin-left:4px", title: "generate this shot again on the next Queue"
+ (plan.redo_only ? " (only this shot)" : " (and the shots that continue from it)"),
onClick: () => c.redo([k], !plan.redo_only) }, "↻") : null]);
}) : [hint(n ? "Nothing stored yet for this video: Queue the workflow." : "")]),
h("div", { class: "row", style: "margin-top:6px;flex-wrap:wrap;gap:6px" }, [
hint(ok.length ? `${done}/${ok.length} stored and up to date · ${cache.mb || 0} MB` : ""),
h("span", { class: "grow" }),
check(!!plan.redo_only, v => c.setPlan("redo_only", v), "↻ redoes only that shot"),
ok.length ? h("button", { title: "every stored shot runs again on the next Queue", onClick: () => c.redo(ok.map((_, k) => k), true) }, "↻ Redo all") : null,
h("button", { disabled: !done, title: "Put the stored shots together into an MP4 in the output folder now, without generating: "
+ "one shot in memory at a time, the original video where a shot is skipped or not stored yet, with the soundtrack.",
onClick: () => c.combine() }, "🎬 Combine stored shots"),
]),
cache.combined ? h("div", { class: "row", style: "margin-top:6px" }, [
hint(`✓ ${cache.combined.shots}/${cache.combined.of} shots → `),
h("a", { href: cache.combined.url, target: "_blank", style: "margin-left:4px" }, cache.combined.filename)]) : null,
hint("Each shot remembers the settings it was made with: the next Queue runs only the shots whose settings changed "
+ "(and the ones that continue from them). Model, LoRA, seed or sampler changes are not seen by the planner: use ↻ or Redo all. "
+ (cache.folder ? `Folder: ${cache.folder}` : ""), "display:block;margin-top:6px"),
], { sub: ok.length ? `${done}/${ok.length} stored` : "nothing stored yet" });
}
// The planner only works out the second pass size; the graph upscales the latent (e.g. Minimax H3 Latent Upscaler
// (3D), target dimensions) and samples it again, reading the planner's second_pass / upscale_width / upscale_height
function secondPassSection(c) {
const { plan, size } = c;
const sp = { ...DEFAULTS.second_pass, ...(plan.second_pass || {}) };
const set = (k, v) => c.setPlan("second_pass", { ...sp, [k]: v });
const num = (k, step, min, max) => h("input", { type: "number", step, min, max, value: sp[k],
onChange: e => { const v = parseFloat(e.target.value); if (Number.isFinite(v)) set(k, v); } });
const out = secondPassSize(size.w, size.h, sp);
return section("Second pass (upscale)", [
h("div", { class: "row" }, [check(sp.enabled, v => set("enabled", v), "Enable the second pass")]),
h("div", { class: "grid", style: sp.enabled ? "" : "opacity:.55" }, [
fld("Size", select(sp.mode, [["scale", "Scale by multiplier"], ["dims", "Target dimensions"], ["megapixels", "Megapixels"]], v => set("mode", v))),
sp.mode === "scale" ? fld("Multiplier", num("scale", 0.05, 1, 4)) : null,
sp.mode === "dims" ? fld("Width", num("width", 8, 64, 8192)) : null,
sp.mode === "dims" ? fld("Height", num("height", 8, 64, 8192)) : null,
sp.mode === "megapixels" ? fld("Megapixels", num("megapixels", 0.1, 0.1, 16)) : null,
fld("Align (px)", num("align", 1, 1, 512), "32 recommended"),
fld("Result", size.w ? `${size.w}×${size.h} → ${out.w}×${out.h}` : "—"),
]),
hint("The planner outputs second_pass (on/off), upscale_width and upscale_height. Upscale the denoised latent of each shot "
+ "to that size (e.g. Minimax H3 Latent Upscaler (3D) in target dimensions mode) and sample it again before decoding; "
+ "use second_pass to switch that branch.", "display:block;margin-top:6px"),
], { sub: sp.enabled ? `on · ${out.w}×${out.h}` : "off" });
], { open: filtersOn.value || stats.value.length > 0, sub: "skipped shots do not run",
right: filtersOn.value ? pill(`${skippedN} skipped`, "warn") : pill("off") });
}
export function runTab(c) {
return h("div", { class: "card" }, [checksSection(c), memorySection(c), loopSection(c), secondPassSection(c), cacheSection(c)]);
return h("div", { class: "card" }, [checksSection(c), loopSection(c), filtersSection(c)]);
}
+93 -60
View File
@@ -23,25 +23,23 @@ function shotCard(c, s, i) {
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("🎨 Colored Mark", "on", `${s.color_guide_color || "green"} · opacity ${s.color_guide_opacity ?? 0.35}`) : 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", s.chain === "history" ? `continues the previous shot's last ${s.chainHistory} frames (history)` : s.chain === "head" ? `starts with the previous shot's last ${Math.min(s.chainHistory, 39)} frames, kept as they are (head)` : `continues from the previous shot's ${s.chainFrame} frame as ${s.chain}`) : null,
{ ok: bdg("💾", "on", "stored and up to date: the next Queue does not generate it again (Run tab → Stored shots to redo it)"),
run: bdg("⏳", "", "not stored yet, or its settings changed: the next Queue generates it") }[c.cacheOf(s)] || 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", {
src: c.personOf(id)?.thumb || "", title: `Person ${id}${id === who.main ? " (main)" : ""}${c.included(id) ? " · included" : ""}`,
class: [id === who.main && "main", c.included(id) && "lk"] })) : [h("span", { class: "t" }, "no faces")]) : null,
src: c.personOf(id)?.thumb || "", title: `Person ${id}${id === who.main ? " (main)" : ""}${c.linked(id) ? " · linked" : ""}`,
class: [id === who.main && "main", c.linked(id) && "lk"] })) : [h("span", { class: "t" }, "no faces")]) : null,
st ? h("div", { class: "t", style: "margin-top:2px" },
`👤 ${st.persons} · ${(st.person_area * 100).toFixed(1)}% · 🙂 ${st.faces} · ☀ ${(st.brightness * 100).toFixed(0)}%`) : null,
why ? h("div", { class: "t", style: "color:#ffc46b" }, why) : null,
h("div", { class: "t" }, `${fmtT(s.start, fps)} → ${fmtT(s.end, fps)} · ${s.len}f → ${s.gen}f`),
h("div", { class: "thumbs" }, [
...["ref", "ref2"].map(k => {
const own = s[k], name = own || plan["global_" + k];
return name ? h("img", { class: "rt", src: c.viewUrl(name), title: own ? name : `${name} (global)`,
style: own ? "" : "opacity:.45" }) : h("div", { class: "rt ph2" }, k);
const own = s[k], via = c.castRef(s, k), name = own || via || plan["global_" + k];
return name ? h("img", { class: "rt", src: c.viewUrl(name), title: own ? name : via ? `${name} (from the person)` : `${name} (global)`,
style: own ? "" : via ? "outline:1px dashed #8fd18f" : "opacity:.45" }) : h("div", { class: "rt ph2" }, k);
}),
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)) }),
@@ -76,20 +74,23 @@ function player(c, cur) {
]);
}
function refSlot(c, name, label, target, field) {
function refSlot(c, name, label, target, field, via = "") {
return h("div", { class: "rslot" }, [
h("div", { class: "refslot", title: name ? name + " (click to replace)" : "click to upload",
h("div", { class: ["refslot", !name && via && "via"], title: name ? name + " (click to replace)" : via ? `uses ${via}; click to upload this shot's own` : "click to upload",
onClick: () => c.uploadRef(target, field) },
name ? [h("img", { src: c.viewUrl(name) })] : [label]),
h("div", { class: "rbtns" }, [name ? h("button", { title: "remove (use the global reference)", onClick: () => c.setRef(target, field, "") }, "✕") : null]),
name ? [h("img", { src: c.viewUrl(name) })] : via ? [h("img", { src: c.viewUrl(via), style: "opacity:.4" })] : [label]),
h("div", { class: "rbtns" }, [name ? h("button", { title: "remove (use the person's / global one)", onClick: () => c.setRef(target, field, "") }, "✕") : null]),
]);
}
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 hasRef2 = !!(cur.ref2 || plan.global_ref2);
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" }, [
h("button", { class: "ghost", disabled: i === 0, title: "previous shot (←)", onClick: () => c.select(i - 1) }, "◀"),
@@ -112,8 +113,8 @@ function editor(c, cur) {
const refPrompt = section("Reference & prompt", [
h("div", { class: "row", style: "align-items:flex-start;gap:10px" }, [
h("div", { class: "refbox" }, [
refSlot(c, cur.ref, "⬆ reference", i, "ref"),
refSlot(c, cur.ref2, "⬆ ref 2", i, "ref2"),
refSlot(c, cur.ref, "⬆ reference", i, "ref", viaRef("ref")),
refSlot(c, cur.ref2, "⬆ ref 2", i, "ref2", viaRef("ref2")),
]),
h("div", { style: "flex:1;min-width:200px" }, [
h("textarea", { placeholder: "Prompt for this shot (empty = global prompt). Placeholders: {target} {details} {shot} {setting}",
@@ -124,43 +125,16 @@ function editor(c, cur) {
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) })),
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 and all shots) and the recent list", onClick: () => c.clearAllRefs() }, "✕ clear all")]) : null,
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 the global ones",
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"),
hint("empty slots use the global references (Prompts & refs tab)"),
hint("empty slots use the person's reference (Cast) or the global one (Prompts & refs tab)"),
]),
], { sub: "who the shot becomes" });
const mode = (on, title, text, onClick) => h("div", { class: ["mode", on && "on"], onClick }, [h("b", title), h("span", text)]);
const who = section("Select region", [
h("div", { class: "row", style: "margin-top:4px;align-items:center;gap:8px" }, [
check(!!cur.color_guide, v => c.setMeta(i, "color_guide", v), "🎨 Colored Mark",
"Color the selected region in the source video as a translucent visual marker. It does not create or enable an inpainting mask."),
h("label", { class: "row", style: "gap:6px;align-items:center" }, [h("span", { class: "hint" }, "Region color · Ref 1"),
h("select", { value: cur.color_guide_color || plan.global_color_guide_ref1 || "#00ff00", style: "width:auto", onChange: e => {
c.setMeta(i, "color_guide_color", e.target.value);
if (maskPrev.value[segKey(cur)]) c.previewMask(i);
} },
GUIDE_COLORS.map(([v, label]) => h("option", { value: v, disabled: v === (cur.color_guide_color2 || plan.global_color_guide_ref2 || "#9b30ff") }, label)))]),
]),
cur.color_guide ? h("details", { class: "note", style: "margin:4px 0;padding:6px 8px", open: !!cur.color_guide_text2 || hasRef2 }, [
h("summary", { style: "cursor:pointer" }, cur.color_guide_text2 || (cur.color_guide_points2 || []).length ? "Mark a second region · Ref 2" : "+ Mark a second region (optional)"),
h("div", { class: "row", style: "margin-top:8px;align-items:center;gap:8px" }, [
h("span", { class: "hint", style: "white-space:nowrap" }, "Describe second region"),
h("input", { type: "text", value: cur.color_guide_text2 || "", style: "flex:1;min-width:160px", placeholder: "e.g. the person on the left", 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("button", { onClick: () => c.openPoints(i, 2) }, (cur.color_guide_points2 || []).length ? `🎯 Points 2 (${cur.color_guide_points2.length})` : "🎯 Mark with points"),
h("select", { value: cur.color_guide_color2 || plan.global_color_guide_ref2 || "#9b30ff", onChange: e => {
c.setMeta(i, "color_guide_color2", e.target.value);
if ((cur.color_guide_points2 || []).length) c.previewMask(i, { points: cur.color_guide_points2, key: cur.color_guide_key2 || 0,
text: cur.color_guide_text2 || "", preview_color: e.target.value });
} },
GUIDE_COLORS.map(([v, label]) => h("option", { value: v, disabled: v === (cur.color_guide_color || plan.global_color_guide_ref1 || "#00ff00") }, label))),
]),
]) : null,
cur.color_guide ? h("details", { style: "margin:4px 0" }, [h("summary", { class: "hint", style: "cursor:pointer" }, `Opacity · ${Number(cur.color_guide_opacity ?? 0.35).toFixed(2)}`),
h("div", { class: "row", style: "margin-top:6px;gap:8px;align-items:center" }, [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("Keeps movement and expression visible.")])]) : null,
const who = section("Who is replaced", [
h("div", { class: "row", style: "align-items:center" }, [
h("input", { type: "text", value: cur.mask.text || "", style: "flex:1;min-width:160px",
placeholder: "mask: what to segment, in English (woman in pink top, black dog…) · Enter = preview",
@@ -179,14 +153,14 @@ function editor(c, cur) {
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 / Filters / Masks) —" : "— none: SAM 3 (text / points) —"),
[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 / Filters / 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 / Filters / Masks tab).", "display:block;margin-top:2px") : null,
: 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" : ""}`),
@@ -198,7 +172,69 @@ function editor(c, cur) {
["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.color_guide && !hasMask(cur) ? h("div", { class: "err" }, "Colored Mark needs a region selection: enter a description, add points, or choose a mask video, then preview it.") : 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." }, [
@@ -209,7 +245,7 @@ function editor(c, cur) {
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 / Filters / Masks tab) when the new subject 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"),
@@ -240,23 +276,20 @@ 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. history: the END of the previous result (5/22/39/56 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). head: the END of the previous result (5/22/39 frames) becomes the first frames of this shot, kept exactly as they are (not swapped again), and the new frames continue from them; the join cuts them off (same take only, H3 grid).",
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("option", { value: "history" }, "previous shot's end as history (long take)"),
h("option", { value: "head" }, "previous shot's end as this shot's start (kept, long take)")]),
cur.chain === "history" || cur.chain === "head"
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. 5 frames is a short context; longer windows carry more motion and use more memory.",
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)) },
(cur.chain === "head" ? [5, 22, 39] : [5, 22, 39, 56]).map(n => h("option", { value: String(n) }, `last ${n} frames (${(n / 24).toFixed(1)} s)`)))
[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 === "head" ? (cur.cut ? "⚠ a camera cut starts this shot: head is skipped (it only carries a continuing take)"
: "this shot is generated from earlier: its first frames are the PREVIOUS shot's result, pinned in the latent, and the join cuts them (queue loop: shots run in order). Costs those frames of generation per shot.")
: 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)"),
+8 -29
View File
@@ -4,11 +4,8 @@ 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;
const fps = plan.fps;
const num = (k, step = 0.1, min = 0) => h("input", { type: "number", step, min, value: plan[k],
onChange: e => c.setPlan(k, parseFloat(e.target.value) || 0,
["max_s", "min_s"].includes(k) ? () => c.autoSplit(true) : undefined) });
const sel_ = (k, opts, after) => select(plan[k], opts, v =>
c.setPlan(k, v, after || (k === "mode" ? () => c.autoSplit(true) : undefined)));
const num = (k, step = 0.1, min = 0) => h("input", { type: "number", step, min, value: plan[k], onChange: e => c.setPlan(k, parseFloat(e.target.value) || 0) });
const sel_ = (k, opts, after) => select(plan[k], opts, v => c.setPlan(k, v, after));
const au = an.value?.audio;
const source = section("Source video", [
@@ -68,44 +65,26 @@ export function videoTab(c) {
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"]], () => c.autoSplit(true))),
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(); c.autoSplit(true); } })),
onInput: e => { plan.sensitivity = parseFloat(e.target.value); }, onChange: c.save })),
fld("Frame grid", sel_("grid", Object.keys(GRIDS))),
fld("Max seconds / shot", num("max_s", 0.1, 0.2), `= ${maxLen.value} frames (${(maxLen.value / fps).toFixed(2)} s)`),
fld("Min seconds / shot", num("min_s", 0.1, 0)),
fld("Max shots (0 = all)", num("max_parts", 1, 0), "limit how many detected shots are used"),
fld("Timeline fps", num("fps", 1, 1), "re-analyses"),
]),
h("div", { class: "row", style: "margin-top:8px" }, [
h("button", { class: "pri", disabled: !plan.video || !!busy.value, onClick: () => c.autoSplit(true) }, "✂ Auto split"),
h("button", { disabled: !plan.video, onClick: c.analyze }, "↻ Re-analyse"),
detectorUsed.value ? hint(`detector: ${detectorUsed.value}`) : null,
c.allSegCount.value > c.segs.value.length ? hint(`Max shots = ${plan.max_parts}: ${c.allSegCount.value - c.segs.value.length} `
+ `more shot(s) on the timeline are left out. Set Max shots to 0 to use them all.`, "color:#f0b429") : null,
hint("Auto split replaces the boundaries; per-shot settings stay with the shot at the same position."),
]),
], { sub: "camera cuts or fixed length, on the model's frame grid" });
const updateSize = () => c.autoSplit(false);
const genFields = [
fld("Size mode", select(plan.generation_size_mode || "megapixels", [
["megapixels", "Megapixel target"], ["original", "Original video"], ["custom", "Custom dimensions"],
], v => c.setPlan("generation_size_mode", v, updateSize))),
];
if ((plan.generation_size_mode || "megapixels") === "megapixels")
genFields.push(fld("Megapixels", h("input", { type: "number", step: 0.01, min: 0.02, value: plan.megapixels,
onChange: e => c.setPlan("megapixels", parseFloat(e.target.value) || 0.5, updateSize) }), size.w ? `${size.w}×${size.h}` : ""));
if (plan.generation_size_mode === "custom") {
for (const [key, label] of [["custom_width", "Custom width"], ["custom_height", "Custom height"]])
genFields.push(fld(label, h("input", { type: "number", step: 32, min: 32, value: plan[key],
onChange: e => c.setPlan(key, Math.max(32, parseInt(e.target.value) || 1024), updateSize) })));
if (size.w) genFields.push(fld("Output size", `${size.w}×${size.h}`));
}
if (plan.generation_size_mode === "original") genFields.push(h("div", { class: "hint" }, "Original resolution is rounded to the size multiple for model compatibility."));
genFields.push(fld("Size multiple", h("input", { type: "number", step: 8, min: 8, value: plan.multiple,
onChange: e => c.setPlan("multiple", Math.max(8, parseInt(e.target.value) || 32), updateSize) })));
const gen = section("Generation size", h("div", { class: "grid" }, genFields), { sub: "cropped shots use it for the crop" });
const gen = section("Generation size", h("div", { class: "grid" }, [
fld("Megapixels", num("megapixels", 0.01, 0.02), size.w ? `${size.w}×${size.h}` : ""),
fld("Size multiple", num("multiple", 8, 8)),
]), { sub: "cropped shots use it for the crop" });
return h("div", { class: "card" }, [source, an.value ? rng : null, an.value ? split : null, an.value ? gen : null]);
}