Shot Planner: Start at / End at range - only that part of the video is split, analysed, generated and joined; timeline dims the rest; max total seconds counts from the start (1.79.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9ae5fc9614
commit
34ffcef172
+24
-13
@@ -364,22 +364,34 @@ def detect_cuts(score: list[float], raw: list[float], sensitivity: float, fps: f
|
||||
|
||||
# ---------------------------------------------------------------------------- planning
|
||||
|
||||
def plan_range(n: int, fps: float, start_s: float = 0.0, end_s: float = 0.0, max_total_s: float = 0.0) -> tuple[int, int]:
|
||||
"""The part of the video the plan works on, in timeline frames [lo, hi): start / end (0 = the end of the video),
|
||||
then 'max total seconds' counted from the start."""
|
||||
lo = max(0, min(n - 1, int(round(float(start_s or 0) * fps))))
|
||||
hi = n if not end_s or float(end_s) <= 0 else max(lo + 1, min(n, int(round(float(end_s) * fps))))
|
||||
if max_total_s and float(max_total_s) > 0:
|
||||
hi = min(hi, lo + max(1, int(round(float(max_total_s) * fps))))
|
||||
return lo, hi
|
||||
|
||||
|
||||
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."""
|
||||
max_parts: int = 0, max_total: int = 0, manual: list[int] | None = None, start: int = 0) -> list[dict]:
|
||||
"""Split [start, n) into shots (n = the end frame). Returns [{start, end, cut_before}]. Lengths are in frames."""
|
||||
lo = max(0, int(start or 0))
|
||||
if max_total and max_total > 0:
|
||||
n = min(n, int(max_total))
|
||||
n = min(n, lo + int(max_total))
|
||||
lo = min(lo, n - 1)
|
||||
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)
|
||||
cuts = sorted(c for c in set(cuts or []) if lo < c < n)
|
||||
cutset = set(cuts)
|
||||
if mode == "manual" and manual:
|
||||
bounds = sorted(set([0, n] + [b for b in manual if 0 < b < n]))
|
||||
bounds = sorted(set([lo, n] + [b for b in manual if lo < 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)))
|
||||
k = max(1, math.ceil((n - lo) / max_len))
|
||||
bounds = sorted(set(lo + round((n - lo) * j / k) for j in range(k + 1)))
|
||||
else: # shots
|
||||
bounds = [0] + cuts + [n]
|
||||
bounds = [lo] + cuts + [n]
|
||||
# merge shots that are too short into a neighbour, as long as the merge still fits
|
||||
changed = True
|
||||
while changed:
|
||||
@@ -397,7 +409,7 @@ def plan_segments(n: int, cuts: list[int], mode: str, max_len: int, min_len: int
|
||||
changed = True
|
||||
break
|
||||
# split shots that are too long into equal parts
|
||||
out = [0]
|
||||
out = [lo]
|
||||
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)]
|
||||
@@ -410,7 +422,7 @@ def plan_segments(n: int, cuts: list[int], mode: str, max_len: int, min_len: int
|
||||
|
||||
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": [],
|
||||
"sensitivity": 0.5, "max_parts": 0, "max_total_s": 0.0, "start_s": 0.0, "end_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": {}, "audio_mode": "auto", "ref_details": {},
|
||||
@@ -433,7 +445,7 @@ def resolve_plan(plan: dict, analysis: dict, path: str | None = None) -> list[di
|
||||
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))
|
||||
lo, hi = plan_range(analysis["n"], fps, plan.get("start_s", 0), plan.get("end_s", 0), plan.get("max_total_s", 0))
|
||||
if path:
|
||||
cuts, _ = find_cuts(path, analysis, plan.get("detector", "adaptive"), float(plan["sensitivity"]))
|
||||
else:
|
||||
@@ -447,8 +459,7 @@ def resolve_plan(plan: dict, analysis: dict, path: str | None = None) -> list[di
|
||||
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)
|
||||
segs = plan_segments(hi, cuts, mode, max_len, min_len, int(plan.get("max_parts") or 0), 0, manual, start=lo)
|
||||
meta = plan.get("segs") or []
|
||||
for i, s in enumerate(segs):
|
||||
m = meta[i] if i < len(meta) and isinstance(meta[i], dict) else {}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "bfsnodes"
|
||||
description = "Custom ComfyUI nodes for reference-guided video: BFS Sampler (guide-driven IC-LoRAs, auto crop, mask inpainting, temporal chunking), LTX Multiple Controls / Identity Transfer / Edit Anything, Head-Swap and Anime2Real conditioning, MiniMax-H3 latent guides, RoPE layouts and loaders, LoRA Surgery, and Shot Planner / Shot Loop (tabbed panel): split long videos into shots at camera cuts, face-tracked cast, SAM 3 mask per shot with four generation modes (full frame, mask only, crop, crop + mask), optional Qwen3-VL to suggest settings, describe references ({details}) and the person to replace ({target}), continuity between shots, H3 conditioning with an optional duet and prompt writer, join with the original timing, audio and a side-by-side comparison. H3 Duet: pin a source clip beside the video, training-free."
|
||||
version = "1.78.2"
|
||||
version = "1.79.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"torch",
|
||||
|
||||
@@ -768,5 +768,21 @@ class FitPromptTest(unittest.TestCase):
|
||||
self.assertEqual(SL.tidy_slot("<Subject 1> is the green goblin in a vest", d), "green goblin in a vest")
|
||||
|
||||
|
||||
class RangeTest(unittest.TestCase):
|
||||
def test_start_end_and_max_total_from_the_start(self):
|
||||
self.assertEqual(SL.plan_range(1000, 24.0, 10, 20), (240, 480))
|
||||
self.assertEqual(SL.plan_range(1000, 24.0, 10, 0), (240, 1000))
|
||||
self.assertEqual(SL.plan_range(1000, 24.0, 10, 0, 5), (240, 360)) # max total counted from the start
|
||||
segs = SL.plan_segments(480, [100, 300], "shots", 107, 10, start=240)
|
||||
self.assertEqual(segs[0]["start"], 240)
|
||||
self.assertEqual(segs[-1]["end"], 480)
|
||||
self.assertTrue(all(240 <= s["start"] < s["end"] <= 480 for s in segs))
|
||||
self.assertTrue(any(s["start"] == 300 for s in segs)) # the cut inside the range is kept
|
||||
fixed = SL.plan_segments(480, [], "fixed", 107, 10, start=240)
|
||||
self.assertEqual((fixed[0]["start"], fixed[-1]["end"]), (240, 480))
|
||||
manual = SL.plan_segments(480, [], "manual", 107, 10, manual=[100, 350], start=240)
|
||||
self.assertEqual([(s["start"], s["end"]) for s in manual], [(240, 350), (350, 480)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+18
-10
@@ -84,15 +84,20 @@ function Panel(io) {
|
||||
};
|
||||
const save = () => io.setPlan(JSON.stringify(plan));
|
||||
|
||||
const n = computed(() => {
|
||||
if (!an.value) return 0;
|
||||
const cap = plan.max_total_s > 0 ? Math.round(plan.max_total_s * plan.fps) : Infinity;
|
||||
return Math.min(an.value.n, cap);
|
||||
const n = computed(() => (an.value ? an.value.n : 0)); // the whole video on the timeline
|
||||
// the part the plan works on [lo, hi): start / end (0 = the end), then "max total seconds" counted from the start
|
||||
const range = computed(() => {
|
||||
const N = n.value; if (!N) return { lo: 0, hi: 0 };
|
||||
const lo = Math.max(0, Math.min(N - 1, Math.round((+plan.start_s || 0) * plan.fps)));
|
||||
let hi = +plan.end_s > 0 ? Math.max(lo + 1, Math.min(N, Math.round(+plan.end_s * plan.fps))) : N;
|
||||
if (+plan.max_total_s > 0) hi = Math.min(hi, lo + Math.max(1, Math.round(+plan.max_total_s * plan.fps)));
|
||||
return { lo, hi };
|
||||
});
|
||||
const maxLen = computed(() => snapDown(Math.round(plan.max_s * plan.fps), plan.grid));
|
||||
const segs = computed(() => {
|
||||
const N = n.value; if (!N) return [];
|
||||
const b = [0, ...plan.bounds.filter(x => x > 0 && x < N), N].sort((a, c) => a - c);
|
||||
const { lo, hi } = range.value;
|
||||
const b = [lo, ...plan.bounds.filter(x => x > lo && x < hi), hi].sort((a, c) => a - c);
|
||||
const cs = new Set(cuts.value);
|
||||
let out = [];
|
||||
for (let i = 0; i < b.length - 1; i++) {
|
||||
@@ -364,6 +369,8 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
const setCastOpt = (k, v) => { plan[k] = v; save(); if (k === "cast_split") autoSplit(true); };
|
||||
const setFilter = (k, v) => { plan.filters = { ...plan.filters, [k]: v }; save(); if (stats.value.length) analyzeContent(); };
|
||||
const setPlan = (k, v, after) => { plan[k] = v; save(); after && after(); };
|
||||
// the range changed: split again inside it (the per-shot settings stay with the shot at the same position)
|
||||
const setRange = (k, v) => { plan[k] = Math.max(0, +v || 0); save(); autoSplit(true); };
|
||||
const setGlobalMaskVideo = v => { plan.mask_video = v; save(); maskPrev.value = {}; };
|
||||
// XHR instead of fetch: big videos show how much has been sent
|
||||
const postWithProgress = (url, body, onPct) => new Promise((resolve, reject) => {
|
||||
@@ -401,8 +408,8 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
// ---- editing
|
||||
const setVideo = v => { plan.video = v; plan.bounds = []; plan.segs = []; save(); analyze(); if (v) tab.value = "shots"; };
|
||||
const splitAt = f => {
|
||||
const N = n.value; f = Math.round(f);
|
||||
if (f <= 0 || f >= N || plan.bounds.includes(f)) return;
|
||||
const { lo, hi } = range.value; f = Math.round(f);
|
||||
if (f <= lo || f >= hi || plan.bounds.includes(f)) return;
|
||||
const i = segs.value.findIndex(s => f > s.start && f < s.end);
|
||||
plan.bounds = [...plan.bounds, f].sort((a, c) => a - c);
|
||||
plan.segs.splice(i + 1, 0, { ...(plan.segs[i] || {}) });
|
||||
@@ -439,7 +446,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
const move = e => {
|
||||
const x = e.clientX - box.left + tlEl.value.scrollLeft;
|
||||
const f = Math.round(x / pxPerFrame.value);
|
||||
const lo = (plan.bounds[k - 1] ?? 0) + 1, hi = (plan.bounds[k + 1] ?? n.value) - 1;
|
||||
const lo = (plan.bounds[k - 1] ?? range.value.lo) + 1, hi = (plan.bounds[k + 1] ?? range.value.hi) - 1;
|
||||
plan.bounds[k] = Math.max(lo, Math.min(hi, f));
|
||||
hover.value = { frame: plan.bounds[k], x };
|
||||
};
|
||||
@@ -527,7 +534,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
const c = {
|
||||
api, plan, files, an, cuts, detectorUsed, size, busy, error, status, busySince, now, upPct, sel, zoom, hover, prog, tlEl,
|
||||
stats, people, maskPrev, showMasks, vlmSug, modal, segPeople, targetCrop, tab, copyOpts, COPY_FIELDS, vid, play, recent,
|
||||
n, maxLen, segs, active, pxPerFrame, tlWidth, filtersOn, refSets, usedRefs, checks,
|
||||
n, range, setRange, maxLen, segs, active, pxPerFrame, tlWidth, filtersOn, refSets, usedRefs, checks,
|
||||
MODES, modeOf, modeName, viewUrl, save, meta, setMeta, setMode, setPlan, whoIn, castOf, linked, castRef, personOf, statFor, skipWhy, refOf, promptOf, checksFor,
|
||||
refreshFiles, analyze, autoSplit, analyzeContent, findPeople, setCast, describeTarget, previewMask, setMask, setMaskCfg,
|
||||
openPoints, savePoints, savePointsAll, pointsTo, clearMask, copyFrom, analyseVLM, describeRefs, setDetail, setVlmCfg,
|
||||
@@ -541,7 +548,8 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
const guideUrl = new URL("./docs/BFSShotPlanner.md", import.meta.url).href;
|
||||
const header = h("div", { class: "hdr" }, [
|
||||
h("span", { class: "ttl" }, "🎬 Shot Planner"),
|
||||
an.value ? pill(`${active.value.length}/${S.length} shots · ${(N / fps).toFixed(1)}s`, "ok") : null,
|
||||
an.value ? pill(`${active.value.length}/${S.length} shots · ${((range.value.hi - range.value.lo) / fps).toFixed(1)}s`
|
||||
+ (range.value.lo > 0 || range.value.hi < N ? " (range)" : ""), "ok") : null,
|
||||
warns ? h("span", { class: "pill warn", style: "cursor:pointer", title: "open the checks (Run tab)", onClick: () => { tab.value = "run"; } }, `⚠ ${warns}`) : null,
|
||||
h("span", { class: "grow" }),
|
||||
pill(plan.run === "queue" ? "queue loop" : "auto loop"),
|
||||
|
||||
@@ -78,6 +78,11 @@ camera cuts.
|
||||
shot bar to split, *⇥ Merge* to join, or select a shot and press **Delete** to remove the cut at its
|
||||
start (it merges into the previous shot). Your edits are what runs.
|
||||
|
||||
**Range** (🎞 Video tab): *Start at* / *End at* (seconds; 0 = the end of the video) limit the plan to a part of the
|
||||
video, before anything is split, analysed or generated; *⇤ / ⇥ from player* take the player's current position. The
|
||||
timeline dims what is outside; the join and the soundtrack cover only the range. *Max total seconds* counts from the
|
||||
start of the range.
|
||||
|
||||
*Max seconds / shot* is converted to frames and snapped down to the grid (4.5 s at 24 fps =
|
||||
107 frames for H3). *Max shots* and *Max total seconds* cap a long source.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { h } from "../vendor/vue.esm-browser.prod.mjs";
|
||||
export const GRIDS = { "H3 (17n+5)": [17, 5], "LTX / Wan (8n+1)": [8, 1], "Wan (4n+1)": [4, 1], "any": [1, 0] };
|
||||
export const DEFAULTS = {
|
||||
video: "", fps: 24, grid: "H3 (17n+5)", mode: "shots", max_s: 4.5, min_s: 1.0, sensitivity: 0.5,
|
||||
max_parts: 0, max_total_s: 0, bounds: [], segs: [], global_ref: "", global_ref2: "", global_prompt: "", mask_video: "",
|
||||
max_parts: 0, max_total_s: 0, start_s: 0, end_s: 0, bounds: [], segs: [], global_ref: "", global_ref2: "", global_prompt: "", mask_video: "",
|
||||
megapixels: 0.15, multiple: 32, detector: "adaptive", run: "auto", auto_continue: true,
|
||||
skip_fill: "original", audio_mode: "auto", ref_details: {}, mask_cfg: {}, vlm_cfg: {}, cast: {}, cast_assign: true, cast_split: false, cast_only: false,
|
||||
filters: { person: false, min_person_area: 0, max_persons: 0, face: false, skip_dark: false, dark_level: 0.06,
|
||||
|
||||
@@ -97,6 +97,7 @@ const CSS = `
|
||||
.bsl .hdl{position:absolute;top:-70px;bottom:0;width:9px;margin-left:-4px;cursor:ew-resize;z-index:3}
|
||||
.bsl .hdl::after{content:"";position:absolute;left:3px;top:0;bottom:0;width:3px;background:#fff;opacity:.85;border-radius:2px;box-shadow:0 0 4px #000}
|
||||
.bsl .hdl:hover::after{background:#ffd34d}
|
||||
.bsl .outr{position:absolute;top:0;bottom:0;background:repeating-linear-gradient(45deg,#000a 0 6px,#0007 6px 12px);z-index:2;pointer-events:none}
|
||||
.bsl .cut{position:absolute;top:0;height:64px;width:2px;background:#ff4d6d;opacity:.9;pointer-events:none}
|
||||
.bsl .ph{position:absolute;top:0;bottom:0;width:1px;background:#ffd34d;pointer-events:none;z-index:4}
|
||||
.bsl .playhead{position:absolute;top:0;bottom:0;width:2px;background:#ffd34d;box-shadow:0 0 6px #ffd34d;pointer-events:none;z-index:5}
|
||||
|
||||
@@ -29,7 +29,7 @@ function loopSection(c) {
|
||||
fld("Run", select(plan.run, [["auto", "Auto loop (one run)"], ["queue", "Queue loop (one shot per run)"]], v => c.setPlan("run", v, c.progress))),
|
||||
fld("Skipped shots in the output", select(plan.skip_fill, [["original", "Keep original video"], ["drop", "Remove them"]], v => c.setPlan("skip_fill", v))),
|
||||
fld("Max shots (0 = all)", num("max_parts", 1, 0), "quick tests"),
|
||||
fld("Max total seconds (0 = all)", num("max_total_s", 0.5, 0), "quick tests"),
|
||||
fld("Max total seconds (0 = all)", num("max_total_s", 0.5, 0), "from the start of the range"),
|
||||
]),
|
||||
plan.run === "queue" ? h("div", { style: "margin-top:8px" }, [
|
||||
h("div", { class: "row", style: "margin-bottom:4px" }, [h("b", "Queue loop"), h("span", { class: "grow" }),
|
||||
|
||||
@@ -26,6 +26,9 @@ export function timelineCard(c) {
|
||||
onClick: e => { if (e.target.classList.contains("hdl")) return; c.seek(c.frameAt(e).frame); } }, [
|
||||
h("div", { class: "tlin", style: `width:${tlWidth.value}px` }, [
|
||||
h("div", { class: "strip" }, thumbs.map(t => h("img", { src: t.src, style: `width:${thumbW}px` }))),
|
||||
// outside the start / end range: dimmed, not planned
|
||||
c.range.value.lo > 0 ? h("div", { class: "outr", style: `left:0;width:${c.range.value.lo * ppf}px`, title: "before the start: not planned" }) : null,
|
||||
c.range.value.hi < N ? h("div", { class: "outr", style: `left:${c.range.value.hi * ppf}px;width:${(N - c.range.value.hi) * ppf}px`, title: "after the end: not planned" }) : null,
|
||||
...cuts.value.filter(x => x < N).map(x => h("div", { class: "cut", style: `left:${x * ppf}px`, title: `cut @ ${x}` })),
|
||||
h("svg", { class: "spark", viewBox: `0 0 ${tlWidth.value} 26`, preserveAspectRatio: "none" },
|
||||
[h("polyline", { points: sparkPts, fill: "none", stroke: "#ff7a90", "stroke-width": 1, "vector-effect": "non-scaling-stroke" })]),
|
||||
|
||||
@@ -29,6 +29,21 @@ export function videoTab(c) {
|
||||
"It is split into shots the model can generate; every shot then gets its own reference, prompt and mask."]),
|
||||
]);
|
||||
|
||||
const R = c.range.value, N = c.n.value;
|
||||
const playF = Math.min(N - 1, Math.max(0, c.play.frame || 0));
|
||||
const rng = section("Range", [
|
||||
h("div", { class: "row", style: "align-items:flex-end;gap:10px" }, [
|
||||
fld("Start at (s)", h("input", { type: "number", step: 0.1, min: 0, value: plan.start_s || 0, onChange: e => c.setRange("start_s", e.target.value) }),
|
||||
`frame ${R.lo}`),
|
||||
h("button", { title: "start where the player is", onClick: () => c.setRange("start_s", +(playF / fps).toFixed(3)) }, "⇤ from player"),
|
||||
fld("End at (s, 0 = end)", h("input", { type: "number", step: 0.1, min: 0, value: plan.end_s || 0, onChange: e => c.setRange("end_s", e.target.value) }),
|
||||
`frame ${R.hi}`),
|
||||
h("button", { title: "end where the player is", onClick: () => c.setRange("end_s", +(playF / fps).toFixed(3)) }, "⇥ from player"),
|
||||
(plan.start_s || plan.end_s) ? h("button", { class: "ghost", onClick: () => { plan.start_s = 0; c.setRange("end_s", 0); } }, "✕ whole video") : null,
|
||||
hint(`${((R.hi - R.lo) / fps).toFixed(2)} s of ${(N / fps).toFixed(2)} s · only this part is split, analysed, generated and joined`),
|
||||
]),
|
||||
], { sub: "work on a part of the video" });
|
||||
|
||||
const split = section("Split into shots", [
|
||||
h("div", { class: "grid" }, [
|
||||
fld("Mode", sel_("mode", [["shots", "Camera cuts"], ["fixed", "Fixed length"]])),
|
||||
@@ -53,5 +68,5 @@ export function videoTab(c) {
|
||||
fld("Size multiple", num("multiple", 8, 8)),
|
||||
]), { sub: "cropped shots use it for the crop" });
|
||||
|
||||
return h("div", { class: "card" }, [source, an.value ? split : null, an.value ? gen : null]);
|
||||
return h("div", { class: "card" }, [source, an.value ? rng : null, an.value ? split : null, an.value ? gen : null]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user