1918 lines
95 KiB
Python
1918 lines
95 KiB
Python
"""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", "filters": {}, "skip_fill": "original",
|
|
"cast": {}, "cast_assign": True, "cast_split": False, "cast_only": False, "mask_cfg": {}, "vlm_cfg": {},
|
|
}
|
|
|
|
|
|
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"]
|
|
cast = cached_cast(path, analysis) if path else None # only once the people were analysed
|
|
if cast is not None and plan.get("cast_split") and not manual:
|
|
extra = []
|
|
bounds = [0] + cuts + [analysis["n"]]
|
|
for a, b in zip(bounds[:-1], bounds[1:]):
|
|
extra += person_change_points(cast, a, b, max(min_len, int(round(fps))))
|
|
cuts = sorted(set(cuts) | set(extra))
|
|
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["chain"] = m.get("chain") or "off"
|
|
s["chain_frame"] = m.get("chain_frame") or "first"
|
|
s["crop"] = bool(m.get("crop"))
|
|
s["mask"] = m.get("mask") or {}
|
|
s["cut_before"] = bool(s.get("cut_before")) or (s["start"] in cuts)
|
|
s["gen_len"] = snap_up(s["end"] - s["start"], grid)
|
|
s["people"], s["main"] = [], -1
|
|
if cast is not None:
|
|
sp = shot_people(cast, s["start"], s["end"])
|
|
s["people"], s["main"] = sp["people"], sp["main"]
|
|
entry = (plan.get("cast") or {}).get(str(sp["main"])) or {}
|
|
if plan.get("cast_assign", True) and not entry.get("ignore"):
|
|
s["ref"] = s["ref"] or entry.get("ref", "")
|
|
s["ref2"] = s["ref2"] or entry.get("ref2", "")
|
|
linked = [p for p in sp["people"] if (plan.get("cast") or {}).get(str(p), {}).get("ref")
|
|
and not (plan.get("cast") or {}).get(str(p), {}).get("ignore")]
|
|
s["cast_skip"] = bool(plan.get("cast_only")) and not linked
|
|
return segs
|
|
|
|
|
|
# ---------------------------------------------------------------------------- content filters
|
|
|
|
DEFAULT_FILTERS = {
|
|
"person": False, "min_person_area": 0.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,
|
|
}
|
|
_DET: dict[str, Any] = {}
|
|
_STATS_CACHE: dict[tuple, dict] = {}
|
|
|
|
|
|
def _yolo(kind: str):
|
|
"""YOLO model for 'person' or 'face' from models/ultralytics, or None (OpenCV fallback)."""
|
|
if kind in _DET:
|
|
return _DET[kind]
|
|
model = None
|
|
try:
|
|
from ultralytics import YOLO
|
|
root = os.path.join(folder_paths.models_dir, "ultralytics")
|
|
cands = []
|
|
for sub in ("bbox", "segm", ""):
|
|
d = os.path.join(root, sub)
|
|
if os.path.isdir(d):
|
|
cands += [os.path.join(d, f) for f in sorted(os.listdir(d)) if f.endswith(".pt") and kind in f.lower()]
|
|
if cands:
|
|
model = YOLO(cands[0])
|
|
except Exception: # noqa: BLE001 - fall back to OpenCV
|
|
model = None
|
|
_DET[kind] = model
|
|
return model
|
|
|
|
|
|
def _detect(frames: list[np.ndarray]) -> list[dict]:
|
|
"""Per frame: person boxes (area fraction) and face count."""
|
|
import cv2
|
|
out = [{"persons": [], "faces": 0} for _ in frames]
|
|
pm, fm = _yolo("person"), _yolo("face")
|
|
if pm is not None:
|
|
for i, r in enumerate(pm(frames, verbose=False, conf=0.35, classes=[0])):
|
|
h, w = frames[i].shape[:2]
|
|
for b in r.boxes.xyxy.cpu().numpy() if r.boxes is not None else []:
|
|
out[i]["persons"].append(float((b[2] - b[0]) * (b[3] - b[1]) / (w * h)))
|
|
else:
|
|
hog = cv2.HOGDescriptor()
|
|
hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
|
|
for i, fr in enumerate(frames):
|
|
h, w = fr.shape[:2]
|
|
rects, _ = hog.detectMultiScale(cv2.cvtColor(fr, cv2.COLOR_RGB2GRAY), winStride=(8, 8))
|
|
out[i]["persons"] = [float(rw * rh / (w * h)) for (_, _, rw, rh) in rects]
|
|
if fm is not None:
|
|
for i, r in enumerate(fm(frames, verbose=False, conf=0.4)):
|
|
out[i]["faces"] = int(len(r.boxes)) if r.boxes is not None else 0
|
|
else:
|
|
casc = cv2.CascadeClassifier(os.path.join(cv2.data.haarcascades, "haarcascade_frontalface_default.xml"))
|
|
for i, fr in enumerate(frames):
|
|
out[i]["faces"] = int(len(casc.detectMultiScale(cv2.cvtColor(fr, cv2.COLOR_RGB2GRAY), 1.1, 5)))
|
|
return out
|
|
|
|
|
|
def shot_stats(path: str, analysis: dict, seg: dict, samples: int = 6) -> dict:
|
|
"""Sampled content statistics for one shot (cached)."""
|
|
key = (path, os.path.getmtime(path), float(analysis["fps"]), seg["start"], seg["end"], samples)
|
|
if key in _STATS_CACHE:
|
|
return _STATS_CACHE[key]
|
|
src = _timeline(analysis["n_src"], analysis["fps_src"], float(analysis["fps"]))
|
|
k = max(1, min(samples, seg["end"] - seg["start"]))
|
|
pick = np.linspace(seg["start"], seg["end"] - 1, k).round().astype(int)
|
|
w = 640
|
|
h = max(32, int(round(w * analysis["height"] / max(1, analysis["width"]))))
|
|
frames = _read_frames(path, src[pick], (w, h))
|
|
det = _detect(frames)
|
|
raw = analysis.get("raw") or []
|
|
motion = float(np.mean(raw[seg["start"] + 1:seg["end"]])) if seg["end"] - seg["start"] > 1 and raw else 0.0
|
|
st = {
|
|
"persons": int(max(len(d["persons"]) for d in det)),
|
|
"person_area": round(float(max([max(d["persons"]) for d in det if d["persons"]] or [0.0])), 4),
|
|
"person_frames": int(sum(1 for d in det if d["persons"])),
|
|
"faces": int(max(d["faces"] for d in det)),
|
|
"brightness": round(float(np.mean([f.mean() / 255.0 for f in frames])), 4),
|
|
"motion": round(motion, 4), "sampled": int(k),
|
|
}
|
|
_STATS_CACHE[key] = st
|
|
return st
|
|
|
|
|
|
def skip_reason(stats: dict, length: int, f: dict) -> str:
|
|
"""Why a shot should be skipped under these filters ('' = keep)."""
|
|
if f.get("min_frames") and length < int(f["min_frames"]):
|
|
return f"shorter than {int(f['min_frames'])} frames"
|
|
if f.get("skip_dark") and stats["brightness"] < float(f.get("dark_level", 0.06)):
|
|
return "dark / fade"
|
|
if f.get("skip_static") and stats["motion"] < float(f.get("static_level", 0.004)):
|
|
return "static"
|
|
if f.get("person") and stats["persons"] == 0:
|
|
return "no person"
|
|
if f.get("person") and float(f.get("min_person_area") or 0) > 0 and stats["person_area"] < float(f["min_person_area"]):
|
|
return f"person smaller than {float(f['min_person_area']) * 100:.0f}% of the frame"
|
|
if int(f.get("max_persons") or 0) > 0 and stats["persons"] > int(f["max_persons"]):
|
|
return f"more than {int(f['max_persons'])} people"
|
|
if f.get("face") and stats["faces"] == 0:
|
|
return "no face"
|
|
return ""
|
|
|
|
|
|
def filters_active(f: dict) -> bool:
|
|
return any(f.get(k) for k in ("person", "face", "skip_dark", "skip_static")) or \
|
|
int(f.get("max_persons") or 0) > 0 or int(f.get("min_frames") or 0) > 0
|
|
|
|
|
|
def apply_filters(plan: dict, analysis: dict, path: str, segs: list[dict]) -> list[dict]:
|
|
"""Mark each shot with stats and an automatic skip reason; per-shot 'force' overrides it."""
|
|
f = dict(DEFAULT_FILTERS); f.update(plan.get("filters") or {})
|
|
meta = plan.get("segs") or []
|
|
need = filters_active(f)
|
|
for i, s in enumerate(segs):
|
|
force = (meta[i] or {}).get("force", "auto") if i < len(meta) and isinstance(meta[i], dict) else "auto"
|
|
s["force"] = force
|
|
s["stats"] = shot_stats(path, analysis, s, int(f.get("samples") or 6)) if need else None
|
|
s["skip_reason"] = skip_reason(s["stats"], s["end"] - s["start"], f) if need else ""
|
|
if not s["skip_reason"] and s.get("cast_skip"):
|
|
s["skip_reason"] = "no linked person"
|
|
s["run"] = s["enabled"] and (force == "run" or (force != "skip" and not s["skip_reason"]))
|
|
return segs
|
|
|
|
|
|
# ---------------------------------------------------------------------------- cast (people by face)
|
|
|
|
_FACE_APP: Any = None
|
|
_CAST_CACHE: dict[tuple, dict] = {}
|
|
|
|
|
|
def _face_app():
|
|
"""InsightFace detector + ArcFace recogniser (buffalo_l), or None when unavailable."""
|
|
global _FACE_APP
|
|
if _FACE_APP is not None:
|
|
return _FACE_APP or None
|
|
try:
|
|
import insightface
|
|
root = os.path.join(folder_paths.models_dir, "insightface")
|
|
# CPU only: light, no VRAM, and no CUDA/cuDNN loading (a missing cuDNN aborts the whole process)
|
|
app = insightface.app.FaceAnalysis(name="buffalo_l", root=root, allowed_modules=["detection", "recognition"],
|
|
providers=["CPUExecutionProvider"])
|
|
app.prepare(ctx_id=-1, det_size=(320, 320))
|
|
# small models: a few threads are faster than onnxruntime's default pool (one per core) and stay light
|
|
import onnxruntime as ort
|
|
so = ort.SessionOptions()
|
|
so.intra_op_num_threads = min(4, os.cpu_count() or 4)
|
|
so.inter_op_num_threads = 1
|
|
for model in app.models.values():
|
|
model.session = ort.InferenceSession(model.model_file, so, providers=["CPUExecutionProvider"])
|
|
_FACE_APP = app
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f"[BFS Shot Planner] face analysis unavailable: {exc!r}")
|
|
_FACE_APP = False
|
|
return _FACE_APP or None
|
|
|
|
|
|
def _cast_key(path: str, analysis: dict, step_s: float = 0.5, threshold: float = 0.42, min_share: float = 0.01) -> tuple:
|
|
return (path, os.path.getmtime(path), float(analysis["fps"]), round(step_s, 3), round(threshold, 3), round(min_share, 4))
|
|
|
|
|
|
def cached_cast(path: str, analysis: dict) -> dict | None:
|
|
"""The cast of a video when it was already analysed with the default settings, else None (never computes)."""
|
|
try:
|
|
return _CAST_CACHE.get(_cast_key(path, analysis))
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def analyze_cast(path: str, analysis: dict, step_s: float = 0.5, threshold: float = 0.42,
|
|
min_share: float = 0.01, max_faces: int = 3) -> dict:
|
|
"""Detect faces every `step_s` seconds (CPU), keep the `max_faces` most probable ones per frame, group them
|
|
into people by ArcFace similarity.
|
|
|
|
Returns {"people": [{id, thumb, count, share}], "samples": [{f, faces: [{pid, area}]}]} where
|
|
`f` is a timeline frame. Person ids are stable for the same video and settings.
|
|
"""
|
|
import cv2
|
|
key = _cast_key(path, analysis, step_s, threshold, min_share)
|
|
if key in _CAST_CACHE:
|
|
return _CAST_CACHE[key]
|
|
app = _face_app()
|
|
if app is None:
|
|
raise RuntimeError("Face analysis needs the insightface package and the buffalo_l models in models/insightface.")
|
|
fps = float(analysis["fps"])
|
|
step = max(1, int(round(step_s * fps)))
|
|
frames_idx = np.arange(0, analysis["n"], step)
|
|
src = _timeline(analysis["n_src"], analysis["fps_src"], fps)
|
|
w = 960
|
|
h = max(32, int(round(w * analysis["height"] / max(1, analysis["width"]))))
|
|
frames = _read_frames(path, src[frames_idx], (w, h))
|
|
dets = [] # (sample index, embedding, area fraction, crop)
|
|
from insightface.app.common import Face
|
|
rec = app.models["recognition"]
|
|
for si, fr in enumerate(frames):
|
|
bgr = cv2.cvtColor(fr, cv2.COLOR_RGB2BGR)
|
|
boxes, kpss = app.det_model.detect(bgr, max_num=0, metric="default")
|
|
# only the most probable faces of the frame get recognised, so a crowd stays cheap and out of the cast
|
|
cand = []
|
|
for k in range(boxes.shape[0]):
|
|
x0, y0, x1, y1 = [int(v) for v in boxes[k, :4]]
|
|
area = max(0, x1 - x0) * max(0, y1 - y0) / float(w * h)
|
|
if area >= 0.0015 and boxes[k, 4] >= 0.5:
|
|
cand.append((float(boxes[k, 4]), k, area))
|
|
for score, k, area in sorted(cand, reverse=True)[:max_faces]:
|
|
f = Face(bbox=boxes[k, :4], kps=kpss[k] if kpss is not None else None, det_score=score)
|
|
rec.get(bgr, f)
|
|
x0, y0, x1, y1 = [int(v) for v in f.bbox]
|
|
pad = int(0.25 * max(x1 - x0, y1 - y0))
|
|
crop = fr[max(0, y0 - pad):min(h, y1 + pad), max(0, x0 - pad):min(w, x1 + pad)]
|
|
dets.append((si, f.normed_embedding.astype(np.float32), area, crop))
|
|
# greedy online clustering on cosine similarity, then merge close clusters
|
|
cents, members = [], []
|
|
for k, (_, e, _, _) in enumerate(dets):
|
|
if cents:
|
|
sims = np.array([c @ e / (np.linalg.norm(c) + 1e-8) for c in cents])
|
|
j = int(sims.argmax())
|
|
if sims[j] >= threshold:
|
|
members[j].append(k); cents[j] = cents[j] + e
|
|
continue
|
|
cents.append(e.copy()); members.append([k])
|
|
merged = True
|
|
while merged:
|
|
merged = False
|
|
for a in range(len(cents)):
|
|
for b in range(a + 1, len(cents)):
|
|
ca, cb = cents[a] / np.linalg.norm(cents[a]), cents[b] / np.linalg.norm(cents[b])
|
|
if ca @ cb >= threshold:
|
|
cents[a] = cents[a] + cents[b]; members[a] += members[b]
|
|
del cents[b]; del members[b]; merged = True
|
|
break
|
|
if merged:
|
|
break
|
|
total = max(1, len(frames))
|
|
groups = [m for m in members if len({dets[k][0] for k in m}) / total >= min_share]
|
|
groups.sort(key=lambda m: -len({dets[k][0] for k in m}))
|
|
people, owner = [], {}
|
|
for pid, m in enumerate(groups):
|
|
best = max(m, key=lambda k: dets[k][2])
|
|
crop = cv2.resize(dets[best][3], (96, 96), interpolation=cv2.INTER_AREA)
|
|
ok, buf = cv2.imencode(".jpg", cv2.cvtColor(crop, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
seen = sorted({dets[k][0] for k in m})
|
|
people.append({"id": pid, "thumb": "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode(),
|
|
"count": len(seen), "share": round(len(seen) / total, 4),
|
|
"first": int(frames_idx[seen[0]]), "last": int(frames_idx[seen[-1]])})
|
|
for k in m:
|
|
owner[k] = pid
|
|
samples = [{"f": int(f), "faces": []} for f in frames_idx]
|
|
for k, (si, _, area, _) in enumerate(dets):
|
|
if k in owner:
|
|
samples[si]["faces"].append({"pid": owner[k], "area": round(float(area), 5)})
|
|
out = {"people": people, "samples": samples, "step": step}
|
|
_CAST_CACHE[key] = out
|
|
return out
|
|
|
|
|
|
def shot_people(cast: dict, start: int, end: int) -> dict:
|
|
"""Who appears in [start, end): per person, sampled frames seen and summed face area; plus the main one."""
|
|
score: dict[int, list[float]] = {}
|
|
for smp in cast["samples"]:
|
|
if start <= smp["f"] < end:
|
|
for fc in smp["faces"]:
|
|
v = score.setdefault(fc["pid"], [0, 0.0]); v[0] += 1; v[1] += fc["area"]
|
|
main = max(score, key=lambda p: (score[p][0], score[p][1])) if score else -1
|
|
return {"people": sorted(score), "main": main}
|
|
|
|
|
|
def person_change_points(cast: dict, start: int, end: int, min_run: int) -> list[int]:
|
|
"""Frames inside [start, end) where the main (largest) face switches to another person for at least min_run."""
|
|
runs = []
|
|
for smp in cast["samples"]:
|
|
if start <= smp["f"] < end:
|
|
main = max(smp["faces"], key=lambda fc: fc["area"])["pid"] if smp["faces"] else -1
|
|
if main >= 0 and (not runs or runs[-1][1] != main):
|
|
runs.append([smp["f"], main])
|
|
pts = []
|
|
for (f0, p0), (f1, p1) in zip(runs, runs[1:]):
|
|
if f1 - (pts[-1] if pts else start) >= min_run and end - f1 >= min_run:
|
|
pts.append(f1)
|
|
return pts
|
|
|
|
|
|
# ---------------------------------------------------------------------------- shot masks (SAM 3) and crop / uncrop
|
|
|
|
SAM3_FILE = "sam3.1_multiplex_fp16.safetensors"
|
|
SAM3_URL = "https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors"
|
|
_SAM3: dict[str, Any] = {}
|
|
_MASK_CACHE: dict[tuple, dict] = {}
|
|
# global mask settings (the planner's Mask card); a shot only says what to segment (text / points / key frame)
|
|
DEFAULT_MASK = {"text": "", "points": [], "key": 0, "threshold": 0.5, "max_objects": 4, "invert": False,
|
|
"fill_holes": True, "temporal_expand": 2, "blockify": 0, "padding": 0.15, "expand": 16,
|
|
"feather": 12, "paste": "mask"}
|
|
SHOT_MASK_KEYS = ("text", "points", "key")
|
|
|
|
|
|
def _sam3_path() -> str:
|
|
"""The official SAM 3.1 checkpoint in models/checkpoints, downloaded once when missing."""
|
|
p = folder_paths.get_full_path("checkpoints", SAM3_FILE)
|
|
if p:
|
|
return p
|
|
import urllib.request
|
|
dst = os.path.join(folder_paths.get_folder_paths("checkpoints")[0], SAM3_FILE)
|
|
tmp = dst + ".part"
|
|
print(f"[BFS Shot Planner] downloading {SAM3_FILE} (1.7 GB) from Comfy-Org/sam3.1 ...")
|
|
with urllib.request.urlopen(SAM3_URL) as r, open(tmp, "wb") as f:
|
|
total, done, step = int(r.headers.get("Content-Length") or 0), 0, 0
|
|
while True:
|
|
chunk = r.read(1 << 22)
|
|
if not chunk:
|
|
break
|
|
f.write(chunk); done += len(chunk)
|
|
if total and done * 10 // total > step:
|
|
step = done * 10 // total
|
|
print(f"[BFS Shot Planner] {SAM3_FILE}: {step * 10}%")
|
|
os.replace(tmp, dst)
|
|
return dst
|
|
|
|
|
|
def _sam3():
|
|
if "model" not in _SAM3:
|
|
import comfy.sd
|
|
out = comfy.sd.load_checkpoint_guess_config(_sam3_path(), output_vae=False, output_clip=True,
|
|
embedding_directory=folder_paths.get_folder_paths("embeddings"))
|
|
_SAM3["model"], _SAM3["clip"] = out[0], out[1]
|
|
return _SAM3["model"], _SAM3["clip"]
|
|
|
|
|
|
def mask_spec(m: dict | None, cfg: dict | None = None) -> dict:
|
|
"""Global settings (cfg) with the shot's own target (text / points / key frame) on top."""
|
|
spec = dict(DEFAULT_MASK)
|
|
spec.update({k: v for k, v in (cfg or {}).items() if v is not None and k not in SHOT_MASK_KEYS})
|
|
spec.update({k: v for k, v in (m or {}).items() if v is not None and k in SHOT_MASK_KEYS})
|
|
return spec
|
|
|
|
|
|
def shape_mask(masks: torch.Tensor, spec: dict) -> torch.Tensor:
|
|
"""invert -> fill holes -> temporal expand -> blockify, on [N,H,W] 0/1 masks."""
|
|
x = masks.float()
|
|
if spec.get("invert"):
|
|
x = 1 - x
|
|
if spec.get("fill_holes"):
|
|
try:
|
|
from scipy.ndimage import binary_fill_holes
|
|
x = torch.from_numpy(np.stack([binary_fill_holes(f > 0.5) for f in x.numpy()]).astype(np.float32))
|
|
except ImportError:
|
|
pass
|
|
t = int(spec.get("temporal_expand") or 0)
|
|
if t > 0 and x.shape[0] > 1:
|
|
N, H, W = x.shape
|
|
x = torch.nn.functional.max_pool1d(x.permute(1, 2, 0).reshape(-1, 1, N), 2 * t + 1, 1, t) \
|
|
.reshape(H, W, N).permute(2, 0, 1)
|
|
b = int(spec.get("blockify") or 0)
|
|
if b > 1:
|
|
H, W = x.shape[1:]
|
|
cov = torch.nn.functional.avg_pool2d(x[:, None], b, b, ceil_mode=True)[:, 0]
|
|
x = (cov >= 0.5).float().repeat_interleave(b, 1).repeat_interleave(b, 2)[:, :H, :W]
|
|
return (x > 0.5).to(torch.uint8)
|
|
|
|
|
|
def _track(model, imgs: torch.Tensor, init: torch.Tensor | None, cond, spec: dict) -> torch.Tensor:
|
|
from comfy_extras.nodes_sam3 import SAM3_TrackToMask, SAM3_VideoTrack
|
|
data = SAM3_VideoTrack.execute(images=imgs, model=model, initial_mask=init, conditioning=cond,
|
|
detection_threshold=float(spec["threshold"]),
|
|
max_objects=int(spec["max_objects"]), detect_interval=1).args[0]
|
|
return SAM3_TrackToMask.execute(track_data=data, object_indices="").args[0].float().cpu()
|
|
|
|
|
|
def segment_frames(imgs: torch.Tensor, spec: dict) -> torch.Tensor:
|
|
"""[N,H,W,3] -> [N,H,W] masks with SAM 3: tracked from the points on the key frame, or from the text."""
|
|
from comfy_extras.nodes_sam3 import SAM3_Detect
|
|
model, clip = _sam3()
|
|
text = str(spec.get("text") or "").strip()
|
|
cond = clip.encode_from_tokens_scheduled(clip.tokenize(text)) if text else None
|
|
pts = spec.get("points") or []
|
|
N, H, W = imgs.shape[:3]
|
|
if not pts:
|
|
if cond is None:
|
|
raise ValueError("the shot's mask needs a text prompt or points")
|
|
return _track(model, imgs, None, cond, spec)
|
|
key = max(0, min(N - 1, int(spec.get("key") or 0)))
|
|
pos = [{"x": p["x"] * W, "y": p["y"] * H} for p in pts if p.get("label", 1)]
|
|
neg = [{"x": p["x"] * W, "y": p["y"] * H} for p in pts if not p.get("label", 1)]
|
|
first = SAM3_Detect.execute(model=model, image=imgs[key:key + 1], positive_coords=json.dumps(pos),
|
|
negative_coords=json.dumps(neg), threshold=float(spec["threshold"]),
|
|
refine_iterations=2).args[0][:1].float()
|
|
fwd = _track(model, imgs[key:], first, None, spec) # key frame -> end
|
|
if key == 0:
|
|
return fwd
|
|
back = _track(model, imgs[:key + 1].flip(0), first, None, spec).flip(0) # key frame -> start
|
|
return torch.cat([back[:-1], fwd], 0)
|
|
|
|
|
|
def shot_mask(path: str, analysis: dict, start: int, length: int, spec: dict) -> dict:
|
|
"""Masks of one shot (timeline frames start..start+length) at a working size, cached; plus the crop box."""
|
|
key = (path, os.path.getmtime(path), float(analysis["fps"]), int(start), int(length),
|
|
json.dumps({k: spec[k] for k in ("text", "points", "key", "threshold", "max_objects")}, sort_keys=True))
|
|
if key not in _MASK_CACHE:
|
|
src = _timeline(analysis["n_src"], analysis["fps_src"], float(analysis["fps"]))
|
|
idx = np.clip(np.arange(start, start + length), 0, analysis["n"] - 1)
|
|
sw, sh = analysis["width"], analysis["height"]
|
|
s = 640 / max(sw, sh)
|
|
w, h = max(32, int(sw * s) // 2 * 2), max(32, int(sh * s) // 2 * 2)
|
|
frames = _read_frames(path, src[idx], (w, h))
|
|
imgs = torch.from_numpy(np.stack(frames).astype(np.float32) / 255.0)
|
|
_MASK_CACHE[key] = {"masks": (segment_frames(imgs, spec) > 0.5).to(torch.uint8), "size": (w, h)}
|
|
_node_boundary()
|
|
out = dict(_MASK_CACHE[key])
|
|
out["masks"] = shape_mask(out["masks"], spec)
|
|
out["box"] = crop_box(out["masks"], float(spec["padding"]))
|
|
return out
|
|
|
|
|
|
def crop_box(masks: torch.Tensor, padding: float) -> list[float] | None:
|
|
"""One box for the whole shot (union of every frame's mask) plus padding, normalised [x0, y0, x1, y1]."""
|
|
if masks.numel() == 0 or not bool(masks.any()):
|
|
return None
|
|
union = masks.amax(0)
|
|
ys, xs = torch.nonzero(union, as_tuple=True)
|
|
H, W = union.shape
|
|
x0, x1, y0, y1 = xs.min().item() / W, (xs.max().item() + 1) / W, ys.min().item() / H, (ys.max().item() + 1) / H
|
|
px, py = (x1 - x0) * padding, (y1 - y0) * padding
|
|
return [max(0.0, x0 - px), max(0.0, y0 - py), min(1.0, x1 + px), min(1.0, y1 + py)]
|
|
|
|
|
|
def grow_blur(m: torch.Tensor, grow: int, blur: int) -> torch.Tensor:
|
|
"""[N,H,W] in 0-1: dilate by `grow` px, then soften the edge by `blur` px."""
|
|
x = m.float()[:, None]
|
|
if grow > 0:
|
|
x = torch.nn.functional.max_pool2d(x, 2 * grow + 1, 1, grow)
|
|
for _ in range(2 if blur > 0 else 0):
|
|
x = torch.nn.functional.avg_pool2d(torch.nn.functional.pad(x, (blur,) * 4, mode="replicate"), 2 * blur + 1, 1)
|
|
return x[:, 0].clamp(0, 1)
|
|
|
|
|
|
def box_px(box: list[float], W: int, H: int, multiple: int = 2) -> tuple[int, int, int, int]:
|
|
x0, y0 = int(box[0] * W), int(box[1] * H)
|
|
x1, y1 = max(x0 + multiple, int(round(box[2] * W))), max(y0 + multiple, int(round(box[3] * H)))
|
|
return x0, y0, min(W, x1), min(H, y1)
|
|
|
|
|
|
def crop_panel(img: torch.Tensor, shot: dict) -> torch.Tensor:
|
|
"""Cut a duet panel off a decoded canvas (the shot was conditioned with a duet panel)."""
|
|
info = shot.get("panel")
|
|
if not info or img.shape[1:3] == (shot.get("height"), shot.get("width")):
|
|
return img
|
|
h, w = info["h"] * 16, info["w"] * 16
|
|
y0 = info["strip_h"] * 16 if info["position"] == "top" else 0
|
|
x0 = info["strip_w"] * 16 if info["position"] == "left" else 0
|
|
if img.shape[1] < y0 + h or img.shape[2] < x0 + w:
|
|
return img
|
|
return img[:, y0:y0 + h, x0:x0 + w]
|
|
|
|
|
|
def uncrop(result: torch.Tensor, shot: dict) -> torch.Tensor:
|
|
"""Paste a cropped shot's result back into its full frames (feathered by the mask or the box)."""
|
|
c = shot["crop"]
|
|
full = shot["full_frames"]
|
|
W, H = full.shape[2], full.shape[1]
|
|
x0, y0, x1, y1 = box_px(c["box"], W, H)
|
|
n = min(result.shape[0], full.shape[0])
|
|
res = torch.nn.functional.interpolate(result[:n, ..., :3].movedim(-1, 1).float(), size=(y1 - y0, x1 - x0),
|
|
mode="bilinear", align_corners=False).movedim(1, -1).clamp(0, 1)
|
|
out = full[:n].clone()
|
|
if c.get("paste") == "box":
|
|
a = torch.zeros(n, H, W)
|
|
a[:, y0:y1, x0:x1] = 1
|
|
alpha = grow_blur(a, 0, int(c.get("feather", 12)))
|
|
else:
|
|
m = c["mask"][:n].float()
|
|
alpha = grow_blur(m, int(c.get("expand", 8)), int(c.get("feather", 12)))
|
|
a = alpha[:, y0:y1, x0:x1, None]
|
|
out[:, y0:y1, x0:x1] = res * a + out[:, y0:y1, x0:x1] * (1 - a)
|
|
if result.shape[0] > n: # frames past the planned length stay as generated, pasted the same way
|
|
out = torch.cat([out, out[-1:].expand(result.shape[0] - n, -1, -1, -1)], 0)
|
|
return out
|
|
|
|
|
|
def mask_preview(path: str, analysis: dict, start: int, length: int, spec: dict, count: int = 6) -> dict:
|
|
"""A few frames of the shot with the mask in red and the crop box, as data URLs."""
|
|
import cv2
|
|
r = shot_mask(path, analysis, start, length, spec)
|
|
masks, (w, h), box = r["masks"], r["size"], r["box"]
|
|
src = _timeline(analysis["n_src"], analysis["fps_src"], float(analysis["fps"]))
|
|
picks = sorted(set(int(round(i)) for i in np.linspace(0, length - 1, min(count, length))))
|
|
frames = _read_frames(path, src[np.clip(np.array(picks) + start, 0, analysis["n"] - 1)], (w, h))
|
|
out = []
|
|
for i, fr in zip(picks, frames):
|
|
img = fr.copy()
|
|
m = masks[min(i, masks.shape[0] - 1)].numpy().astype(bool)
|
|
img[m] = (img[m] * 0.45 + np.array([255, 40, 60]) * 0.55).astype(np.uint8)
|
|
if box:
|
|
x0, y0, x1, y1 = box_px(box, w, h)
|
|
cv2.rectangle(img, (x0, y0), (x1 - 1, y1 - 1), (255, 210, 90), 2)
|
|
ok, buf = cv2.imencode(".jpg", cv2.cvtColor(img, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
out.append({"f": start + i, "src": "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode()})
|
|
cover = float(masks.float().mean()) if masks.numel() else 0.0
|
|
return {"box": box, "frames": out, "coverage": cover, "empty": box is None}
|
|
|
|
|
|
# ---------------------------------------------------------------------------- VLM suggestions (optional)
|
|
|
|
DEFAULT_VLM = {"enabled": False, "frames": 3, "max_tokens": 320, "auto_segment": True, "auto_shot": True,
|
|
"instruction": ""}
|
|
_VLM: dict[str, Any] = {} # the VLM connected to a planner (kept for the panel's Analyse button)
|
|
_VLM_CACHE: dict[tuple, dict] = {}
|
|
|
|
VLM_QUESTION = """You see {n} frames (in time order) of ONE camera shot from a video that will be edited with an AI video model.
|
|
Answer with one JSON object only, no code fence:
|
|
{{"segment": "the main person or object a user would edit, as a short English noun phrase a segmentation model understands, describing what it looks like (e.g. 'woman in a black top', 'man in a grey suit', 'red car'); never on-screen text",
|
|
"shot": "one or two sentences for a video prompt: camera distance and angle, camera movement, where the subject is in the frame and what they do, in time order; describe actions and framing, not the person's face or clothes",
|
|
"people": <number of people visible>,
|
|
"recommend": "run" or "skip" (skip when the shot has nobody to edit, is a title card, a black frame or text only),
|
|
"reason": "a few words"}}{extra}"""
|
|
|
|
|
|
def _outside_prompt() -> None:
|
|
"""Models run from a panel button (no prompt running): ComfyUI's progress hook reads the server's last prompt
|
|
and node ids, which do not exist until a first prompt has run."""
|
|
try:
|
|
from server import PromptServer
|
|
srv = PromptServer.instance
|
|
for attr in ("last_prompt_id", "last_node_id"):
|
|
if not hasattr(srv, attr):
|
|
setattr(srv, attr, None)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
|
|
def _node_boundary() -> None:
|
|
"""What ComfyUI does between two nodes: drop the per-thread CUDA malloc graph and prefetch queues. Needed when
|
|
one node runs a model several times (a second text generation in the same node otherwise hits a device assert)."""
|
|
try:
|
|
import comfy.model_prefetch
|
|
comfy.model_prefetch.cleanup_prefetch_queues()
|
|
except Exception: # noqa: BLE001 - older ComfyUI without it
|
|
pass
|
|
|
|
|
|
def vlm_cfg(cfg: dict | None) -> dict:
|
|
out = dict(DEFAULT_VLM)
|
|
out.update({k: v for k, v in (cfg or {}).items() if v is not None})
|
|
return out
|
|
|
|
|
|
def _parse_json(text: str) -> dict:
|
|
t = text.strip()
|
|
if "```" in t:
|
|
t = t.split("```")[1]
|
|
t = t[4:] if t.lower().startswith("json") else t
|
|
a, b = t.find("{"), t.rfind("}")
|
|
try:
|
|
return json.loads(t[a:b + 1]) if a >= 0 and b > a else {}
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
|
|
|
|
def vlm_shot(clip, path: str, analysis: dict, start: int, end: int, cfg: dict) -> dict:
|
|
"""Ask the VLM about one shot (a few frames); cached per shot and settings."""
|
|
key = (path, os.path.getmtime(path), int(start), int(end), id(clip),
|
|
json.dumps({k: cfg[k] for k in ("frames", "max_tokens", "instruction")}, sort_keys=True))
|
|
if key in _VLM_CACHE:
|
|
return _VLM_CACHE[key]
|
|
from comfy_extras.nodes_textgen import TextGenerate
|
|
n = max(1, min(8, int(cfg["frames"])))
|
|
src = _timeline(analysis["n_src"], analysis["fps_src"], float(analysis["fps"]))
|
|
picks = np.clip(np.linspace(start, end - 1, n).round().astype(int), 0, analysis["n"] - 1)
|
|
s = 448 / max(analysis["width"], analysis["height"])
|
|
size = (max(32, int(analysis["width"] * s) // 2 * 2), max(32, int(analysis["height"] * s) // 2 * 2))
|
|
imgs = torch.from_numpy(np.stack(_read_frames(path, src[picks], size)).astype(np.float32) / 255.0)
|
|
extra = ("\n" + cfg["instruction"].strip()) if str(cfg.get("instruction") or "").strip() else ""
|
|
q = VLM_QUESTION.format(n=n, extra=extra)
|
|
text = TextGenerate.execute(clip=clip, prompt=q, max_length=int(cfg["max_tokens"]),
|
|
sampling_mode={"sampling_mode": "off"}, image=imgs,
|
|
mtp="off").args[0]
|
|
_node_boundary()
|
|
|
|
out = _parse_json(text)
|
|
out = {"segment": str(out.get("segment") or "").strip(), "shot": str(out.get("shot") or "").strip(),
|
|
"people": out.get("people"), "recommend": str(out.get("recommend") or "run").lower(),
|
|
"reason": str(out.get("reason") or "").strip(), "raw": "" if out else text}
|
|
_VLM_CACHE[key] = out
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------- continuity between shots
|
|
|
|
CHAIN_MODES = ("off", "reference", "first frame")
|
|
_LAST_RESULT: dict[str, Any] = {} # auto loop: the last shot a render node produced (index, count, frames)
|
|
|
|
|
|
def pick_frame(frames: torch.Tensor, length: int, which: str) -> torch.Tensor:
|
|
"""One frame [1,H,W,3] of a shot's result: its first, middle or last real frame (not the overlap)."""
|
|
n = max(1, min(int(length), frames.shape[0]))
|
|
i = {"first": 0, "middle": n // 2}.get(which, n - 1)
|
|
return frames[i:i + 1, ..., :3].float()
|
|
|
|
|
|
def remember_result(shot: dict, frames: torch.Tensor) -> None:
|
|
_LAST_RESULT.clear()
|
|
_LAST_RESULT.update(index=int(shot.get("index", 0)), count=int(shot.get("count", 0)), frames=frames.detach().cpu())
|
|
|
|
|
|
def chain_image(shot: dict) -> torch.Tensor | None:
|
|
"""The previous shot's result frame this shot continues from, if it asked for one and it exists."""
|
|
if shot.get("chain", "off") == "off" or int(shot.get("index", 0)) == 0:
|
|
return None
|
|
if shot.get("chain_image") is not None:
|
|
return shot["chain_image"]
|
|
if _LAST_RESULT.get("index") == int(shot["index"]) - 1 and _LAST_RESULT.get("count") == int(shot.get("count", 0)):
|
|
prev_len = shot.get("prev_length") or _LAST_RESULT["frames"].shape[0]
|
|
return pick_frame(_LAST_RESULT["frames"], prev_len, shot.get("chain_frame", "first"))
|
|
return None
|
|
|
|
|
|
def _fit_to(img: torch.Tensor, w: int, h: int) -> torch.Tensor:
|
|
x = img[..., :3].movedim(-1, 1).float()
|
|
return torch.nn.functional.interpolate(x, size=(h, w), mode="bilinear", align_corners=False).movedim(1, -1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------- 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)."}),
|
|
"vlm": ("CLIP", {"tooltip": "Optional vision-language model (CLIPLoader with qwen3vl_4b / qwen3vl_8b). "
|
|
"It looks at every shot and suggests what to segment, a description of the "
|
|
"shot (fills {shot} in the prompt) and whether to run it. Settings in the "
|
|
"panel's VLM card."}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("BFS_SHOT", "INT", "FLOAT", "INT", "INT", "AUDIO", "STRING", "BFS_SHOT_TIMELINE", "IMAGE", "IMAGE")
|
|
RETURN_NAMES = ("shots", "count", "fps", "width", "height", "audio", "summary", "timeline", "ref_image", "ref_image_2")
|
|
OUTPUT_IS_LIST = (True, False, False, False, False, False, False, False, True, True)
|
|
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.",
|
|
"Every shot in order, including the ones that do not run (disabled or filtered out). Connect it "
|
|
"to BFS Shot Join so skipped shots are filled with the original video (or dropped).",
|
|
"The references the shots use, without repeats: one image when every shot shares the same reference.",
|
|
"The second references the shots use, without repeats.")
|
|
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, vlm=None):
|
|
p = _load_plan(plan)
|
|
if vlm is not None:
|
|
_VLM["clip"] = vlm # the panel's Analyse button uses it too
|
|
vcfg = vlm_cfg(p.get("vlm_cfg"))
|
|
use_vlm = vlm is not None and vcfg["enabled"]
|
|
suggestions = []
|
|
path = _input_path(p["video"])
|
|
fps = float(p["fps"])
|
|
a = analyze(path, fps)
|
|
if p.get("cast") or p.get("cast_split"):
|
|
analyze_cast(path, a) # warms the cache so the plan uses the same people as the panel
|
|
all_segs = apply_filters(p, a, path, resolve_plan(p, a, path))
|
|
segs = [s for s in all_segs if s["run"]]
|
|
if not segs:
|
|
raise ValueError("BFS Shot Planner: no shot is left to run (all disabled or filtered out).")
|
|
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]
|
|
cache_imgs: dict[str, torch.Tensor] = {}
|
|
|
|
def ref_for(name, default):
|
|
if not name:
|
|
return default
|
|
if name not in cache_imgs:
|
|
cache_imgs[name] = _load_image(name)
|
|
return cache_imgs[name]
|
|
|
|
g_names = {"ref": "__socket__" if ref_image is not None else p.get("global_ref", ""),
|
|
"ref2": "__socket__" if ref_image_2 is not None else p.get("global_ref2", "")}
|
|
|
|
def unique(field, default):
|
|
seen, out = set(), []
|
|
for s in segs:
|
|
key = s[field] or g_names[field] # the image this shot actually uses
|
|
img = ref_for(s[field], default)
|
|
if img is None or key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(img)
|
|
return out or [_grey()]
|
|
|
|
used_refs, used_refs2 = unique("ref", g_ref), unique("ref2", g_ref2)
|
|
# the VLM answers for every shot first: switching between it and SAM 3 mid-generation breaks the VLM
|
|
vlm_out = {i: vlm_shot(vlm, path, a, segs[i]["start"], segs[i]["end"], vcfg) for i in todo} if use_vlm else {}
|
|
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 = ref_for(s["ref"], g_ref)
|
|
ref2 = ref_for(s["ref2"], g_ref2)
|
|
prev_len = (segs[i - 1]["end"] - segs[i - 1]["start"]) if i > 0 else 0
|
|
chain_img = None
|
|
if queue and i > 0 and s["chain"] != "off":
|
|
fp = os.path.join(run_dir(rid), f"shot_{i - 1:04d}.pt")
|
|
if os.path.exists(fp):
|
|
prev = torch.load(fp)["frames"].float() / 255.0
|
|
chain_img = _fit_to(pick_frame(prev, prev_len, s["chain_frame"]), W, H)
|
|
sug = vlm_out.get(i)
|
|
if sug is not None:
|
|
suggestions.append(dict(sug, start=s["start"], end=s["end"]))
|
|
if vcfg["auto_segment"] and sug["segment"] and not (s["mask"].get("text") or s["mask"].get("points")):
|
|
s["mask"] = dict(s["mask"], text=sug["segment"])
|
|
crop, full_frames = None, None
|
|
if s.get("crop"):
|
|
spec = mask_spec(s.get("mask"), p.get("mask_cfg"))
|
|
r = shot_mask(path, a, s["start"], s["gen_len"], spec)
|
|
if r["box"] is not None:
|
|
full_frames = ft
|
|
sx0, sy0, sx1, sy1 = box_px(r["box"], a["width"], a["height"])
|
|
cw, chh = generation_size(sx1 - sx0, sy1 - sy0, float(p["megapixels"]), int(p["multiple"]))
|
|
hi = _read_frames(path, src[idx], None) # source resolution, then crop
|
|
ft = torch.from_numpy(np.stack([_fit(f[sy0:sy1, sx0:sx1], (cw, chh)) for f in hi])
|
|
.astype(np.float32) / 255.0)
|
|
m = torch.nn.functional.interpolate(r["masks"][:, None].float(), size=(H, W), mode="nearest")[:, 0]
|
|
crop = {"box": r["box"], "mask": m, "paste": spec["paste"], "expand": int(spec["expand"]),
|
|
"feather": int(spec["feather"])}
|
|
bx0, by0, bx1, by1 = box_px(r["box"], r["size"][0], r["size"][1])
|
|
crop["crop_mask"] = torch.nn.functional.interpolate(
|
|
r["masks"][:, None, by0:by1, bx0:bx1].float(), size=(chh, cw), mode="nearest")[:, 0]
|
|
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": ft.shape[2], "height": ft.shape[1], "frames": ft,
|
|
"crop": crop, "full_frames": full_frames,
|
|
"ref": ref, "ref2": ref2,
|
|
"prompt": (s["prompt"] or g_prompt or "").replace(
|
|
"{shot}", sug["shot"] if (sug and vcfg["auto_shot"]) else ""),
|
|
"audio": _slice_audio(audio, s["start"] / fps, s["gen_len"] / fps),
|
|
"run_id": rid, "queue": queue,
|
|
"chain": s["chain"], "chain_frame": s["chain_frame"], "chain_image": chain_img,
|
|
"prev_length": prev_len,
|
|
})
|
|
if suggestions:
|
|
_notify("bfs-shotloop-vlm", {"video": p["video"], "segs": suggestions})
|
|
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
|
|
full_total = sum(s["end"] - s["start"] for s in all_segs)
|
|
timeline = {"path": path, "fps": fps, "width": W, "height": H, "fill": p.get("skip_fill", "original"),
|
|
"audio": _slice_audio(audio, all_segs[0]["start"] / fps, full_total / fps) if audio else None,
|
|
"segs": [{"start": s["start"], "end": s["end"], "run": s["run"], "cut_before": s["cut_before"],
|
|
"run_index": segs.index(s) if s["run"] else -1} for s in all_segs],
|
|
"src": src.tolist(), "n": a["n"]}
|
|
skipped = [(i, s.get("skip_reason") or ("disabled" if not s["enabled"] else "skip")) for i, s in enumerate(all_segs) if not s["run"]]
|
|
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 i, why in skipped:
|
|
lines.append(f"skip shot {i + 1} (frames {all_segs[i]['start']}-{all_segs[i]['end'] - 1}): {why}")
|
|
for i, g in sorted(vlm_out.items()):
|
|
lines.append(f"VLM #{i + 1}: segment '{g['segment']}', {g['recommend']}"
|
|
f"{' (' + g['reason'] + ')' if g['reason'] else ''} | {g['shot']}")
|
|
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), timeline, used_refs, used_refs2)
|
|
|
|
|
|
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", "BFS_SHOT",
|
|
"IMAGE", "MASK")
|
|
RETURN_NAMES = ("guide_frames", "ref_image", "ref_image_2", "prompt", "length", "first_frame",
|
|
"audio", "width", "height", "index", "shot", "previous_result", "mask")
|
|
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).",
|
|
"The same shot, unchanged: connect it to BFS Shot Repack after editing the pieces.",
|
|
"The previous shot's result frame this shot continues from (Continuity in the planner; queue loop), "
|
|
"or a grey image.",
|
|
"The shot's SAM 3 mask over the guide frames (in the crop when the shot is cropped), or all ones.")
|
|
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"], shot,
|
|
chain_image(shot) if chain_image(shot) is not None else _grey(),
|
|
shot["crop"]["crop_mask"] if shot.get("crop") else torch.ones(shot["frames"].shape[:3]))
|
|
|
|
|
|
class BFSShotRepack:
|
|
"""Put edited pieces back into a shot (runs once per shot), e.g. a reference with its background removed."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {"shot": ("BFS_SHOT",)},
|
|
"optional": {
|
|
"guide_frames": ("IMAGE", {"tooltip": "Replacement guide frames. Resized to the shot's generation "
|
|
"size; padded or trimmed to its length."}),
|
|
"ref_image": ("IMAGE", {"tooltip": "Replacement reference (e.g. background removed)."}),
|
|
"ref_image_2": ("IMAGE", {"tooltip": "Replacement second reference."}),
|
|
"prompt": ("STRING", {"forceInput": True, "tooltip": "Replacement prompt."}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("BFS_SHOT",)
|
|
RETURN_NAMES = ("shot",)
|
|
FUNCTION = "repack"
|
|
CATEGORY = "BFS/shot loop"
|
|
DESCRIPTION = ("Rebuild a shot after editing its pieces (Unpack -> any processing -> Repack). Inputs left "
|
|
"unconnected keep the shot's original values; timing, cuts and audio are unchanged.")
|
|
|
|
def repack(self, shot, guide_frames=None, ref_image=None, ref_image_2=None, prompt=None):
|
|
out = dict(shot)
|
|
if guide_frames is not None:
|
|
fr = guide_frames
|
|
H, W = shot["height"], shot["width"]
|
|
if tuple(fr.shape[1:3]) != (H, W):
|
|
arr = (fr.clamp(0, 1) * 255).round().to(torch.uint8).cpu().numpy()
|
|
fr = torch.from_numpy(np.stack([_fit(a, (W, H)) for a in arr]).astype(np.float32) / 255.0)
|
|
n = shot["gen_length"]
|
|
if fr.shape[0] != n:
|
|
print(f"[BFS Shot Repack] shot {shot['index'] + 1}: guide has {fr.shape[0]} frames, "
|
|
f"expected {n}; {'trimming' if fr.shape[0] > n else 'holding the last frame'}")
|
|
fr = fr[:n] if fr.shape[0] > n else torch.cat([fr, fr[-1:].expand(n - fr.shape[0], -1, -1, -1)], 0)
|
|
out["frames"] = fr
|
|
if ref_image is not None:
|
|
out["ref"] = ref_image[:1]
|
|
if ref_image_2 is not None:
|
|
out["ref2"] = ref_image_2[:1]
|
|
if prompt is not None:
|
|
out["prompt"] = prompt
|
|
return (out,)
|
|
|
|
|
|
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)."}),
|
|
"duet": (["off", "canvas", "shifted RoPE"], {"default": "off", "tooltip":
|
|
"Pin the shot's own clip in a side panel and generate in sync with it (training-free duet). "
|
|
"canvas: panel and video share one wide grid. shifted RoPE: the video keeps its own RoPE "
|
|
"positions and the panel sits past its edge (connect the model and use the model output). "
|
|
"BFS Shot Join cuts the panel off by itself. In the prompt the panel has no tag: call it "
|
|
"'the kept footage' by its side ('the LEFT half')."}),
|
|
"model": ("MODEL", {"tooltip": "Needed for 'shifted RoPE': route the model through this node."}),
|
|
"panel_position": (["left", "right", "top", "bottom"], {"default": "left"}),
|
|
"panel_size": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 1.5, "step": 0.01,
|
|
"tooltip": "Panel size against the video (1.0 = two equal halves)."}),
|
|
"panel_noise": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"tooltip": "0 pins the panel exactly; 0.05-0.2 loosens it for bigger changes."}),
|
|
"rope_gap": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 256.0, "step": 1.0,
|
|
"tooltip": "shifted RoPE only: empty RoPE steps (2x2 patches) between video and panel. Keep it small "
|
|
"against the video width (0-2 at low resolution): a gap close to the "
|
|
"video's width makes the model draw its own split screen."}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("CONDITIONING", "LATENT", "MODEL")
|
|
RETURN_NAMES = ("positive", "latent", "model")
|
|
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, duet="off", model=None, panel_position="left", panel_size=1.0,
|
|
panel_noise=0.0, rope_gap=0.0):
|
|
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"]
|
|
prev = chain_image(shot)
|
|
if prev is not None and shot.get("chain") == "reference":
|
|
refs[f"ref_image_{len(refs)}"] = prev # one more <Picture n>, after the shot's own
|
|
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]
|
|
on_canvas = duet != "off"
|
|
if aligned and (not on_canvas or audio is not None):
|
|
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae,
|
|
audio_vae=audio_vae if audio is not None else None,
|
|
image=shot["frames"], audio=audio).args[0]
|
|
if first_frame != "none":
|
|
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae,
|
|
image=shot["frames"][:1]).args[0]
|
|
prev = chain_image(shot)
|
|
if prev is not None and shot.get("chain") == "first frame":
|
|
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae,
|
|
image=prev).args[0]
|
|
shot.pop("panel", None)
|
|
if on_canvas:
|
|
try:
|
|
from .bfs_h3_side_panel import BFSH3SidePanel, patch_model_rope
|
|
except ImportError:
|
|
from bfs_h3_side_panel import BFSH3SidePanel, patch_model_rope
|
|
# the guide goes straight onto the canvas (guides added above are moved onto it by the panel step)
|
|
guide = shot["frames"] if aligned and audio is None else None
|
|
positive, latent, info, _, _ = BFSH3SidePanel().apply(
|
|
positive, latent, vae, shot["frames"], panel_position, panel_size, "contain", 0, "all frames",
|
|
panel_noise, guide, 0)
|
|
shot["panel"] = info # BFS Shot Join crops the decoded canvas back to the video
|
|
if duet == "shifted RoPE":
|
|
if model is None:
|
|
raise ValueError("duet 'shifted RoPE' needs the model input (and its model output in the sampler)")
|
|
model = patch_model_rope(model, info, rope_gap)
|
|
return (positive, latent, model)
|
|
|
|
|
|
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)."}),
|
|
"timeline": ("BFS_SHOT_TIMELINE", {"tooltip": "The planner's timeline. With it, shots that "
|
|
"did not run are filled with the original video (or dropped, per the planner's setting) "
|
|
"and the soundtrack follows."}),
|
|
"comparison": ("BOOLEAN", {"default": False, "tooltip": "Also output a side-by-side video: "
|
|
"original shot | references | result, with the shot's info on top and its prompt below."}),
|
|
"label": ("STRING", {"default": "", "multiline": False, "tooltip": "Extra text for the "
|
|
"comparison's top bar, e.g. the model, LoRA, steps and seed."})},
|
|
}
|
|
|
|
INPUT_IS_LIST = True
|
|
RETURN_TYPES = ("IMAGE", "AUDIO", "FLOAT", "IMAGE")
|
|
RETURN_NAMES = ("images", "audio", "fps", "comparison")
|
|
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, timeline=None, comparison=None, label=None):
|
|
tl = timeline[0] if timeline else None
|
|
want = bool(comparison[0]) if comparison else False
|
|
lab = (label[0] if label else "") or ""
|
|
self._parts = [] # (shot or None, frames in the output, original frames) per piece, for the comparison
|
|
images = [crop_panel(img, sh) for img, sh in zip(images, shots)]
|
|
images = [uncrop(img, sh) if sh.get("crop") and sh.get("full_frames") is not None else img
|
|
for img, sh in zip(images, shots)]
|
|
if shots and shots[0].get("queue"):
|
|
out = self._join_queue(images, shots, crossfade, audio, tl)
|
|
else:
|
|
out = self._join_all(images, shots, crossfade, audio, tl)
|
|
if len(out) == 3 and not isinstance(out[0], torch.Tensor): # queue loop, not the last shot
|
|
return out + (out[0],)
|
|
video = out[0]
|
|
comp = comparison_video(video, self._parts, lab) if want else video[:1]
|
|
return tuple(out) + (comp,)
|
|
|
|
def _join_queue(self, images, shots, crossfade, audio, tl=None):
|
|
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", "prompt",
|
|
"ref", "ref2", "frames", "full_frames")}},
|
|
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, tl)
|
|
|
|
def _join_all(self, images, shots, crossfade, audio=None, tl=None):
|
|
if tl is not None and any(not s["run"] for s in tl["segs"]):
|
|
return self._join_timeline(images, shots, crossfade, audio, tl)
|
|
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)
|
|
if hasattr(self, "_parts"):
|
|
self._parts.append((shots[j], L, shots[j].get("full_frames") if shots[j].get("full_frames") is not None
|
|
else shots[j].get("frames")))
|
|
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)
|
|
|
|
|
|
def _join_timeline_impl(self, images, shots, crossfade, audio, tl):
|
|
"""Rebuild the whole timeline: generated shots where they ran, original video (or nothing) elsewhere."""
|
|
xf = int(crossfade[0]) if crossfade else 0
|
|
by_idx = {shots[j]["index"]: images[j] for j in range(len(shots))}
|
|
meta = {s["index"]: s for s in shots}
|
|
first = images[0]
|
|
H, W = first.shape[1:3]
|
|
fps = float(tl["fps"])
|
|
src = np.asarray(tl["src"])
|
|
out, kept = [], []
|
|
prev_tail = None
|
|
for seg in tl["segs"]:
|
|
L = seg["end"] - seg["start"]
|
|
if seg["run"]:
|
|
img = by_idx[seg["run_index"]]
|
|
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)
|
|
body = img[:L]
|
|
if body.shape[0] < L:
|
|
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 seg["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); kept.append((seg["start"], L))
|
|
if hasattr(self, "_parts"):
|
|
sm = meta.get(seg["run_index"]) or {}
|
|
self._parts.append((sm, L, sm.get("full_frames") if sm.get("full_frames") is not None else sm.get("frames")))
|
|
prev_tail = img[L:]
|
|
else:
|
|
prev_tail = None
|
|
if tl.get("fill", "original") == "drop":
|
|
continue
|
|
frames = _read_frames(tl["path"], src[seg["start"]:seg["end"]], (W, H))
|
|
orig = torch.from_numpy(np.stack(frames).astype(np.float32) / 255.0)
|
|
out.append(orig); kept.append((seg["start"], L))
|
|
if hasattr(self, "_parts"):
|
|
self._parts.append(({"skipped": True, "start": seg["start"], "end": seg["end"], "fps": fps,
|
|
"cut_before": seg["cut_before"]}, L, orig))
|
|
video = torch.cat(out, 0)
|
|
a = (audio[0] if audio else None) or tl.get("audio")
|
|
if a is not None:
|
|
sr = a["sample_rate"]; base = tl["segs"][0]["start"]
|
|
if tl.get("fill", "original") == "drop":
|
|
parts = [a["waveform"][..., int(round((st - base) / fps * sr)):int(round((st - base + L) / fps * sr))] for st, L in kept]
|
|
wf = torch.cat(parts, -1)
|
|
else:
|
|
wf = a["waveform"]
|
|
n = int(round(video.shape[0] / fps * sr))
|
|
wf = wf[..., :n]
|
|
if wf.shape[-1] < n:
|
|
wf = torch.nn.functional.pad(wf, (0, n - wf.shape[-1]))
|
|
a = {"waveform": wf, "sample_rate": sr}
|
|
return (video, a, fps)
|
|
|
|
|
|
BFSShotJoin._join_timeline = _join_timeline_impl
|
|
|
|
|
|
def _font(size: int):
|
|
from PIL import ImageFont
|
|
for name in ("DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "arial.ttf"):
|
|
try:
|
|
return ImageFont.truetype(name, size)
|
|
except OSError:
|
|
continue
|
|
try:
|
|
return ImageFont.load_default(size=size)
|
|
except TypeError:
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _text_bar(lines: list[str], width: int, size: int, max_lines: int, fg=(235, 235, 235), bg=(18, 18, 22)):
|
|
"""A dark bar with word-wrapped lines, as a float tensor [h, width, 3]."""
|
|
from PIL import Image, ImageDraw
|
|
font = _font(size)
|
|
probe = ImageDraw.Draw(Image.new("RGB", (8, 8)))
|
|
wrapped = []
|
|
for line in lines:
|
|
words, cur = line.split(), ""
|
|
for w in words:
|
|
t = (cur + " " + w).strip()
|
|
if probe.textlength(t, font=font) <= width - 2 * size and cur:
|
|
cur = t
|
|
elif not cur:
|
|
cur = w
|
|
else:
|
|
wrapped.append(cur); cur = w
|
|
wrapped.append(cur)
|
|
if len(wrapped) > max_lines:
|
|
wrapped = wrapped[:max_lines]
|
|
wrapped[-1] = wrapped[-1][: max(0, len(wrapped[-1]) - 3)] + "..."
|
|
lh = int(size * 1.3)
|
|
img = Image.new("RGB", (width, lh * len(wrapped) + size), bg)
|
|
d = ImageDraw.Draw(img)
|
|
for i, t in enumerate(wrapped):
|
|
d.text((size, size // 2 + i * lh), t, fill=fg, font=font)
|
|
return torch.from_numpy(np.asarray(img).astype(np.float32) / 255.0)
|
|
|
|
|
|
def _column_labels(columns: list[tuple[str, int]], size: int) -> torch.Tensor:
|
|
"""One row with each label centred over its column."""
|
|
from PIL import Image, ImageDraw
|
|
font = _font(size)
|
|
total = sum(w for _, w in columns)
|
|
img = Image.new("RGB", (total, int(size * 1.6)), (30, 30, 36))
|
|
d = ImageDraw.Draw(img)
|
|
x = 0
|
|
for name, w in columns:
|
|
tw = d.textlength(name, font=font)
|
|
d.text((x + (w - tw) / 2, size * 0.25), name, fill=(255, 210, 90), font=font)
|
|
x += w
|
|
return torch.from_numpy(np.asarray(img).astype(np.float32) / 255.0)
|
|
|
|
|
|
def _fit_box(img: torch.Tensor | None, w: int, h: int) -> torch.Tensor:
|
|
"""[1,H,W,3] or None -> [h,w,3], letterboxed on dark grey."""
|
|
out = torch.full((h, w, 3), 0.1)
|
|
if img is None:
|
|
return out
|
|
x = img[:1, ..., :3].movedim(-1, 1).float()
|
|
s = min(w / x.shape[-1], h / x.shape[-2])
|
|
nw, nh = max(1, int(x.shape[-1] * s)), max(1, int(x.shape[-2] * s))
|
|
x = torch.nn.functional.interpolate(x, size=(nh, nw), mode="bilinear", align_corners=False)[0].movedim(0, -1)
|
|
out[(h - nh) // 2:(h - nh) // 2 + nh, (w - nw) // 2:(w - nw) // 2 + nw] = x.clamp(0, 1)
|
|
return out
|
|
|
|
|
|
def comparison_video(video: torch.Tensor, parts: list, label: str = "") -> torch.Tensor:
|
|
"""original | references | result for every output frame, with shot info on top and the prompt below."""
|
|
H, W = video.shape[1:3]
|
|
rw = max(32, W // 2)
|
|
total_w = W * 2 + rw
|
|
size = max(12, total_w // 70)
|
|
n = sum(L for _, L, _ in parts) or video.shape[0]
|
|
count = sum(1 for s, _, _ in parts if s and not s.get("skipped"))
|
|
cols = _column_labels([("original", W), ("references", rw), ("result", W)], size)
|
|
frames, pos, k = [], 0, 0
|
|
for shot, L, orig in parts:
|
|
shot = shot or {}
|
|
fps = float(shot.get("fps") or 24.0)
|
|
skipped = bool(shot.get("skipped"))
|
|
if not skipped:
|
|
k += 1
|
|
t0, t1 = shot.get("start", 0) / fps, shot.get("end", 0) / fps
|
|
head = [f"{'skipped (original video)' if skipped else f'shot {k}/{count}'} "
|
|
f"{int(t0 // 60)}:{t0 % 60:05.2f} -> {int(t1 // 60)}:{t1 % 60:05.2f} {L} frames"
|
|
f"{' -> ' + str(shot.get('gen_length')) + ' generated' if shot.get('gen_length') else ''}"
|
|
f"{' cut' if shot.get('cut_before') else ''}"]
|
|
if label:
|
|
head.append(label)
|
|
top = torch.cat([_text_bar(head, total_w, size, 3), cols], 0)
|
|
prompt = " ".join(str(shot.get("prompt") or "").split())
|
|
bottom = _text_bar([("prompt: " + prompt) if prompt else ("" if skipped else "prompt: (none)")], total_w,
|
|
max(10, int(size * 0.8)), 6)
|
|
refs = [r for r in (shot.get("ref"), shot.get("ref2")) if r is not None]
|
|
if refs:
|
|
rh = H // len(refs)
|
|
col = torch.cat([_fit_box(r, rw, rh) for r in refs] + ([torch.full((H - rh * len(refs), rw, 3), 0.1)]
|
|
if H - rh * len(refs) else []), 0)
|
|
else:
|
|
col = _fit_box(None, rw, H)
|
|
for i in range(L):
|
|
res = video[pos + i, ..., :3]
|
|
if orig is not None and orig.shape[0]:
|
|
o = orig[min(i, orig.shape[0] - 1)][..., :3].float()
|
|
if o.shape[:2] != (H, W):
|
|
o = torch.nn.functional.interpolate(o.movedim(-1, 0)[None], size=(H, W), mode="bilinear",
|
|
align_corners=False)[0].movedim(0, -1)
|
|
else:
|
|
o = torch.full((H, W, 3), 0.1)
|
|
frames.append(torch.cat([top, torch.cat([o.cpu(), col, res.cpu()], 1), bottom], 0))
|
|
pos += L
|
|
if not frames:
|
|
return video[:1]
|
|
# same height for every frame, and both sides on a multiple of 16: video encoders (x264 / yuv420p) need even sizes
|
|
hmax = max(f.shape[0] for f in frames)
|
|
H16, W16 = -(-hmax // 16) * 16, -(-total_w // 16) * 16
|
|
out = torch.full((min(n, len(frames)), H16, W16, 3), 18 / 255)
|
|
for i, f in enumerate(frames[:n]):
|
|
out[i, :f.shape[0], :f.shape[1]] = f
|
|
return out
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"BFSShotPlanner": BFSShotPlanner,
|
|
"BFSShotUnpack": BFSShotUnpack,
|
|
"BFSShotRepack": BFSShotRepack,
|
|
"BFSShotH3Conditioning": BFSShotH3Conditioning,
|
|
"BFSShotJoin": BFSShotJoin,
|
|
}
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"BFSShotPlanner": "BFS Shot Planner",
|
|
"BFSShotUnpack": "BFS Shot Unpack",
|
|
"BFSShotRepack": "BFS Shot Repack",
|
|
"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):
|
|
# images are only listed on request: an input folder can hold thousands of them
|
|
images = _list_input(IMAGE_EXTS) if request.query.get("images") in ("1", "true") else []
|
|
return web.json_response({"videos": _list_input(VIDEO_EXTS), "images": images})
|
|
|
|
@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"])
|
|
if p.get("cast") or p.get("cast_split"):
|
|
try:
|
|
analyze_cast(path, a)
|
|
except Exception: # noqa: BLE001 - plan without people
|
|
pass
|
|
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/filters")
|
|
async def _bfs_shot_filters(request):
|
|
body = await request.json()
|
|
try:
|
|
p = _load_plan(json.dumps(body.get("plan", {})))
|
|
f = dict(DEFAULT_FILTERS); f.update(p.get("filters") or {})
|
|
path = _input_path(p["video"])
|
|
a = analyze(path, float(p["fps"]))
|
|
segs = resolve_plan(p, a, path)
|
|
for s in segs: # stats always, so the panel can show them before any filter is on
|
|
s["stats"] = shot_stats(path, a, s, int(f.get("samples") or 6))
|
|
s["skip_reason"] = skip_reason(s["stats"], s["end"] - s["start"], f)
|
|
except Exception as exc: # noqa: BLE001
|
|
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
|
return web.json_response({"segs": [{"start": s["start"], "end": s["end"], "stats": s["stats"],
|
|
"skip_reason": s["skip_reason"]} for s in segs]})
|
|
|
|
@PromptServer.instance.routes.get("/bfs/shotloop/frame")
|
|
async def _bfs_shot_frame(request):
|
|
import cv2
|
|
q = request.rel_url.query
|
|
try:
|
|
path = _input_path(q["video"])
|
|
a = analyze(path, float(q.get("fps", 24)))
|
|
f = max(0, min(a["n"] - 1, int(q.get("f", 0))))
|
|
src = _timeline(a["n_src"], a["fps_src"], float(a["fps"]))
|
|
w = int(q.get("w", 960))
|
|
h = max(16, int(round(w * a["height"] / max(1, a["width"]))))
|
|
fr = _read_frames(path, src[[f]], (w, h))[0]
|
|
ok, buf = cv2.imencode(".jpg", cv2.cvtColor(fr, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 88])
|
|
except Exception as exc: # noqa: BLE001
|
|
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
|
return web.Response(body=buf.tobytes(), content_type="image/jpeg")
|
|
|
|
@PromptServer.instance.routes.post("/bfs/shotloop/mask")
|
|
async def _bfs_shot_mask(request):
|
|
body = await request.json()
|
|
try:
|
|
p = _load_plan(json.dumps(body.get("plan", {})))
|
|
path = _input_path(p["video"])
|
|
a = analyze(path, float(p["fps"]))
|
|
segs = resolve_plan(p, a, path)
|
|
seg = segs[int(body.get("index", 0))]
|
|
spec = mask_spec(body.get("mask") or seg.get("mask"), p.get("mask_cfg"))
|
|
import asyncio # SAM 3 takes seconds: keep the server responsive
|
|
_outside_prompt()
|
|
out = await asyncio.get_running_loop().run_in_executor(
|
|
None, lambda: mask_preview(path, a, seg["start"], seg["end"] - seg["start"], spec, int(body.get("count", 6))))
|
|
except Exception as exc: # noqa: BLE001
|
|
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
|
return web.json_response(out)
|
|
|
|
@PromptServer.instance.routes.post("/bfs/shotloop/vlm")
|
|
async def _bfs_shot_vlm(request):
|
|
body = await request.json()
|
|
try:
|
|
clip = _VLM.get("clip")
|
|
if clip is None:
|
|
raise ValueError("No VLM yet: connect a CLIPLoader with qwen3vl_4b / qwen3vl_8b to the planner's "
|
|
"vlm input and run the workflow once, then Analyse works from the panel.")
|
|
p = _load_plan(json.dumps(body.get("plan", {})))
|
|
path = _input_path(p["video"])
|
|
a = analyze(path, float(p["fps"]))
|
|
segs = resolve_plan(p, a, path)
|
|
want = body.get("indices")
|
|
pick = [segs[i] for i in want] if want else segs
|
|
cfg = vlm_cfg(p.get("vlm_cfg"))
|
|
import asyncio
|
|
_outside_prompt()
|
|
out = await asyncio.get_running_loop().run_in_executor(
|
|
None, lambda: [dict(vlm_shot(clip, path, a, s["start"], s["end"], cfg), start=s["start"], end=s["end"])
|
|
for s in pick])
|
|
except Exception as exc: # noqa: BLE001
|
|
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
|
return web.json_response({"segs": out})
|
|
|
|
@PromptServer.instance.routes.post("/bfs/shotloop/cast")
|
|
async def _bfs_shot_cast(request):
|
|
body = await request.json()
|
|
try:
|
|
p = _load_plan(json.dumps(body.get("plan", {})))
|
|
path = _input_path(p["video"])
|
|
a = analyze(path, float(p["fps"]))
|
|
c = analyze_cast(path, a)
|
|
except Exception as exc: # noqa: BLE001
|
|
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
|
return web.json_response({"people": c["people"], "step": c["step"], "samples": len(c["samples"])})
|
|
|
|
@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}")
|