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 <noreply@anthropic.com>
This commit is contained in:
alisson-anjos
2026-10-16 12:52:14 +00:00
co-authored by Claude Opus 5.5
parent 35ef99090b
commit d62a72147c
4 changed files with 23 additions and 4 deletions
+11 -1
View File
@@ -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:
+1 -1
View File
@@ -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",
+4
View File
@@ -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)
+7 -2
View File
@@ -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";
}