From d62a72147c3d5549aacbfbf83111f4ff4e47f0db Mon Sep 17 00:00:00 2001 From: alisson-anjos Date: Fri, 16 Oct 2026 12:52:14 +0000 Subject: [PATCH] Shot Planner: a plan with no range works on the first 15 s (1.106.2) A plan that never set its range (fresh node, older workflow, plan made outside the panel) used the whole video, so a long video run through a workflow nobody adjusted generated every shot of it. Unset now means the first 15 s, read the same way by the panel and the server; the whole video is the explicit 'whole video' choice (end frame 0), as before. Co-Authored-By: Claude Opus 5.5 --- bfs_shot_loop.py | 12 +++++++++++- pyproject.toml | 2 +- tests/test_bfs_shot_loop.py | 4 ++++ web/js/bfs_shot_loop.js | 9 +++++++-- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/bfs_shot_loop.py b/bfs_shot_loop.py index e88d83b..7087319 100644 --- a/bfs_shot_loop.py +++ b/bfs_shot_loop.py @@ -388,12 +388,22 @@ def detect_cuts(score: list[float], raw: list[float], sensitivity: float, fps: f # ---------------------------------------------------------------------------- planning +# a plan that never set its range works on the first seconds only, never the whole video by surprise: a long video +# run by a workflow nobody adjusted would generate every shot of it. The whole video is an explicit end frame 0. +DEFAULT_RANGE_S = 15.0 + + def plan_range(n: int, fps: float, start_s: float = 0.0, end_s: float = 0.0, max_total_s: float = 0.0, start_f: int | None = None, end_f: int | None = None) -> tuple[int, int]: """The part of the video the plan works on, in timeline frames [lo, hi): start / end frame (end 0 = the end of the - video; older plans give seconds), then 'max total seconds' counted from the start.""" + video; older plans give seconds), then 'max total seconds' counted from the start. Nothing set at all: the first + DEFAULT_RANGE_S seconds.""" + unset = (start_f is None and end_f is None and not float(start_s or 0) and not float(end_s or 0) + and not float(max_total_s or 0)) a = int(start_f) if start_f is not None else int(round(float(start_s or 0) * fps)) b = int(end_f) if end_f is not None else (int(round(float(end_s) * fps)) if end_s and float(end_s) > 0 else 0) + if unset: + b = a + max(1, int(round(DEFAULT_RANGE_S * fps))) lo = max(0, min(n - 1, a)) hi = n if b <= 0 else max(lo + 1, min(n, b)) if max_total_s and float(max_total_s) > 0: diff --git a/pyproject.toml b/pyproject.toml index 8840cf4..17a1e92 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 full frame, paste, mask, crop and crop + mask generation modes, optional translucent Colored Mark overlay for manually selected regions, optional Qwen3-VL to suggest settings, describe references ({details}) and the person to replace ({target}), continuity between shots, H3 conditioning with an optional duet and prompt writer, join with the original timing, audio and a side-by-side comparison. H3 Duet: pin a source clip beside the video, training-free." -version = "1.106.1" +version = "1.106.2" license = {file = "LICENSE"} dependencies = [ "torch", diff --git a/tests/test_bfs_shot_loop.py b/tests/test_bfs_shot_loop.py index 0b24576..47f5c7c 100644 --- a/tests/test_bfs_shot_loop.py +++ b/tests/test_bfs_shot_loop.py @@ -909,6 +909,10 @@ class RangeTest(unittest.TestCase): self.assertEqual(SL.plan_range(1000, 24.0, 10, 0, 5), (240, 360)) # max total counted from the start self.assertEqual(SL.plan_range(1000, 24.0, 0, 0, 0, start_f=250, end_f=500), (250, 500)) # frames win self.assertEqual(SL.plan_range(1000, 24.0, 0, 0, 0, start_f=250, end_f=0), (250, 1000)) + # nothing set (a fresh or old plan): the first 15 s, never the whole video by surprise; end 0 = whole, on purpose + self.assertEqual(SL.plan_range(1000, 24.0), (0, 360)) + self.assertEqual(SL.plan_range(200, 24.0), (0, 200)) + self.assertEqual(SL.plan_range(1000, 24.0, 0, 0, 0, start_f=0, end_f=0), (0, 1000)) segs = SL.plan_segments(480, [100, 300], "shots", 107, 10, start=240) self.assertEqual(segs[0]["start"], 240) self.assertEqual(segs[-1]["end"], 480) diff --git a/web/js/bfs_shot_loop.js b/web/js/bfs_shot_loop.js index f05ebe9..e0c1c96 100644 --- a/web/js/bfs_shot_loop.js +++ b/web/js/bfs_shot_loop.js @@ -15,6 +15,7 @@ import { createApp, ref, reactive, computed, watch, nextTick, onMounted, onBefor import { h, rawH, t, uiLanguage, setUiLanguage, DEFAULTS, MASK_DEFAULTS, VLM_DEFAULTS, snapUp, snapDown, segKey, hasMask, pill } from "./shotloop/common.js"; import { styles } from "./shotloop/styles.js"; // history lengths H3's VAE encodes whole (17k+5); old plans saved 17/34/51, read as the next valid one up +const DEFAULT_RANGE_S = 15; // a plan that never set its range works on the first 15 s (bfs_shot_loop.DEFAULT_RANGE_S) const historyLen = n => (n = Number(n) || 22) <= 5 ? 5 : 5 + Math.ceil((n - 5) / 17) * 17; import { timelineCard } from "./shotloop/view_timeline.js"; import { videoTab } from "./shotloop/view_video.js"; @@ -103,7 +104,11 @@ function Panel(io) { const N = n.value; if (!N) return { lo: 0, hi: 0 }; // frames (older plans saved seconds) const a = plan.start_f != null ? +plan.start_f : Math.round((+plan.start_s || 0) * plan.fps); - const b = plan.end_f != null ? +plan.end_f : (+plan.end_s > 0 ? Math.round(+plan.end_s * plan.fps) : 0); + let b = plan.end_f != null ? +plan.end_f : (+plan.end_s > 0 ? Math.round(+plan.end_s * plan.fps) : 0); + // never set: the first 15 s (as the server reads it), so a long video is never run whole by surprise; + // "whole video" stores end 0 explicitly + if (plan.start_f == null && plan.end_f == null && !+plan.start_s && !+plan.end_s && !+plan.max_total_s) + b = a + Math.max(1, Math.round(DEFAULT_RANGE_S * plan.fps)); const lo = Math.max(0, Math.min(N - 1, a)); let hi = b > 0 ? Math.max(lo + 1, Math.min(N, b)) : N; if (+plan.max_total_s > 0) hi = Math.min(hi, lo + Math.max(1, Math.round(+plan.max_total_s * plan.fps))); @@ -566,7 +571,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla // do not surprise them with a wall of detected shots on first load. plan.mode = "single"; plan.start_f = 0; - plan.end_f = Math.round(15 * plan.fps); + plan.end_f = Math.round(DEFAULT_RANGE_S * plan.fps); plan.start_s = 0; plan.end_s = 0; plan.max_total_s = 0; tab.value = "video"; }