Shot Planner / Shot Loop: split long videos into model-sized shots (1.48.0)

A video model only follows a guide inside the clip length it was trained on, so long
sources are split into shots that fit and run once per shot.

- BFS Shot Planner: interactive Vue timeline (thumbnails, cut-detector curve, draggable
  boundaries, split/merge, per-shot reference gallery with upload, per-shot prompt).
  Splits at camera cuts via PySceneDetect (adaptive/content, built-in fallback), fixed
  length, or by hand; lengths snap to the model grid (H3 17n+5, LTX/Wan 8n+1, 4n+1).
  Outputs the shots as a ComfyUI list so everything downstream runs once per shot.
- BFS Shot Unpack: guide frames, references, prompt, length, audio of one shot.
- BFS Shot H3 Conditioning: native MiniMax H3 conditioning per shot (aligned guide,
  native reference video, or both).
- BFS Shot Join: concatenates in order, trims each shot to its true length, cross-fades
  soft joins with the real overlap frames, returns the soundtrack.
- Queue loop mode: one shot per run stored on disk, auto re-queue, nodes after the join
  blocked until the last shot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alisson-anjos
2026-10-02 17:32:32 +00:00
co-authored by Claude Opus 5.5
parent f145527ea5
commit bf473e1dbd
8 changed files with 1495 additions and 2 deletions
+18
View File
@@ -123,6 +123,24 @@ The offset is applied only to appearance/reference inputs. Guide videos, masks,
and target video positions remain unchanged. In **LTX Multiple Controls**, the field is named
`identity_temporal_offset_latents` and affects only `identity_image`.
## Shot Planner / Shot Loop (1.48.0)
Split a long video into shots a video model can follow (at camera cuts via PySceneDetect, fixed
length, or by hand on a timeline), set a reference image and prompt per shot, run the rest of the
workflow once per shot, and join the results back with the original timing and soundtrack.
- **BFS Shot Planner** — interactive timeline (thumbnails, cut detector curve, draggable
boundaries, per-shot reference gallery and prompt). Outputs the shots as a ComfyUI list, so
every node downstream runs once per shot.
- **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.
Two run modes: *auto loop* (all shots in one run) and *queue loop* (one shot per run, stored on
disk, re-queued automatically; nodes after the join only run on the last shot). See
[`web/docs/BFSShotPlanner.md`](web/docs/BFSShotPlanner.md).
## Requirements
- ComfyUI
+8
View File
@@ -74,6 +74,12 @@ except Exception as _e: # noqa
# gain came from the projector + LoRA, not the CAN). Kept the file but not registered.
CAN_NODE_CLASS_MAPPINGS, CAN_NODE_DISPLAY_NAME_MAPPINGS = {}, {}
try:
from .bfs_shot_loop import NODE_CLASS_MAPPINGS as SHOT_NODE_CLASS_MAPPINGS
from .bfs_shot_loop import NODE_DISPLAY_NAME_MAPPINGS as SHOT_NODE_DISPLAY_NAME_MAPPINGS
except Exception as _e: # noqa
print(f"[BFSNodes] Shot loop nodes not loaded: {_e!r}")
SHOT_NODE_CLASS_MAPPINGS, SHOT_NODE_DISPLAY_NAME_MAPPINGS = {}, {}
try:
from .minimax_h3_downscaled_guide import NODE_CLASS_MAPPINGS as H3GUIDE_NODE_CLASS_MAPPINGS
from .minimax_h3_downscaled_guide import NODE_DISPLAY_NAME_MAPPINGS as H3GUIDE_NODE_DISPLAY_NAME_MAPPINGS
@@ -97,6 +103,7 @@ except Exception as _e: # noqa
NODE_CLASS_MAPPINGS = {
**H3GUIDE_NODE_CLASS_MAPPINGS,
**SHOT_NODE_CLASS_MAPPINGS,
**GV_NODE_CLASS_MAPPINGS,
**CAN_NODE_CLASS_MAPPINGS,
**BFS_NODE_CLASS_MAPPINGS,
@@ -122,6 +129,7 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = {
**H3GUIDE_NODE_DISPLAY_NAME_MAPPINGS,
**SHOT_NODE_DISPLAY_NAME_MAPPINGS,
**GV_NODE_DISPLAY_NAME_MAPPINGS,
**CAN_NODE_DISPLAY_NAME_MAPPINGS,
**BFS_NODE_DISPLAY_NAME_MAPPINGS,
+783
View File
@@ -0,0 +1,783 @@
"""BFS Shot Loop: plan a long video into model-sized shots, run any workflow once per shot, join.
A video model only follows a guide reliably inside the clip length it was trained on (an H3 LoRA
trained on 107 frames loses the scene at 243). This splits the source into shots that fit, at
camera cuts when there are any, and hands them to the rest of the graph as a ComfyUI *list*.
ComfyUI then runs every node that receives a list once per item, pairing the lists by index, so
shot ``i`` always gets guide ``i``, reference ``i`` and prompt ``i``. ``BFS Shot Join`` takes the
decoded list back, trims each shot to its true length and concatenates them in order.
Every shot is generated at a length the model accepts (for example 17n+5 frames for MiniMax H3).
When a shot is shorter than that, the extra guide frames are taken from the video that follows it,
so the guide never repeats or stretches; the join then cuts the result back to the shot's own
frames, which keeps the timing identical to the source. Those extra frames also give the join a
real overlap to cross-fade across boundaries that are not camera cuts.
"""
from __future__ import annotations
import base64
import hashlib
import json
import math
import os
import subprocess
from typing import Any
import numpy as np
import torch
import folder_paths
# ---------------------------------------------------------------------------- frame grids
GRIDS: dict[str, tuple[int, int]] = {
"H3 (17n+5)": (17, 5),
"LTX / Wan (8n+1)": (8, 1),
"Wan (4n+1)": (4, 1),
"any": (1, 0),
}
DEFAULT_GRID = "H3 (17n+5)"
VIDEO_EXTS = (".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v", ".gif")
IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp")
def snap_up(n: int, grid: str) -> int:
"""Smallest valid clip length >= n."""
step, off = GRIDS.get(grid, GRIDS[DEFAULT_GRID])
n = max(1, int(n))
if step == 1:
return n
if n <= off:
return off
return off + step * math.ceil((n - off) / step)
def snap_down(n: int, grid: str) -> int:
"""Largest valid clip length <= n (never below the grid's smallest length)."""
step, off = GRIDS.get(grid, GRIDS[DEFAULT_GRID])
n = max(1, int(n))
if step == 1:
return n
if n <= off:
return off
return off + step * ((n - off) // step)
# ---------------------------------------------------------------------------- video io
def _input_path(name: str) -> str:
path = folder_paths.get_annotated_filepath(name) if name else ""
if not path or not os.path.isfile(path):
raise FileNotFoundError(f"BFS Shot Loop: file not found in the input folder: {name!r}")
return path
def _probe(path: str) -> dict:
import cv2
cap = cv2.VideoCapture(path)
if not cap.isOpened():
raise ValueError(f"BFS Shot Loop: cannot open video {path}")
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
cap.release()
if fps <= 0 or n <= 0:
raise ValueError(f"BFS Shot Loop: could not read fps/frame count from {path}")
return {"fps_src": float(fps), "n_src": n, "width": w, "height": h, "duration": n / fps}
def _timeline(n_src: int, fps_src: float, fps: float) -> np.ndarray:
"""Source frame index for each frame of the resampled timeline (nearest frame, real time)."""
n = max(1, int(math.floor(n_src / fps_src * fps + 1e-6)))
idx = np.round(np.arange(n) * fps_src / fps).astype(np.int64)
return np.clip(idx, 0, n_src - 1)
def _read_frames(path: str, src_indices: np.ndarray, size: tuple[int, int] | None) -> list[np.ndarray]:
"""Decode the requested source frames (sorted, may repeat) as RGB uint8, sequentially."""
import cv2
want = sorted(set(int(i) for i in src_indices))
got: dict[int, np.ndarray] = {}
cap = cv2.VideoCapture(path)
i, k = 0, 0
last = None
while k < len(want):
ok = cap.grab()
if not ok:
break
if i == want[k]:
ok, fr = cap.retrieve()
if ok:
fr = cv2.cvtColor(fr, cv2.COLOR_BGR2RGB)
if size is not None:
fr = _fit(fr, size)
last = fr
got[i] = fr
while k < len(want) and want[k] == i:
k += 1
i += 1
cap.release()
if not got:
raise ValueError(f"BFS Shot Loop: no frames decoded from {path}")
out = []
for s in src_indices:
s = int(s)
out.append(got.get(s, last if s > max(got) else got[min(got, key=lambda x: abs(x - s))]))
return out
def _fit(img: np.ndarray, size: tuple[int, int]) -> np.ndarray:
"""Cover-resize then center-crop to (w, h)."""
import cv2
w, h = size
ih, iw = img.shape[:2]
s = max(w / iw, h / ih)
r = cv2.resize(img, (max(w, round(iw * s)), max(h, round(ih * s))),
interpolation=cv2.INTER_AREA if s < 1 else cv2.INTER_CUBIC)
y, x = (r.shape[0] - h) // 2, (r.shape[1] - w) // 2
return r[y:y + h, x:x + w]
def generation_size(src_w: int, src_h: int, megapixels: float, multiple: int) -> tuple[int, int]:
"""Aspect-preserving size with the given pixel area, both sides on the multiple grid."""
multiple = max(8, int(multiple))
area = max(0.05, float(megapixels)) * 1e6
ar = src_w / max(1, src_h)
h = math.sqrt(area / ar)
w = h * ar
w = max(multiple, int(round(w / multiple)) * multiple)
h = max(multiple, int(round(h / multiple)) * multiple)
return w, h
def _read_audio(path: str, sample_rate: int = 44100) -> dict | None:
"""Decode the whole soundtrack as float32 [1, C, T] with ffmpeg; None when silent."""
try:
probe = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
"stream=channels", "-of", "csv=p=0", path],
capture_output=True, text=True, timeout=60)
ch = int((probe.stdout.strip() or "0").split(",")[0] or 0)
if ch <= 0:
return None
ch = min(ch, 2)
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-vn", "-ac", str(ch), "-ar", str(sample_rate),
"-f", "f32le", "-"], capture_output=True, timeout=600).stdout
if not raw:
return None
a = np.frombuffer(raw, dtype=np.float32).reshape(-1, ch).T.copy()
return {"waveform": torch.from_numpy(a)[None], "sample_rate": sample_rate}
except Exception: # noqa: BLE001 - audio is optional
return None
def _slice_audio(audio: dict | None, start_s: float, dur_s: float) -> dict | None:
if audio is None:
return None
sr = audio["sample_rate"]
wf = audio["waveform"]
a, n = int(round(start_s * sr)), int(round(dur_s * sr))
seg = wf[..., a:a + n]
if seg.shape[-1] < n:
seg = torch.nn.functional.pad(seg, (0, n - seg.shape[-1]))
return {"waveform": seg.contiguous(), "sample_rate": sr}
# ---------------------------------------------------------------------------- analysis
_ANALYSIS_CACHE: dict[tuple, dict] = {}
def analyze(path: str, fps: float, thumbs: int = 120, thumb_h: int = 72) -> dict:
"""Probe, per-frame cut score on the resampled timeline, and a strip of thumbnails."""
import cv2
key = (path, os.path.getmtime(path), round(float(fps), 4), thumbs, thumb_h)
if key in _ANALYSIS_CACHE:
return _ANALYSIS_CACHE[key]
info = _probe(path)
src = _timeline(info["n_src"], info["fps_src"], fps)
n = len(src)
small = []
for fr in _read_frames(path, src, None):
g = cv2.resize(fr, (64, 36), interpolation=cv2.INTER_AREA)
small.append(g)
hsv = [cv2.calcHist([cv2.cvtColor(s, cv2.COLOR_RGB2HSV)], [0, 1], None, [16, 8], [0, 180, 0, 256]) for s in small]
hsv = [cv2.normalize(h, h).flatten() for h in hsv]
raw = np.zeros(n, np.float32)
for i in range(1, n):
pix = np.abs(small[i].astype(np.float32) - small[i - 1].astype(np.float32)).mean() / 255.0
hist = cv2.compareHist(hsv[i - 1], hsv[i], cv2.HISTCMP_BHATTACHARYYA)
raw[i] = 0.5 * pix + 0.5 * float(hist)
# local normalisation: a cut stands out from the motion around it
score = np.zeros(n, np.float32)
w = max(4, int(round(fps)))
for i in range(1, n):
lo, hi = max(1, i - w), min(n, i + w + 1)
neigh = np.concatenate([raw[lo:i], raw[i + 1:hi]])
base = float(np.median(neigh)) if len(neigh) else 0.0
score[i] = raw[i] / (base + 0.02)
k = max(1, n // max(1, thumbs))
th = []
tw = max(16, int(round(thumb_h * info["width"] / max(1, info["height"]))))
frames_for_thumbs = _read_frames(path, src[::k], (tw, thumb_h))
for j, fr in enumerate(frames_for_thumbs):
ok, buf = cv2.imencode(".jpg", cv2.cvtColor(fr, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 70])
th.append({"f": int(j * k), "src": "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode()})
out = dict(info, fps=float(fps), n=n, thumbs=th, thumb_w=tw, thumb_h=thumb_h,
raw=[round(float(x), 4) for x in raw], score=[round(float(x), 3) for x in score])
_ANALYSIS_CACHE[key] = out
return out
_PSD_CACHE: dict[tuple, list[float]] = {}
def scenedetect_cut_times(path: str, detector: str, sensitivity: float) -> list[float] | None:
"""Cut times in seconds from PySceneDetect, or None when the package is missing."""
try:
from scenedetect import SceneManager, open_video
from scenedetect.detectors import AdaptiveDetector, ContentDetector
except ImportError:
return None
sensitivity = float(min(1.0, max(0.0, sensitivity)))
key = (path, os.path.getmtime(path), detector, round(sensitivity, 3))
if key in _PSD_CACHE:
return _PSD_CACHE[key]
video = open_video(path)
min_len = max(3, int(round(video.frame_rate * 0.25)))
sm = SceneManager()
if detector == "content":
sm.add_detector(ContentDetector(threshold=45.0 - 33.0 * sensitivity, min_scene_len=min_len))
else:
sm.add_detector(AdaptiveDetector(adaptive_threshold=5.0 - 3.5 * sensitivity, min_scene_len=min_len))
sm.detect_scenes(video, show_progress=False)
times = [float(a.get_seconds()) for a, _ in sm.get_scene_list()[1:]]
_PSD_CACHE[key] = times
return times
def find_cuts(path: str, analysis: dict, detector: str, sensitivity: float) -> tuple[list[int], str]:
"""Cut frames on the resampled timeline and which detector produced them."""
fps = float(analysis["fps"])
if detector in ("adaptive", "content"):
times = scenedetect_cut_times(path, detector, sensitivity)
if times is not None:
n = analysis["n"]
return sorted({int(round(t * fps)) for t in times if 0 < round(t * fps) < n}), f"PySceneDetect {detector}"
return detect_cuts(analysis["score"], analysis["raw"], sensitivity, fps), "builtin"
def detect_cuts(score: list[float], raw: list[float], sensitivity: float, fps: float) -> list[int]:
"""Frame indices that start a new shot (built-in detector). Higher sensitivity finds more cuts."""
sensitivity = float(min(1.0, max(0.0, sensitivity)))
thr = 12.0 - 10.0 * sensitivity # local ratio threshold: 12 (strict) .. 2 (loose)
abs_min = 0.12 - 0.09 * sensitivity # ignore tiny global changes
gap = max(3, int(round(fps * 0.25))) # no two cuts closer than a quarter second
cand = [i for i in range(1, len(score)) if score[i] >= thr and raw[i] >= abs_min]
cuts: list[int] = []
for i in sorted(cand, key=lambda j: -score[j]):
if all(abs(i - c) >= gap for c in cuts):
cuts.append(i)
return sorted(cuts)
# ---------------------------------------------------------------------------- planning
def plan_segments(n: int, cuts: list[int], mode: str, max_len: int, min_len: int,
max_parts: int = 0, max_total: int = 0, manual: list[int] | None = None) -> list[dict]:
"""Split [0, n) into shots. Returns [{start, end, cut_before}]. Lengths are in frames."""
if max_total and max_total > 0:
n = min(n, int(max_total))
max_len = max(1, int(max_len))
min_len = max(1, min(int(min_len), max_len))
cuts = sorted(c for c in set(cuts or []) if 0 < c < n)
cutset = set(cuts)
if mode == "manual" and manual:
bounds = sorted(set([0, n] + [b for b in manual if 0 < b < n]))
elif mode == "fixed":
k = max(1, math.ceil(n / max_len))
bounds = sorted(set(round(n * j / k) for j in range(k + 1)))
else: # shots
bounds = [0] + cuts + [n]
# merge shots that are too short into a neighbour, as long as the merge still fits
changed = True
while changed:
changed = False
for j in range(len(bounds) - 1):
if bounds[j + 1] - bounds[j] >= min_len or len(bounds) <= 2:
continue
left = bounds[j + 1] - bounds[j - 1] if j > 0 else None
right = bounds[j + 2] - bounds[j] if j + 2 < len(bounds) else None
opts = [(v, side) for v, side in ((left, "l"), (right, "r")) if v is not None and v <= max_len]
if not opts:
continue
side = min(opts)[1]
bounds.pop(j if side == "l" else j + 1)
changed = True
break
# split shots that are too long into equal parts
out = [0]
for a, b in zip(bounds[:-1], bounds[1:]):
k = max(1, math.ceil((b - a) / max_len))
out += [a + round((b - a) * j / k) for j in range(1, k + 1)]
bounds = sorted(set(out))
segs = [{"start": a, "end": b, "cut_before": a in cutset} for a, b in zip(bounds[:-1], bounds[1:]) if b > a]
if max_parts and max_parts > 0:
segs = segs[:int(max_parts)]
return segs
DEFAULT_PLAN = {
"video": "", "fps": 24.0, "grid": DEFAULT_GRID, "mode": "shots", "max_s": 4.5, "min_s": 1.0,
"sensitivity": 0.5, "max_parts": 0, "max_total_s": 0.0, "bounds": [], "segs": [],
"global_ref": "", "global_ref2": "", "global_prompt": "", "megapixels": 0.15, "multiple": 32,
"detector": "adaptive", "run": "auto",
}
def _load_plan(plan_json: str) -> dict:
try:
p = json.loads(plan_json or "{}")
except json.JSONDecodeError as exc:
raise ValueError(f"BFS Shot Planner: the plan is not valid JSON ({exc})") from exc
out = dict(DEFAULT_PLAN)
out.update({k: v for k, v in p.items() if v is not None})
return out
def resolve_plan(plan: dict, analysis: dict, path: str | None = None) -> list[dict]:
"""Shots for this plan: the boundaries edited in the panel, or an automatic split."""
fps = float(analysis["fps"])
grid = plan["grid"]
max_len = snap_down(int(round(float(plan["max_s"]) * fps)), grid)
min_len = max(1, int(round(float(plan["min_s"]) * fps)))
max_total = int(round(float(plan.get("max_total_s") or 0) * fps))
if path:
cuts, _ = find_cuts(path, analysis, plan.get("detector", "adaptive"), float(plan["sensitivity"]))
else:
cuts = detect_cuts(analysis["score"], analysis["raw"], float(plan["sensitivity"]), fps)
manual = plan.get("bounds") or None
mode = "manual" if manual else plan["mode"]
segs = plan_segments(analysis["n"], cuts, mode, max_len, min_len,
int(plan.get("max_parts") or 0), max_total, manual)
meta = plan.get("segs") or []
for i, s in enumerate(segs):
m = meta[i] if i < len(meta) and isinstance(meta[i], dict) else {}
s["enabled"] = bool(m.get("enabled", True))
s["ref"] = m.get("ref") or ""
s["ref2"] = m.get("ref2") or ""
s["prompt"] = m.get("prompt") or ""
s["cut_before"] = bool(s.get("cut_before")) or (s["start"] in cuts)
s["gen_len"] = snap_up(s["end"] - s["start"], grid)
return segs
# ---------------------------------------------------------------------------- queue loop state
def run_id_for(plan_json: str, path: str) -> str:
h = hashlib.sha1((plan_json + str(os.path.getmtime(path))).encode()).hexdigest()[:16]
return h
def run_dir(run_id: str) -> str:
d = os.path.join(folder_paths.get_temp_directory(), "bfs_shotloop", run_id)
os.makedirs(d, exist_ok=True)
return d
def run_state(run_id: str) -> dict:
f = os.path.join(run_dir(run_id), "state.json")
if os.path.isfile(f):
with open(f) as fh:
return json.load(fh)
return {"done": [], "count": 0}
def save_state(run_id: str, st: dict) -> None:
with open(os.path.join(run_dir(run_id), "state.json"), "w") as fh:
json.dump(st, fh)
def _notify(event: str, data: dict) -> None:
try:
from server import PromptServer
PromptServer.instance.send_sync(event, data)
except Exception: # noqa: BLE001 - headless runs have no UI to notify
pass
# ---------------------------------------------------------------------------- images
def _load_image(name: str) -> torch.Tensor | None:
if not name:
return None
from PIL import Image, ImageOps
img = ImageOps.exif_transpose(Image.open(_input_path(name))).convert("RGB")
return torch.from_numpy(np.asarray(img).astype(np.float32) / 255.0)[None]
def _grey(h: int = 64, w: int = 64) -> torch.Tensor:
return torch.full((1, h, w, 3), 0.5)
# ---------------------------------------------------------------------------- nodes
class BFSShotPlanner:
"""Split a video into shots that fit the model, with a reference and prompt per shot."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"plan": ("STRING", {"default": json.dumps(DEFAULT_PLAN), "multiline": True,
"tooltip": "The panel writes this. JSON with the video, split settings, "
"boundaries and the per-shot reference and prompt."}),
},
"optional": {
"ref_image": ("IMAGE", {"tooltip": "Default reference for every shot that has none of its own "
"(overrides the panel's global reference)."}),
"ref_image_2": ("IMAGE", {"tooltip": "Default second reference (e.g. a full-body photo)."}),
"prompt": ("STRING", {"forceInput": True,
"tooltip": "Default prompt for shots without their own "
"(overrides the panel's global prompt)."}),
},
}
RETURN_TYPES = ("BFS_SHOT", "INT", "FLOAT", "INT", "INT", "AUDIO", "STRING")
RETURN_NAMES = ("shots", "count", "fps", "width", "height", "audio", "summary")
OUTPUT_IS_LIST = (True, False, False, False, False, False, False)
OUTPUT_TOOLTIPS = (
"One item per shot. Every node that receives this list runs once per shot; connect it to "
"BFS Shot Unpack or BFS Shot H3 Conditioning, sample, decode, then BFS Shot Join.",
"Number of shots that will run.", "Timeline frame rate.", "Generation width.",
"Generation height.", "The whole soundtrack, trimmed to the planned duration.",
"Human-readable plan.")
FUNCTION = "plan_shots"
CATEGORY = "BFS/shot loop"
DESCRIPTION = ("Split a long video into model-sized shots (at camera cuts, fixed length, or by hand), "
"give every shot its own reference image and prompt, and run the rest of the graph once "
"per shot. Join the decoded shots with BFS Shot Join.")
@classmethod
def IS_CHANGED(cls, plan, **kwargs):
try:
if _load_plan(plan).get("run") == "queue":
return float("nan") # the next shot depends on the loop state on disk
except ValueError:
pass
return plan
def plan_shots(self, plan, ref_image=None, ref_image_2=None, prompt=None):
p = _load_plan(plan)
path = _input_path(p["video"])
fps = float(p["fps"])
a = analyze(path, fps)
segs = resolve_plan(p, a, path)
segs = [s for s in segs if s["enabled"]]
if not segs:
raise ValueError("BFS Shot Planner: no shots are enabled.")
W, H = generation_size(a["width"], a["height"], float(p["megapixels"]), int(p["multiple"]))
src = _timeline(a["n_src"], a["fps_src"], fps)
audio = _read_audio(path)
g_ref = ref_image if ref_image is not None else _load_image(p.get("global_ref", ""))
g_ref2 = ref_image_2 if ref_image_2 is not None else _load_image(p.get("global_ref2", ""))
g_prompt = prompt if prompt is not None else p.get("global_prompt", "")
queue = p.get("run") == "queue"
rid = run_id_for(plan, path) if queue else ""
todo = list(range(len(segs)))
if queue:
st = run_state(rid)
st["count"] = len(segs)
save_state(rid, st)
pending = [i for i in todo if i not in st["done"]]
todo = pending[:1] if pending else [len(segs) - 1]
shots = []
for i, s in enumerate(segs):
if i not in todo:
continue
idx = np.arange(s["start"], s["start"] + s["gen_len"])
idx = np.clip(idx, 0, a["n"] - 1) # past the end: hold the last frame
frames = _read_frames(path, src[idx], (W, H))
ft = torch.from_numpy(np.stack(frames).astype(np.float32) / 255.0)
ref = _load_image(s["ref"]) if s["ref"] else g_ref
ref2 = _load_image(s["ref2"]) if s["ref2"] else g_ref2
shots.append({
"index": i, "count": len(segs), "start": s["start"], "end": s["end"],
"length": s["end"] - s["start"], "gen_length": s["gen_len"], "fps": fps,
"cut_before": s["cut_before"], "width": W, "height": H, "frames": ft,
"ref": ref, "ref2": ref2, "prompt": s["prompt"] or g_prompt or "",
"audio": _slice_audio(audio, s["start"] / fps, s["gen_len"] / fps),
"run_id": rid, "queue": queue,
})
total = sum(s["end"] - s["start"] for s in segs)
full_audio = _slice_audio(audio, segs[0]["start"] / fps, total / fps) if audio else None
lines = [f"{len(segs)} shots, {total} frames ({total / fps:.2f}s) at {fps:g} fps, {W}x{H}"
+ (f" | queue loop: running shot {shots[0]['index'] + 1}/{len(segs)}" if queue else "")]
for s in shots:
lines.append(f"#{s['index'] + 1}: frames {s['start']}-{s['end'] - 1} ({s['length']} -> generate "
f"{s['gen_length']}){' cut' if s['cut_before'] else ''}"
f"{' ref' if s['ref'] is not None else ''}{' ref2' if s['ref2'] is not None else ''}")
return (shots, len(segs), fps, W, H, full_audio, "\n".join(lines))
class BFSShotUnpack:
"""Open one shot into plain values for any workflow (runs once per shot)."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"shot": ("BFS_SHOT",)}}
RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "STRING", "INT", "IMAGE", "AUDIO", "INT", "INT", "INT")
RETURN_NAMES = ("guide_frames", "ref_image", "ref_image_2", "prompt", "length", "first_frame",
"audio", "width", "height", "index")
OUTPUT_TOOLTIPS = (
"The shot's guide frames, already at a length the model accepts.",
"This shot's reference (a grey placeholder if none was set).",
"This shot's second reference (a grey placeholder if none was set).",
"This shot's prompt.", "Frames to generate (the grid-valid length).",
"First guide frame of the shot.", "Soundtrack of the shot (silence if the video has none).",
"Generation width.", "Generation height.", "Shot index (0-based).")
FUNCTION = "unpack"
CATEGORY = "BFS/shot loop"
DESCRIPTION = "Split one shot into its guide frames, references, prompt and length."
def unpack(self, shot):
audio = shot["audio"] or {"waveform": torch.zeros(1, 1, int(44100 * shot["gen_length"] / shot["fps"])),
"sample_rate": 44100}
return (shot["frames"], shot["ref"] if shot["ref"] is not None else _grey(),
shot["ref2"] if shot["ref2"] is not None else _grey(), shot["prompt"], shot["gen_length"],
shot["frames"][:1], audio, shot["width"], shot["height"], shot["index"])
class BFSShotH3Conditioning:
"""Native MiniMax H3 conditioning for one shot: references, prompt and the shot as a guide."""
GUIDE_MODES = ["aligned guide (Add Guide)", "native reference video (<Video 1>)", "both", "none"]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"shot": ("BFS_SHOT",),
"clip": ("CLIP",),
"vae": ("VAE",),
"guide_mode": (cls.GUIDE_MODES, {"default": cls.GUIDE_MODES[0], "tooltip":
"aligned guide: the shot's frames sit on the generated frames (MiniMax H3 Add Guide), "
"frame by frame. native reference video: the shot enters as <Video 1> like the reference "
"video input. both: the two together."}),
"use_ref_2": ("BOOLEAN", {"default": True, "tooltip": "Also pass the second reference."}),
"first_frame": (["none", "shot's first frame"], {"default": "none", "tooltip":
"Anchor the shot's own first frame as an extra aligned image guide."}),
"ref_image_size": (["match", "max"], {"default": "match"}),
},
"optional": {
"audio_vae": ("VAE",),
"with_audio": ("BOOLEAN", {"default": False, "tooltip":
"Attach the shot's soundtrack to the guide / reference video (needs audio_vae)."}),
},
}
RETURN_TYPES = ("CONDITIONING", "LATENT")
RETURN_NAMES = ("positive", "latent")
FUNCTION = "condition"
CATEGORY = "BFS/shot loop"
DESCRIPTION = ("Build MiniMax H3 conditioning for one shot with the native nodes: Reference to Video "
"(prompt, references, length) plus the shot as an aligned guide and/or reference video.")
def condition(self, shot, clip, vae, guide_mode, use_ref_2, first_frame, ref_image_size,
audio_vae=None, with_audio=False):
from comfy_extras.nodes_minimax_h3 import MiniMaxH3AddGuide, MiniMaxH3ReferenceToVideo
refs = {}
if shot["ref"] is not None:
refs["ref_image_0"] = shot["ref"]
if use_ref_2 and shot["ref2"] is not None:
refs[f"ref_image_{len(refs)}"] = shot["ref2"]
audio = shot["audio"] if (with_audio and audio_vae is not None) else None
native = guide_mode in (self.GUIDE_MODES[1], self.GUIDE_MODES[2])
aligned = guide_mode in (self.GUIDE_MODES[0], self.GUIDE_MODES[2])
kwargs = dict(clip=clip, prompt=shot["prompt"], width=shot["width"], height=shot["height"],
length=shot["gen_length"], ref_image_size=ref_image_size, vae=vae,
audio_vae=audio_vae, ref_images=refs or None)
if native:
kwargs["ref_videos"] = {"ref_video_1": shot["frames"]}
if audio is not None:
kwargs["ref_video_audios"] = {"ref_video_audio_1": audio}
positive, latent = MiniMaxH3ReferenceToVideo.execute(**kwargs).args[:2]
if aligned:
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae,
audio_vae=audio_vae if audio is not None else None,
image=shot["frames"], audio=audio).args[0]
if first_frame != "none":
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae,
image=shot["frames"][:1]).args[0]
return (positive, latent)
class BFSShotJoin:
"""Put the decoded shots back together in order, trimmed to their true lengths."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE", {"tooltip": "Decoded frames of every shot (the list from VAE Decode)."}),
"shots": ("BFS_SHOT", {"tooltip": "The shots list from BFS Shot Planner."}),
"crossfade": ("INT", {"default": 4, "min": 0, "max": 24, "tooltip":
"Frames to blend across boundaries that are not camera cuts, using the overlap each "
"shot generated past its end. 0 = hard joins everywhere."}),
},
"optional": {"audio": ("AUDIO", {"tooltip": "Soundtrack to return with the video "
"(e.g. the planner's audio output)."})},
}
INPUT_IS_LIST = True
RETURN_TYPES = ("IMAGE", "AUDIO", "FLOAT")
RETURN_NAMES = ("images", "audio", "fps")
FUNCTION = "join"
CATEGORY = "BFS/shot loop"
DESCRIPTION = "Concatenate the generated shots in order, trim each to its length and cross-fade soft joins."
def join(self, images, shots, crossfade, audio=None):
if shots and shots[0].get("queue"):
return self._join_queue(images, shots, crossfade, audio)
return self._join_all(images, shots, crossfade, audio)
def _join_queue(self, images, shots, crossfade, audio):
from comfy_execution.graph_utils import ExecutionBlocker
shot = shots[0]
rid = shot["run_id"]
d = run_dir(rid)
img = images[0]
torch.save({"frames": (img.clamp(0, 1) * 255).round().to(torch.uint8).cpu(),
"shot": {k: v for k, v in shot.items() if k in ("index", "count", "start", "end", "length",
"gen_length", "fps", "cut_before")}},
os.path.join(d, f"shot_{shot['index']:04d}.pt"))
st = run_state(rid)
if shot["index"] not in st["done"]:
st["done"].append(shot["index"])
st["count"] = shot["count"]
save_state(rid, st)
done = len(set(st["done"]))
_notify("bfs-shotloop-progress", {"run_id": rid, "done": done, "count": shot["count"]})
if done < shot["count"]:
_notify("bfs-shotloop-next", {"run_id": rid, "done": done, "count": shot["count"]})
blocker = ExecutionBlocker(None)
return (blocker, blocker, blocker)
stored = [torch.load(os.path.join(d, f"shot_{i:04d}.pt")) for i in range(shot["count"])]
imgs = [x["frames"].float() / 255.0 for x in stored]
meta = [x["shot"] for x in stored]
return self._join_all(imgs, meta, crossfade, audio)
def _join_all(self, images, shots, crossfade, audio=None):
xf = int(crossfade[0]) if crossfade else 0
if len(images) != len(shots):
raise ValueError(f"BFS Shot Join: got {len(images)} image batches for {len(shots)} shots. "
"Connect the decoded images of the same shot list.")
order = sorted(range(len(shots)), key=lambda i: shots[i]["index"])
H, W = images[order[0]].shape[1:3]
out = []
prev_tail = None # frames the previous shot generated past its end
for j in order:
img = images[j]
if img.shape[1:3] != (H, W):
img = torch.nn.functional.interpolate(img.movedim(-1, 1), size=(H, W), mode="bilinear",
align_corners=False).movedim(1, -1)
L = shots[j]["length"]
body = img[: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 xf > 0 and prev_tail is not None and not shots[j]["cut_before"]:
n = min(xf, 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] * (1 - w) + body[:n] * w
out.append(body)
prev_tail = img[L:]
video = torch.cat(out, 0)
fps = float(shots[order[0]]["fps"])
a = audio[0] if audio else None
if a is not None:
n = int(round(video.shape[0] / fps * a["sample_rate"]))
wf = a["waveform"][..., :n]
if wf.shape[-1] < n:
wf = torch.nn.functional.pad(wf, (0, n - wf.shape[-1]))
a = {"waveform": wf, "sample_rate": a["sample_rate"]}
return (video, a, fps)
NODE_CLASS_MAPPINGS = {
"BFSShotPlanner": BFSShotPlanner,
"BFSShotUnpack": BFSShotUnpack,
"BFSShotH3Conditioning": BFSShotH3Conditioning,
"BFSShotJoin": BFSShotJoin,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BFSShotPlanner": "BFS Shot Planner",
"BFSShotUnpack": "BFS Shot Unpack",
"BFSShotH3Conditioning": "BFS Shot H3 Conditioning",
"BFSShotJoin": "BFS Shot Join",
}
# ---------------------------------------------------------------------------- http api
try:
from aiohttp import web
from server import PromptServer
def _list_input(exts):
root = folder_paths.get_input_directory()
out = []
for dp, _, fs in os.walk(root):
for f in fs:
if f.lower().endswith(exts):
rel = os.path.relpath(os.path.join(dp, f), root).replace(os.sep, "/")
out.append(rel)
return sorted(out, key=str.lower)
@PromptServer.instance.routes.get("/bfs/shotloop/files")
async def _bfs_shot_files(request):
return web.json_response({"videos": _list_input(VIDEO_EXTS), "images": _list_input(IMAGE_EXTS)})
@PromptServer.instance.routes.get("/bfs/shotloop/analyze")
async def _bfs_shot_analyze(request):
name = request.query.get("video", "")
fps = float(request.query.get("fps", "24") or 24)
try:
a = analyze(_input_path(name), fps)
except Exception as exc: # noqa: BLE001 - the panel shows the reason
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response(a)
@PromptServer.instance.routes.post("/bfs/shotloop/plan")
async def _bfs_shot_plan(request):
body = await request.json()
try:
p = _load_plan(json.dumps(body.get("plan", {})))
a = analyze(_input_path(p["video"]), float(p["fps"]))
path = _input_path(p["video"])
segs = resolve_plan(p, a, path)
cuts, used = find_cuts(path, a, p.get("detector", "adaptive"), float(p["sensitivity"]))
W, H = generation_size(a["width"], a["height"], float(p["megapixels"]), int(p["multiple"]))
except Exception as exc: # noqa: BLE001
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response({"segs": segs, "cuts": cuts, "width": W, "height": H, "detector": used,
"max_len": snap_down(int(round(float(p["max_s"]) * float(p["fps"]))), p["grid"])})
@PromptServer.instance.routes.post("/bfs/shotloop/progress")
async def _bfs_shot_progress(request):
body = await request.json()
try:
plan_json = json.dumps(body.get("plan", {}))
p = _load_plan(plan_json)
rid = run_id_for(body.get("plan_raw") or plan_json, _input_path(p["video"]))
st = run_state(rid)
if body.get("reset"):
import shutil
shutil.rmtree(run_dir(rid), ignore_errors=True)
st = {"done": [], "count": st.get("count", 0)}
except Exception as exc: # noqa: BLE001
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response({"run_id": rid, "done": len(set(st["done"])), "count": st.get("count", 0)})
except Exception as _exc: # noqa: BLE001 - nodes still work without the panel
print(f"[BFSNodes] Shot loop HTTP routes not registered: {_exc!r}")
+3 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "bfsnodes"
description = "Custom ComfyUI nodes for reference-guided video: the BFS Sampler (guide-driven IC-LoRAs with auto-configured crop, mask inpainting and temporal chunking in one node), LTX Multiple Controls and Identity Transfer (IPT2V reference + source-phase RoPE), LTXV Edit Anything, Head-Swap/Anime2Real Bernini conditioning, the AMV Guide Builder, MiniMax-H3 aligned downscaled latent guides with training-matched posterior encoding and spatial coordinates, experimental overlap/sidecar source-phase RoPE and caption-only identity references, true single-frame/video targets, single-frame VAE loaders with tile control, and LoRA Surgery (read any LoRA's layers and blocks, then scale or drop them live to find which modules carry a defect, and save the result as a new LoRA)."
version = "1.47.0"
description = "Custom ComfyUI nodes for reference-guided video: the BFS Sampler (guide-driven IC-LoRAs with auto-configured crop, mask inpainting and temporal chunking in one node), LTX Multiple Controls and Identity Transfer (IPT2V reference + source-phase RoPE), LTXV Edit Anything, Head-Swap/Anime2Real Bernini conditioning, the AMV Guide Builder, MiniMax-H3 aligned downscaled latent guides with training-matched posterior encoding and spatial coordinates, experimental overlap/sidecar source-phase RoPE and caption-only identity references, true single-frame/video targets, single-frame VAE loaders with tile control, LoRA Surgery (read any LoRA's layers and blocks, then scale or drop them live to find which modules carry a defect, and save the result as a new LoRA), and the Shot Planner / Shot Loop (split long videos into model-sized shots at camera cuts with PySceneDetect on an interactive timeline, set a reference and prompt per shot, run any workflow once per shot in one run or as a queue loop, and join the results with the original timing and audio)."
version = "1.48.0"
license = {file = "LICENSE"}
dependencies = [
"torch",
@@ -10,6 +10,7 @@ dependencies = [
"safetensors",
"librosa",
"opencv-python",
"scenedetect",
"insightface==0.7.3",
"onnxruntime",
"transformers>=4.55.0",
+1
View File
@@ -6,3 +6,4 @@ librosa
opencv-python
insightface==0.7.3
onnxruntime
scenedetect
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import importlib.util
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_shotloop_test_")
# The module only needs a few folder_paths calls; stub them so the tests run without ComfyUI.
_fp = types.ModuleType("folder_paths")
_fp.get_temp_directory = lambda: _TMP
_fp.get_input_directory = lambda: _TMP
_fp.get_annotated_filepath = lambda name: str(Path(_TMP) / name)
sys.modules.setdefault("folder_paths", _fp)
class _Blocker:
def __init__(self, message):
self.message = message
_ce = types.ModuleType("comfy_execution")
_gu = types.ModuleType("comfy_execution.graph_utils")
_gu.ExecutionBlocker = _Blocker
sys.modules.setdefault("comfy_execution", _ce)
sys.modules.setdefault("comfy_execution.graph_utils", _gu)
SPEC = importlib.util.spec_from_file_location("bfs_shot_loop", ROOT / "bfs_shot_loop.py")
assert SPEC is not None and SPEC.loader is not None
SL = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(SL)
H3 = "H3 (17n+5)"
def _shot(i, start, end, cut=False, gen=None, count=1, queue=False, run_id=""):
length = end - start
return {"index": i, "count": count, "start": start, "end": end, "length": length,
"gen_length": gen or SL.snap_up(length, H3), "fps": 24.0, "cut_before": cut,
"queue": queue, "run_id": run_id}
class GridTest(unittest.TestCase):
def test_h3_grid(self):
self.assertEqual([SL.snap_up(x, H3) for x in (1, 5, 6, 22, 23, 107, 108)], [5, 5, 22, 22, 39, 107, 124])
self.assertEqual([SL.snap_down(x, H3) for x in (4, 5, 21, 22, 106, 107, 110)], [5, 5, 5, 22, 90, 107, 107])
def test_other_grids(self):
self.assertEqual(SL.snap_up(50, "LTX / Wan (8n+1)"), 57)
self.assertEqual(SL.snap_down(50, "LTX / Wan (8n+1)"), 49)
self.assertEqual(SL.snap_up(6, "Wan (4n+1)"), 9)
self.assertEqual(SL.snap_up(37, "any"), 37)
def test_generation_size_is_on_the_multiple(self):
w, h = SL.generation_size(1280, 720, 0.15, 32)
self.assertEqual((w % 32, h % 32), (0, 0))
self.assertGreater(w, h)
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_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]
self.assertEqual(bounds[:2], [(0, 50), (50, 80)])
self.assertTrue(all(e - s <= 107 for s, e in bounds))
self.assertEqual(bounds[-1][1], 300)
self.assertTrue(segs[1]["cut_before"])
def test_short_shots_merge_into_a_neighbour_that_still_fits(self):
segs = SL.plan_segments(200, [10, 100], "shots", 107, 24)
self.assertEqual([(s["start"], s["end"]) for s in segs], [(0, 100), (100, 200)])
def test_limits_and_manual_bounds(self):
self.assertEqual(len(SL.plan_segments(1000, [], "fixed", 100, 10, max_parts=3)), 3)
self.assertEqual(SL.plan_segments(1000, [], "fixed", 100, 10, max_total=250)[-1]["end"], 250)
manual = SL.plan_segments(100, [], "manual", 107, 10, manual=[30, 70])
self.assertEqual([(s["start"], s["end"]) for s in manual], [(0, 30), (30, 70), (70, 100)])
def test_builtin_detector_finds_spikes_and_respects_the_gap(self):
score = [1.0] * 100
raw = [0.01] * 100
for i in (20, 22, 60):
score[i], raw[i] = 50.0, 0.5
self.assertEqual(SL.detect_cuts(score, raw, 0.5, 24.0), [20, 60])
class JoinTest(unittest.TestCase):
def _frames(self, n, value):
return torch.full((n, 8, 8, 3), float(value))
def test_trims_to_true_lengths_in_order(self):
shots = [_shot(1, 30, 50, cut=True, gen=22), _shot(0, 0, 30, gen=39)]
imgs = [self._frames(22, 0.8), self._frames(39, 0.2)]
video, audio, fps = SL.BFSShotJoin()._join_all(imgs, shots, [0])
self.assertEqual(video.shape[0], 50)
self.assertAlmostEqual(float(video[0, 0, 0, 0]), 0.2)
self.assertAlmostEqual(float(video[-1, 0, 0, 0]), 0.8)
self.assertEqual(fps, 24.0)
def test_crossfade_only_across_soft_joins(self):
imgs = [self._frames(39, 0.0), self._frames(39, 1.0)]
soft = SL.BFSShotJoin()._join_all(imgs, [_shot(0, 0, 30, gen=39), _shot(1, 30, 60, gen=39)], [4])[0]
hard = SL.BFSShotJoin()._join_all(imgs, [_shot(0, 0, 30, gen=39), _shot(1, 30, 60, cut=True, gen=39)], [4])[0]
self.assertGreater(float(soft[31, 0, 0, 0]), 0.0)
self.assertLess(float(soft[31, 0, 0, 0]), 1.0)
self.assertEqual(float(hard[31, 0, 0, 0]), 1.0)
def test_audio_is_trimmed_to_the_video(self):
audio = {"waveform": torch.zeros(1, 2, 48000 * 10), "sample_rate": 48000}
imgs = [self._frames(39, 0.5)]
_, a, _ = SL.BFSShotJoin()._join_all(imgs, [_shot(0, 0, 24, gen=39)], [0], [audio])
self.assertEqual(a["waveform"].shape[-1], 48000)
def test_queue_mode_blocks_until_every_shot_is_stored(self):
join = SL.BFSShotJoin()
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])
self.assertEqual(video.shape[0], 45)
self.assertAlmostEqual(float(video[0, 0, 0, 0]), 0.1, places=2)
if __name__ == "__main__":
unittest.main()
+78
View File
@@ -0,0 +1,78 @@
# BFS Shot Planner / Shot Loop
Turn a long video into shots a video model can actually follow, give every shot its own
reference image and prompt, run the rest of the workflow once per shot, and join the results
back into one video with the original timing and soundtrack.
## Why
A video model only follows a guide reliably inside the clip length it was trained on. An H3
body-swap LoRA trained on 107-frame clips keeps the scene, framing and motion at 4.5 s and
loses them at 10 s, where it starts summarising the clip instead of following it frame by
frame. Splitting the source into model-sized shots, at camera cuts when there are any, keeps
every shot inside that range.
## Nodes
| node | what it does |
|---|---|
| **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 H3 Conditioning** | Ready-made MiniMax H3 conditioning for one shot, built with the native nodes (Reference to Video + Add Guide). |
| **BFS Shot Join** | Concatenates the decoded shots in order, trims each to its true length, cross-fades soft joins and returns the soundtrack. |
## How the loop works
ComfyUI runs any node that receives a list once per item and pairs several lists by index. The
planner's `shots` output is a list, so everything downstream of it (conditioning, sampler,
decode) runs once per shot, and shot *i* always gets guide *i*, reference *i* and prompt *i*.
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.
Two run modes:
- **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, the panel re-queues
the workflow after each shot. *Reset loop* starts over. Changing the plan starts a new run.
## Guide frames and lengths
Each shot is generated at a length the model accepts (frame grid: H3 `17n+5`, LTX/Wan `8n+1`,
Wan `4n+1`, or any). When a shot is shorter than that length, the extra guide frames come from
the video that follows it, so the guide never repeats or stretches. The join cuts each result
back to the shot's own frames, which keeps the timing identical to the source. Those extra
frames are also a real overlap, which the join uses to cross-fade boundaries that are not
camera cuts.
## Splitting
- **Camera cuts** (default): cuts from **PySceneDetect** (`adaptive` or `content`; install
`scenedetect`), or the built-in detector when the package is missing. Shots shorter than the
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.
- **By hand**: drag the white handles on the timeline to move a boundary, double-click the
shot bar to split, *Merge with next* to join. Your edits are what runs.
*Max seconds / shot* is converted to frames and snapped down to the grid (4.5 s at 24 fps =
107 frames for H3). *Max shots* and *Max total seconds* cap a long source.
## References and prompts
Every shot can have its own reference, second reference and prompt; shots without them use the
global ones. Connected `ref_image`, `ref_image_2` and `prompt` inputs override the panel's global
values, so an edited first frame or a prompt from another node can drive the defaults.
## MiniMax H3 with an aligned guide (example)
```
BFS Shot Planner ── shots ──► BFS Shot H3 Conditioning (clip, vae, guide_mode = aligned guide)
└─ positive, latent ─► CFGGuider / sampler ─► VAE Decode ─┐
BFS Shot Join ◄── images ─────────────────────────────────────────────────────────────────┘
└─► Create Video (fps, audio) ─► Save Video
```
For other models, use **BFS Shot Unpack** and wire `guide_frames`, `ref_image`, `prompt` and
`length` into that model's own guide and reference nodes; the list mapping works the same way.
+470
View File
@@ -0,0 +1,470 @@
/**
* BFS Shot Planner: split a long video into model-sized shots on a timeline, give each shot its own
* reference image and prompt, and run the rest of the graph once per shot.
*
* All state lives in the hidden `plan` STRING widget (JSON) so a workflow saves, reloads and shares
* exactly what you set up. The panel is a Vue app mounted into a DOM widget; the server does the video
* work (probe, thumbnails, PySceneDetect cuts, split) through /bfs/shotloop/* routes.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { createApp, ref, reactive, computed, onMounted, onBeforeUnmount, h } from "./vendor/vue.esm-browser.prod.mjs";
const GRIDS = { "H3 (17n+5)": [17, 5], "LTX / Wan (8n+1)": [8, 1], "Wan (4n+1)": [4, 1], "any": [1, 0] };
const DEFAULTS = {
video: "", fps: 24, grid: "H3 (17n+5)", mode: "shots", max_s: 4.5, min_s: 1.0, sensitivity: 0.5,
max_parts: 0, max_total_s: 0, bounds: [], segs: [], global_ref: "", global_ref2: "", global_prompt: "",
megapixels: 0.15, multiple: 32, detector: "adaptive", run: "auto", auto_continue: true,
};
const snapUp = (n, grid) => {
const [s, o] = GRIDS[grid] || GRIDS["H3 (17n+5)"]; n = Math.max(1, n | 0);
if (s === 1) return n; if (n <= o) return o; return o + s * Math.ceil((n - o) / s);
};
const snapDown = (n, grid) => {
const [s, o] = GRIDS[grid] || GRIDS["H3 (17n+5)"]; n = Math.max(1, n | 0);
if (s === 1) return n; if (n <= o) return o; return o + s * Math.floor((n - o) / s);
};
const hue = i => `hsl(${(i * 47 + 200) % 360} 55% 46%)`;
const viewUrl = name => {
if (!name) return "";
const i = name.lastIndexOf("/");
const sub = i >= 0 ? name.slice(0, i) : "", file = i >= 0 ? name.slice(i + 1) : name;
return api.apiURL(`/view?filename=${encodeURIComponent(file)}&subfolder=${encodeURIComponent(sub)}&type=input`);
};
const fmtT = (f, fps) => { const s = f / fps; return `${Math.floor(s / 60)}:${(s % 60).toFixed(2).padStart(5, "0")}`; };
function styles() {
if (document.getElementById("bfs-shotloop-css")) return;
const el = document.createElement("style");
el.id = "bfs-shotloop-css";
el.textContent = `
.bsl{font:12px/1.45 var(--font-family,system-ui,sans-serif);color:#c9c9cf;background:#17171b;border-radius:10px;
height:100%;overflow:auto;box-sizing:border-box;padding:10px;position:relative}
.bsl *{box-sizing:border-box}
.bsl .hdr{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.bsl .ttl{font-weight:600;color:#ececf1;font-size:13px;letter-spacing:.01em}
.bsl .pill{font-size:10px;padding:1px 7px;border-radius:999px;background:#25252d;color:#a9a9b4;border:1px solid #33333d}
.bsl .pill.ok{background:#173527;color:#7fe0a8;border-color:#245c40}
.bsl .pill.warn{background:#3a2a12;color:#ffc46b;border-color:#6a4a16}
.bsl .grow{flex:1}
.bsl select,.bsl input[type=number],.bsl input[type=text],.bsl textarea{background:#101014;border:1px solid #33333d;color:#e6e6ea;
border-radius:6px;padding:4px 7px;font-size:11px;font-family:inherit;width:100%}
.bsl textarea{resize:vertical;min-height:54px;font-family:ui-monospace,monospace}
.bsl input[type=range]{width:100%;accent-color:#5b8cff}
.bsl button{background:#26262e;border:1px solid #393945;color:#dcdce2;border-radius:6px;padding:4px 9px;font-size:11px;cursor:pointer;white-space:nowrap}
.bsl button:hover{background:#30303a}
.bsl button.pri{background:#3a63e0;border-color:#4b74f0;color:#fff}
.bsl button.pri:hover{background:#4672ee}
.bsl button.dng{background:#3a1f22;border-color:#6a2c33;color:#ffb3b3}
.bsl button:disabled{opacity:.45;cursor:default}
.bsl .card{background:#1d1d23;border:1px solid #2c2c35;border-radius:9px;padding:8px;margin-bottom:8px}
.bsl .card h5{margin:0 0 6px;font-size:11px;font-weight:600;color:#b9b9c4;text-transform:uppercase;letter-spacing:.06em;display:flex;gap:6px;align-items:center}
.bsl .grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px 8px}
.bsl .fld label{display:block;font-size:10px;color:#8c8c97;margin-bottom:2px}
.bsl .hint{font-size:10px;color:#7d7d88}
.bsl .err{color:#ff8f8f;background:#2a1517;border:1px solid #5a2228;border-radius:6px;padding:5px 8px;margin-bottom:8px}
.bsl .tl{position:relative;overflow-x:auto;overflow-y:hidden;border-radius:7px;background:#111115;border:1px solid #2a2a33}
.bsl .tlin{position:relative}
.bsl .strip{display:flex;height:64px;overflow:hidden}
.bsl .strip img{height:64px;object-fit:cover;flex:none;opacity:.92}
.bsl .spark{display:block;height:26px;width:100%}
.bsl .segs{position:relative;height:30px;margin-top:2px;cursor:crosshair}
.bsl .seg{position:absolute;top:2px;bottom:2px;border-radius:5px;display:flex;align-items:center;justify-content:center;
font-size:10px;color:#fff;font-weight:600;overflow:hidden;cursor:pointer;border:2px solid transparent;user-select:none}
.bsl .seg.sel{border-color:#fff;box-shadow:0 0 0 2px #5b8cff66}
.bsl .seg.off{opacity:.35;background-image:repeating-linear-gradient(45deg,#0004 0 6px,#0000 6px 12px)}
.bsl .seg.long{outline:2px solid #ff5d5d;outline-offset:-2px}
.bsl .hdl{position:absolute;top:-70px;bottom:0;width:9px;margin-left:-4px;cursor:ew-resize;z-index:3}
.bsl .hdl::after{content:"";position:absolute;left:3px;top:0;bottom:0;width:3px;background:#fff;opacity:.85;border-radius:2px;box-shadow:0 0 4px #000}
.bsl .hdl:hover::after{background:#ffd34d}
.bsl .cut{position:absolute;top:0;height:64px;width:2px;background:#ff4d6d;opacity:.9;pointer-events:none}
.bsl .ph{position:absolute;top:0;bottom:0;width:1px;background:#ffd34d;pointer-events:none;z-index:4}
.bsl .tip{position:absolute;z-index:6;pointer-events:none;background:#0d0d10ee;border:1px solid #3a3a44;border-radius:6px;padding:3px;font-size:10px;color:#ddd}
.bsl .tip img{display:block;height:84px;border-radius:4px}
.bsl .shots{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:6px}
.bsl .sc{background:#202027;border:1px solid #30303a;border-radius:8px;padding:6px;cursor:pointer;position:relative}
.bsl .sc:hover{border-color:#4a4a58}
.bsl .sc.sel{border-color:#5b8cff;box-shadow:0 0 0 1px #5b8cff}
.bsl .sc .bar{height:3px;border-radius:2px;margin-bottom:5px}
.bsl .sc .t{font-size:10px;color:#9a9aa6}
.bsl .sc .p{font-size:10px;color:#c9c9d3;margin-top:3px;height:28px;overflow:hidden}
.bsl .thumbs{display:flex;gap:4px;margin-top:4px}
.bsl .rt{width:34px;height:34px;border-radius:5px;object-fit:cover;background:#2a2a33;border:1px solid #3a3a45}
.bsl .rt.ph2{display:flex;align-items:center;justify-content:center;color:#666;font-size:9px}
.bsl .refbox{display:flex;gap:8px;align-items:center}
.bsl .refslot{width:78px;height:78px;border-radius:8px;border:1px dashed #44444f;background:#141418;display:flex;align-items:center;
justify-content:center;cursor:pointer;overflow:hidden;color:#6f6f7a;font-size:10px;text-align:center;flex:none}
.bsl .refslot img{width:100%;height:100%;object-fit:cover}
.bsl .refslot:hover{border-color:#5b8cff}
.bsl .modal{position:absolute;inset:0;background:#0b0b0ecc;z-index:20;display:flex;align-items:center;justify-content:center;padding:14px}
.bsl .mbox{background:#1b1b21;border:1px solid #3a3a45;border-radius:10px;width:100%;max-height:100%;display:flex;flex-direction:column}
.bsl .mhd{display:flex;gap:6px;align-items:center;padding:8px;border-bottom:1px solid #2c2c35}
.bsl .mgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px;padding:8px;overflow:auto}
.bsl .mi{border:2px solid transparent;border-radius:7px;overflow:hidden;cursor:pointer;background:#141418}
.bsl .mi:hover{border-color:#5b8cff}
.bsl .mi img{width:100%;height:84px;object-fit:cover;display:block}
.bsl .mi div{font-size:9px;padding:2px 4px;color:#9a9aa6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bsl .prog{height:7px;border-radius:4px;background:#26262e;overflow:hidden}
.bsl .prog>div{height:100%;background:linear-gradient(90deg,#3a63e0,#7fe0a8)}
.bsl .row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.bsl details>summary{cursor:pointer;list-style:none;user-select:none}
.bsl details>summary::-webkit-details-marker{display:none}
`;
document.head.appendChild(el);
}
function Panel(io) {
const plan = reactive({ ...DEFAULTS });
const files = reactive({ videos: [], images: [] });
const an = ref(null); // analysis from the server
const cuts = ref([]);
const detectorUsed = ref("");
const size = reactive({ w: 0, h: 0 });
const busy = ref(""); const error = ref("");
const sel = ref(0); const zoom = ref(1);
const hover = ref(null); // {frame, x}
const gallery = ref(null); // {target: index|'global', field: 'ref'|'ref2'}
const gq = ref("");
const prog = reactive({ done: 0, count: 0 });
const tlEl = ref(null);
const load = () => {
let p = {};
try { p = JSON.parse(io.getPlan() || "{}"); } catch { p = {}; }
Object.assign(plan, DEFAULTS, p);
if (!Array.isArray(plan.bounds)) plan.bounds = [];
if (!Array.isArray(plan.segs)) plan.segs = [];
};
const save = () => io.setPlan(JSON.stringify(plan));
const n = computed(() => {
if (!an.value) return 0;
const cap = plan.max_total_s > 0 ? Math.round(plan.max_total_s * plan.fps) : Infinity;
return Math.min(an.value.n, cap);
});
const maxLen = computed(() => snapDown(Math.round(plan.max_s * plan.fps), plan.grid));
const segs = computed(() => {
const N = n.value; if (!N) return [];
const b = [0, ...plan.bounds.filter(x => x > 0 && x < N), N].sort((a, c) => a - c);
const cs = new Set(cuts.value);
let out = [];
for (let i = 0; i < b.length - 1; i++) {
if (b[i + 1] <= b[i]) continue;
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 || "" });
}
if (plan.max_parts > 0) out = out.slice(0, plan.max_parts);
return out;
});
const active = computed(() => segs.value.filter(s => s.enabled));
const pxPerFrame = computed(() => {
const w = (tlEl.value?.clientWidth || 600) - 2;
return Math.max(0.2, (w / Math.max(1, n.value)) * zoom.value);
});
const tlWidth = computed(() => Math.max(1, Math.round(n.value * pxPerFrame.value)));
const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return plan.segs[i]; };
async function refreshFiles() {
try { const r = await api.fetchApi("/bfs/shotloop/files"); Object.assign(files, await r.json()); } catch (e) { /* ignore */ }
}
async function analyze() {
an.value = null; error.value = "";
if (!plan.video) return;
busy.value = "Analysing video…";
try {
const r = await api.fetchApi(`/bfs/shotloop/analyze?video=${encodeURIComponent(plan.video)}&fps=${plan.fps}`);
const j = await r.json();
if (j.error) throw new Error(j.error);
an.value = j;
await autoSplit(plan.bounds.length === 0);
} catch (e) { error.value = String(e.message || e); }
busy.value = "";
}
async function autoSplit(apply = true) {
if (!plan.video) return;
busy.value = "Detecting cuts…"; error.value = "";
try {
const body = { plan: { ...plan, bounds: apply ? [] : plan.bounds } };
const r = await api.fetchApi("/bfs/shotloop/plan", { method: "POST", body: JSON.stringify(body) });
const j = await r.json();
if (j.error) throw new Error(j.error);
cuts.value = j.cuts || []; detectorUsed.value = j.detector || ""; size.w = j.width; size.h = j.height;
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 || "" }));
sel.value = 0; save();
}
} catch (e) { error.value = String(e.message || e); }
busy.value = "";
}
async function upload(file, cb) {
const fd = new FormData(); fd.append("image", file); fd.append("type", "input"); fd.append("overwrite", "true");
busy.value = `Uploading ${file.name}…`;
try {
const r = await api.fetchApi("/upload/image", { method: "POST", body: fd });
const j = await r.json();
await refreshFiles();
cb(j.subfolder ? `${j.subfolder}/${j.name}` : j.name);
} catch (e) { error.value = `Upload failed: ${e}`; }
busy.value = "";
}
const pickFile = (accept, cb) => {
const inp = document.createElement("input"); inp.type = "file"; inp.accept = accept;
inp.onchange = () => inp.files[0] && upload(inp.files[0], cb); inp.click();
};
async function progress(reset = false) {
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; }
} catch { /* ignore */ }
}
// ---- editing
const setVideo = v => { plan.video = v; plan.bounds = []; plan.segs = []; save(); analyze(); };
const splitAt = f => {
const N = n.value; f = Math.round(f);
if (f <= 0 || f >= N || plan.bounds.includes(f)) return;
const i = segs.value.findIndex(s => f > s.start && f < s.end);
plan.bounds = [...plan.bounds, f].sort((a, c) => a - c);
plan.segs.splice(i + 1, 0, { ...(plan.segs[i] || {}) });
sel.value = i + 1; save();
};
const mergeNext = i => {
const s = segs.value[i]; if (!s || i >= segs.value.length - 1) return;
plan.bounds = plan.bounds.filter(b => b !== s.end); plan.segs.splice(i + 1, 1); save();
};
const setMeta = (i, k, v) => { meta(i)[k] = v; save(); };
const applyAll = (k) => { const v = meta(sel.value)[k] || ""; segs.value.forEach((_, i) => { meta(i)[k] = v; }); save(); };
const drag = (k, ev) => {
ev.preventDefault(); ev.stopPropagation();
const box = tlEl.value.getBoundingClientRect();
const move = e => {
const x = e.clientX - box.left + tlEl.value.scrollLeft;
const f = Math.round(x / pxPerFrame.value);
const lo = (plan.bounds[k - 1] ?? 0) + 1, hi = (plan.bounds[k + 1] ?? n.value) - 1;
plan.bounds[k] = Math.max(lo, Math.min(hi, f));
hover.value = { frame: plan.bounds[k], x };
};
const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); save(); };
window.addEventListener("pointermove", move); window.addEventListener("pointerup", up);
};
const frameAt = e => { const box = tlEl.value.getBoundingClientRect(); const x = e.clientX - box.left + tlEl.value.scrollLeft; return { frame: Math.max(0, Math.min(n.value - 1, Math.round(x / pxPerFrame.value))), x }; };
const thumbFor = f => { const t = an.value?.thumbs; if (!t?.length) return ""; let b = t[0]; for (const x of t) { if (x.f <= f) b = x; else break; } return b.src; };
// ---- queue loop events
const onProg = e => { prog.done = e.detail.done; prog.count = e.detail.count; };
const onNext = e => { onProg(e); if (plan.run === "queue" && plan.auto_continue) setTimeout(() => app.queuePrompt(0, 1), 300); };
onMounted(() => {
load(); refreshFiles(); analyze(); progress();
api.addEventListener("bfs-shotloop-progress", onProg); api.addEventListener("bfs-shotloop-next", onNext);
});
onBeforeUnmount(() => { api.removeEventListener("bfs-shotloop-progress", onProg); api.removeEventListener("bfs-shotloop-next", onNext); });
io.expose({ reload: () => { load(); analyze(); progress(); } });
// ---- view helpers
const fld = (label, input, hint) => h("div", { class: "fld" }, [h("label", label), input, hint ? h("div", { class: "hint" }, hint) : null]);
const num = (k, step = 0.1, min = 0) => h("input", { type: "number", step, min, value: plan[k], onChange: e => { plan[k] = parseFloat(e.target.value) || 0; save(); } });
const sel_ = (k, opts, after) => h("select", { value: plan[k], onChange: e => { plan[k] = e.target.value; save(); after && after(); } },
opts.map(o => h("option", { value: Array.isArray(o) ? o[0] : o }, Array.isArray(o) ? o[1] : o)));
const refSlot = (name, label, onClick, onClear) => h("div", { class: "refslot", title: name || label, onClick },
name ? [h("img", { src: viewUrl(name) })] : [label]);
return () => {
const S = segs.value, cur = S[sel.value], N = n.value, ppf = pxPerFrame.value, fps = plan.fps;
const tooLong = S.filter(s => s.len > maxLen.value).length;
const totalGen = active.value.reduce((a, s) => a + s.gen, 0);
const header = h("div", { class: "hdr" }, [
h("span", { class: "ttl" }, "🎬 Shot Planner"),
busy.value ? h("span", { class: "pill warn" }, busy.value) : (an.value ? h("span", { class: "pill ok" }, `${active.value.length} shots · ${(N / fps).toFixed(1)}s`) : null),
h("span", { class: "grow" }),
plan.run === "queue" ? h("span", { class: "pill" }, "queue loop") : h("span", { class: "pill" }, "auto loop"),
]);
const source = h("div", { class: "card" }, [
h("h5", ["Source video", detectorUsed.value ? h("span", { class: "pill" }, detectorUsed.value) : null]),
h("div", { class: "row" }, [
h("div", { style: "flex:1;min-width:180px" }, [h("select", { value: plan.video, onChange: e => setVideo(e.target.value) },
[h("option", { value: "" }, "— choose a video —"), ...files.videos.map(v => h("option", { value: v }, v))])]),
h("button", { onClick: () => pickFile("video/*", setVideo) }, "⬆ Upload"),
h("button", { onClick: refreshFiles, title: "refresh the input folder" }, "↻"),
]),
an.value ? h("div", { class: "hint", style: "margin-top:4px" },
`${an.value.width}×${an.value.height} · ${an.value.fps_src.toFixed(2)} fps · ${an.value.duration.toFixed(2)}s → timeline ${fps} fps, ${an.value.n} frames · generate at ${size.w}×${size.h}`) : null,
]);
const settings = h("details", { class: "card", open: true }, [
h("summary", h("h5", ["Split settings", h("span", { class: "hint", style: "text-transform:none;letter-spacing:0" }, `max ${maxLen.value} frames per shot (${(maxLen.value / fps).toFixed(2)}s)`)])),
h("div", { class: "grid" }, [
fld("Mode", sel_("mode", [["shots", "Camera cuts"], ["fixed", "Fixed length"]])),
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: save })),
fld("Frame grid", sel_("grid", Object.keys(GRIDS))),
fld("Max seconds / shot", num("max_s", 0.1, 0.2)),
fld("Min seconds / shot", num("min_s", 0.1, 0)),
fld("Timeline fps", num("fps", 1, 1), "re-analyses"),
fld("Max shots (0 = all)", num("max_parts", 1, 0)),
fld("Max total seconds (0 = all)", num("max_total_s", 0.5, 0)),
fld("Megapixels", num("megapixels", 0.01, 0.02)),
fld("Size multiple", num("multiple", 8, 8)),
fld("Run", sel_("run", [["auto", "Auto loop (one run)"], ["queue", "Queue loop (one shot per run)"]], progress)),
]),
h("div", { class: "row", style: "margin-top:8px" }, [
h("button", { class: "pri", disabled: !plan.video || !!busy.value, onClick: () => autoSplit(true) }, "✂ Auto split"),
h("button", { disabled: !plan.video, onClick: analyze }, "↻ Re-analyse"),
h("span", { class: "hint" }, "Drag the white handles to move a boundary · double-click the shot bar to split there"),
]),
]);
// timeline
const thumbs = an.value?.thumbs || [];
const thumbW = Math.max(8, (an.value?.thumb_w || 96) * 0 + (thumbs.length ? tlWidth.value / thumbs.length : 96));
const score = an.value?.score || [];
const maxS = Math.max(4, ...score.slice(0, N));
const sparkPts = score.slice(0, N).map((v, i) => `${(i * ppf).toFixed(1)},${(24 - Math.min(1, v / maxS) * 22).toFixed(1)}`).join(" ");
const timeline = an.value ? h("div", { class: "card" }, [
h("h5", ["Timeline", h("span", { class: "grow" }), h("span", { class: "hint", style: "text-transform:none" }, "zoom"),
h("input", { type: "range", min: 1, max: 8, step: 0.5, value: zoom.value, style: "width:110px", onInput: e => { zoom.value = parseFloat(e.target.value); } })]),
h("div", { class: "tl", ref: tlEl,
onPointermove: e => { if (!e.buttons) hover.value = frameAt(e); }, onPointerleave: () => { hover.value = null; } }, [
h("div", { class: "tlin", style: `width:${tlWidth.value}px` }, [
h("div", { class: "strip" }, thumbs.map(t => h("img", { src: t.src, style: `width:${thumbW}px` }))),
...cuts.value.filter(c => c < N).map(c => h("div", { class: "cut", style: `left:${c * ppf}px`, title: `cut @ ${c}` })),
h("svg", { class: "spark", viewBox: `0 0 ${tlWidth.value} 26`, preserveAspectRatio: "none" },
[h("polyline", { points: sparkPts, fill: "none", stroke: "#ff7a90", "stroke-width": 1, "vector-effect": "non-scaling-stroke" })]),
h("div", { class: "segs", onDblclick: e => splitAt(frameAt(e).frame) }, [
...S.map((s, i) => h("div", {
class: ["seg", i === sel.value && "sel", !s.enabled && "off", s.len > maxLen.value && "long"],
style: `left:${s.start * ppf}px;width:${Math.max(2, s.len * ppf - 1)}px;background:${hue(i)}`,
title: `#${i + 1} · frames ${s.start}-${s.end - 1} · ${s.len} → ${s.gen}`, onClick: () => { sel.value = i; },
}, s.len * ppf > 26 ? `${i + 1}` : "")),
...plan.bounds.filter(b => b < N).map((b, k) => h("div", { class: "hdl", style: `left:${b * ppf}px`, title: `boundary @ ${b}`, onPointerdown: e => drag(k, e) })),
]),
hover.value ? h("div", { class: "ph", style: `left:${hover.value.x}px` }) : null,
]),
hover.value ? h("div", { class: "tip", style: `left:${Math.min(tlWidth.value - 160, Math.max(0, hover.value.x - (tlEl.value?.scrollLeft || 0) - 70))}px;top:2px` },
[h("img", { src: thumbFor(hover.value.frame) }), h("div", `frame ${hover.value.frame} · ${fmtT(hover.value.frame, fps)}`)]) : null,
]),
h("div", { class: "row", style: "margin-top:6px" }, [
h("span", { class: "pill" }, `${S.length} shots`), h("span", { class: "pill" }, `${cuts.value.length} cuts`),
h("span", { class: "pill" }, `generate ${totalGen} frames`),
tooLong ? h("span", { class: "pill warn" }, `${tooLong} shot(s) longer than ${maxLen.value} frames`) : null,
]),
]) : null;
// shot cards
const cards = S.length ? h("div", { class: "card" }, [
h("h5", "Shots"),
h("div", { class: "shots" }, S.map((s, i) => h("div", { class: ["sc", i === sel.value && "sel"], onClick: () => { sel.value = i; } }, [
h("div", { class: "bar", style: `background:${hue(i)}` }),
h("div", { class: "row" }, [h("b", `#${i + 1}`), s.cut ? h("span", { class: "pill" }, "cut") : null, !s.enabled ? h("span", { class: "pill warn" }, "off") : null]),
h("div", { class: "t" }, `${fmtT(s.start, fps)} → ${fmtT(s.end, fps)} · ${s.len}f → ${s.gen}f`),
h("div", { class: "thumbs" }, [
s.ref || plan.global_ref ? h("img", { class: "rt", src: viewUrl(s.ref || plan.global_ref), style: s.ref ? "" : "opacity:.45" }) : h("div", { class: "rt ph2" }, "ref"),
s.ref2 || plan.global_ref2 ? h("img", { class: "rt", src: viewUrl(s.ref2 || plan.global_ref2), style: s.ref2 ? "" : "opacity:.45" }) : h("div", { class: "rt ph2" }, "ref2"),
h("img", { class: "rt", src: thumbFor(s.start + Math.floor(s.len / 2)), style: "width:56px" }),
]),
h("div", { class: "p" }, s.prompt ? s.prompt : (plan.global_prompt ? "↳ global prompt" : "— no prompt —")),
]))),
]) : null;
// editor for the selected shot
const editor = cur ? h("div", { class: "card" }, [
h("h5", [`Shot #${sel.value + 1}`, h("span", { class: "hint", style: "text-transform:none" }, `frames ${cur.start}–${cur.end - 1} · ${cur.len} → generate ${cur.gen}`)]),
h("div", { class: "row", style: "align-items:flex-start;gap:10px" }, [
h("div", { class: "refbox" }, [
refSlot(cur.ref, "+ reference\n(uses global)", () => { gallery.value = { target: sel.value, field: "ref" }; }),
refSlot(cur.ref2, "+ ref 2\n(uses global)", () => { gallery.value = { target: sel.value, field: "ref2" }; }),
]),
h("div", { style: "flex:1;min-width:200px" }, [
h("textarea", { placeholder: "Prompt for this shot (empty = global prompt)", value: cur.prompt, onChange: e => setMeta(sel.value, "prompt", e.target.value) }),
]),
]),
h("div", { class: "row", style: "margin-top:6px" }, [
h("button", { onClick: () => setMeta(sel.value, "enabled", !cur.enabled) }, cur.enabled ? "⏸ Disable" : "▶ Enable"),
h("button", { onClick: () => splitAt(cur.start + Math.floor(cur.len / 2)) }, "✂ Split in half"),
h("button", { disabled: sel.value >= S.length - 1, onClick: () => mergeNext(sel.value) }, "⇥ Merge with next"),
h("button", { onClick: () => applyAll("ref") }, "Ref → all"), h("button", { onClick: () => applyAll("prompt") }, "Prompt → all"),
h("button", { class: "dng", onClick: () => { ["ref", "ref2", "prompt"].forEach(k => { meta(sel.value)[k] = ""; }); save(); } }, "Use global"),
]),
]) : null;
const globals = h("div", { class: "card" }, [
h("h5", "Global (used by shots without their own)"),
h("div", { class: "row", style: "align-items:flex-start;gap:10px" }, [
h("div", { class: "refbox" }, [
refSlot(plan.global_ref, "+ global\nreference", () => { gallery.value = { target: "global", field: "global_ref" }; }),
refSlot(plan.global_ref2, "+ global\nref 2", () => { gallery.value = { target: "global", field: "global_ref2" }; }),
]),
h("div", { style: "flex:1;min-width:200px" }, [h("textarea", { placeholder: "Global prompt (a connected `prompt` input overrides it)", value: plan.global_prompt, onChange: e => { plan.global_prompt = e.target.value; save(); } })]),
]),
h("div", { class: "hint", style: "margin-top:4px" }, "Connected ref_image / ref_image_2 / prompt inputs on the node override these defaults."),
]);
const queue = plan.run === "queue" ? h("div", { class: "card" }, [
h("h5", ["Queue loop", h("span", { class: "grow" }), h("span", { class: "hint", style: "text-transform:none" }, `${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" }, [
h("label", { class: "row" }, [h("input", { type: "checkbox", checked: plan.auto_continue, onChange: e => { plan.auto_continue = e.target.checked; save(); } }), "Auto-queue the next shot"]),
h("button", { onClick: () => progress(false) }, "↻ Status"), h("button", { class: "dng", onClick: () => progress(true) }, "⟲ Reset loop"),
]),
h("div", { class: "hint", style: "margin-top:4px" }, "Each run generates one shot and stores it. Nodes after BFS Shot Join only run on the last shot, with the full video."),
]) : null;
const modal = gallery.value ? h("div", { class: "modal", onClick: e => { if (e.target === e.currentTarget) gallery.value = null; } }, [
h("div", { class: "mbox" }, [
h("div", { class: "mhd" }, [
h("b", "Choose reference"), h("input", { type: "text", placeholder: "search…", value: gq.value, onInput: e => { gq.value = e.target.value; }, style: "flex:1" }),
h("button", { onClick: () => pickFile("image/*", name => { choose(name); }) }, "⬆ Upload"),
h("button", { onClick: () => choose("") }, "None"), h("button", { onClick: () => { gallery.value = null; } }, "✕"),
]),
h("div", { class: "mgrid" }, files.images.filter(f => f.toLowerCase().includes(gq.value.toLowerCase())).slice(0, 400).map(f =>
h("div", { class: "mi", onClick: () => choose(f), title: f }, [h("img", { src: viewUrl(f), loading: "lazy" }), h("div", f)]))),
]),
]) : null;
function choose(name) {
const g = gallery.value; if (!g) return;
if (g.target === "global") plan[g.field] = name; else meta(g.target)[g.field] = name;
save(); gallery.value = null;
}
return h("div", { class: "bsl" }, [header, error.value ? h("div", { class: "err" }, error.value) : null, source, settings, timeline, cards, editor, globals, queue, modal]);
};
}
app.registerExtension({
name: "BFSNodes.ShotPlanner",
async nodeCreated(node) {
if (node.comfyClass !== "BFSShotPlanner") return;
styles();
const planWidget = node.widgets?.find(w => w.name === "plan");
if (planWidget) { planWidget.type = "hidden"; planWidget.computeSize = () => [0, -4]; }
const host = document.createElement("div");
host.style.cssText = "width:100%;height:100%;min-height:520px";
node.addDOMWidget("bfs_shot_planner", "div", host, { serialize: false, hideOnZoom: false });
const handle = {};
createApp({
setup: () => Panel({
getPlan: () => planWidget?.value ?? "{}",
setPlan: v => { if (planWidget) planWidget.value = v; node.graph?.setDirtyCanvas(true); },
expose: o => Object.assign(handle, o),
}),
}).mount(host);
if (planWidget) {
planWidget.options = planWidget.options || {};
planWidget.options.serialize = true;
planWidget.serializeValue = () => planWidget.value;
}
const prev = node.onConfigure;
node.onConfigure = function () {
const r = prev?.apply(this, arguments);
setTimeout(() => handle.reload?.(), 0);
return r;
};
node.size = [Math.max(node.size[0], 780), Math.max(node.size[1], 860)];
},
});