From 34ffcef1720f2de7253c7d060465d9be905d5b24 Mon Sep 17 00:00:00 2001 From: alisson-anjos Date: Thu, 8 Oct 2026 17:42:42 +0000 Subject: [PATCH] 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 --- bfs_shot_loop.py | 37 +++++++++++++++++++++----------- pyproject.toml | 2 +- tests/test_bfs_shot_loop.py | 16 ++++++++++++++ web/js/bfs_shot_loop.js | 28 +++++++++++++++--------- web/js/docs/BFSShotPlanner.md | 5 +++++ web/js/shotloop/common.js | 2 +- web/js/shotloop/styles.js | 1 + web/js/shotloop/view_run.js | 2 +- web/js/shotloop/view_timeline.js | 3 +++ web/js/shotloop/view_video.js | 17 ++++++++++++++- 10 files changed, 86 insertions(+), 27 deletions(-) diff --git a/bfs_shot_loop.py b/bfs_shot_loop.py index cbb37f3..d701da8 100644 --- a/bfs_shot_loop.py +++ b/bfs_shot_loop.py @@ -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 {} diff --git a/pyproject.toml b/pyproject.toml index 1fb1196..df4ed8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_bfs_shot_loop.py b/tests/test_bfs_shot_loop.py index 480e3a9..2166f27 100644 --- a/tests/test_bfs_shot_loop.py +++ b/tests/test_bfs_shot_loop.py @@ -768,5 +768,21 @@ class FitPromptTest(unittest.TestCase): self.assertEqual(SL.tidy_slot(" 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() diff --git a/web/js/bfs_shot_loop.js b/web/js/bfs_shot_loop.js index db717a8..47fddfd 100644 --- a/web/js/bfs_shot_loop.js +++ b/web/js/bfs_shot_loop.js @@ -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"), diff --git a/web/js/docs/BFSShotPlanner.md b/web/js/docs/BFSShotPlanner.md index fa88010..34f6bce 100644 --- a/web/js/docs/BFSShotPlanner.md +++ b/web/js/docs/BFSShotPlanner.md @@ -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. diff --git a/web/js/shotloop/common.js b/web/js/shotloop/common.js index 4bf66fa..8f333bf 100644 --- a/web/js/shotloop/common.js +++ b/web/js/shotloop/common.js @@ -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, diff --git a/web/js/shotloop/styles.js b/web/js/shotloop/styles.js index 38a1264..d0e7be2 100644 --- a/web/js/shotloop/styles.js +++ b/web/js/shotloop/styles.js @@ -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} diff --git a/web/js/shotloop/view_run.js b/web/js/shotloop/view_run.js index e838dca..d87cdf2 100644 --- a/web/js/shotloop/view_run.js +++ b/web/js/shotloop/view_run.js @@ -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" }), diff --git a/web/js/shotloop/view_timeline.js b/web/js/shotloop/view_timeline.js index b23e5c0..67a5dee 100644 --- a/web/js/shotloop/view_timeline.js +++ b/web/js/shotloop/view_timeline.js @@ -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" })]), diff --git a/web/js/shotloop/view_video.js b/web/js/shotloop/view_video.js index af886ef..0efc8fc 100644 --- a/web/js/shotloop/view_video.js +++ b/web/js/shotloop/view_video.js @@ -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]); }