Files
alisson-anjos-ComfyUI-BFSNodes/web/js/bfs_shot_loop.js
T
alisson-anjosandClaude Opus 5.5 bf473e1dbd Shot Planner / Shot Loop: split long videos into model-sized shots (1.48.0)
A video model only follows a guide inside the clip length it was trained on, so long
sources are split into shots that fit and run once per shot.

- BFS Shot Planner: interactive Vue timeline (thumbnails, cut-detector curve, draggable
  boundaries, split/merge, per-shot reference gallery with upload, per-shot prompt).
  Splits at camera cuts via PySceneDetect (adaptive/content, built-in fallback), fixed
  length, or by hand; lengths snap to the model grid (H3 17n+5, LTX/Wan 8n+1, 4n+1).
  Outputs the shots as a ComfyUI list so everything downstream runs once per shot.
- BFS Shot Unpack: guide frames, references, prompt, length, audio of one shot.
- BFS Shot H3 Conditioning: native MiniMax H3 conditioning per shot (aligned guide,
  native reference video, or both).
- BFS Shot Join: concatenates in order, trims each shot to its true length, cross-fades
  soft joins with the real overlap frames, returns the soundtrack.
- Queue loop mode: one shot per run stored on disk, auto re-queue, nodes after the join
  blocked until the last shot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:32:32 +00:00

471 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BFS Shot Planner: split a long video into model-sized shots on a timeline, give each shot its own
* reference image and prompt, and run the rest of the graph once per shot.
*
* All state lives in the hidden `plan` STRING widget (JSON) so a workflow saves, reloads and shares
* exactly what you set up. The panel is a Vue app mounted into a DOM widget; the server does the video
* work (probe, thumbnails, PySceneDetect cuts, split) through /bfs/shotloop/* routes.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { createApp, ref, reactive, computed, onMounted, onBeforeUnmount, h } from "./vendor/vue.esm-browser.prod.mjs";
const GRIDS = { "H3 (17n+5)": [17, 5], "LTX / Wan (8n+1)": [8, 1], "Wan (4n+1)": [4, 1], "any": [1, 0] };
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: "",
megapixels: 0.15, multiple: 32, detector: "adaptive", run: "auto", auto_continue: true,
};
const snapUp = (n, grid) => {
const [s, o] = GRIDS[grid] || GRIDS["H3 (17n+5)"]; n = Math.max(1, n | 0);
if (s === 1) return n; if (n <= o) return o; return o + s * Math.ceil((n - o) / s);
};
const snapDown = (n, grid) => {
const [s, o] = GRIDS[grid] || GRIDS["H3 (17n+5)"]; n = Math.max(1, n | 0);
if (s === 1) return n; if (n <= o) return o; return o + s * Math.floor((n - o) / s);
};
const hue = i => `hsl(${(i * 47 + 200) % 360} 55% 46%)`;
const viewUrl = name => {
if (!name) return "";
const i = name.lastIndexOf("/");
const sub = i >= 0 ? name.slice(0, i) : "", file = i >= 0 ? name.slice(i + 1) : name;
return api.apiURL(`/view?filename=${encodeURIComponent(file)}&subfolder=${encodeURIComponent(sub)}&type=input`);
};
const fmtT = (f, fps) => { const s = f / fps; return `${Math.floor(s / 60)}:${(s % 60).toFixed(2).padStart(5, "0")}`; };
function styles() {
if (document.getElementById("bfs-shotloop-css")) return;
const el = document.createElement("style");
el.id = "bfs-shotloop-css";
el.textContent = `
.bsl{font:12px/1.45 var(--font-family,system-ui,sans-serif);color:#c9c9cf;background:#17171b;border-radius:10px;
height:100%;overflow:auto;box-sizing:border-box;padding:10px;position:relative}
.bsl *{box-sizing:border-box}
.bsl .hdr{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.bsl .ttl{font-weight:600;color:#ececf1;font-size:13px;letter-spacing:.01em}
.bsl .pill{font-size:10px;padding:1px 7px;border-radius:999px;background:#25252d;color:#a9a9b4;border:1px solid #33333d}
.bsl .pill.ok{background:#173527;color:#7fe0a8;border-color:#245c40}
.bsl .pill.warn{background:#3a2a12;color:#ffc46b;border-color:#6a4a16}
.bsl .grow{flex:1}
.bsl select,.bsl input[type=number],.bsl input[type=text],.bsl textarea{background:#101014;border:1px solid #33333d;color:#e6e6ea;
border-radius:6px;padding:4px 7px;font-size:11px;font-family:inherit;width:100%}
.bsl textarea{resize:vertical;min-height:54px;font-family:ui-monospace,monospace}
.bsl input[type=range]{width:100%;accent-color:#5b8cff}
.bsl button{background:#26262e;border:1px solid #393945;color:#dcdce2;border-radius:6px;padding:4px 9px;font-size:11px;cursor:pointer;white-space:nowrap}
.bsl button:hover{background:#30303a}
.bsl button.pri{background:#3a63e0;border-color:#4b74f0;color:#fff}
.bsl button.pri:hover{background:#4672ee}
.bsl button.dng{background:#3a1f22;border-color:#6a2c33;color:#ffb3b3}
.bsl button:disabled{opacity:.45;cursor:default}
.bsl .card{background:#1d1d23;border:1px solid #2c2c35;border-radius:9px;padding:8px;margin-bottom:8px}
.bsl .card h5{margin:0 0 6px;font-size:11px;font-weight:600;color:#b9b9c4;text-transform:uppercase;letter-spacing:.06em;display:flex;gap:6px;align-items:center}
.bsl .grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px 8px}
.bsl .fld label{display:block;font-size:10px;color:#8c8c97;margin-bottom:2px}
.bsl .hint{font-size:10px;color:#7d7d88}
.bsl .err{color:#ff8f8f;background:#2a1517;border:1px solid #5a2228;border-radius:6px;padding:5px 8px;margin-bottom:8px}
.bsl .tl{position:relative;overflow-x:auto;overflow-y:hidden;border-radius:7px;background:#111115;border:1px solid #2a2a33}
.bsl .tlin{position:relative}
.bsl .strip{display:flex;height:64px;overflow:hidden}
.bsl .strip img{height:64px;object-fit:cover;flex:none;opacity:.92}
.bsl .spark{display:block;height:26px;width:100%}
.bsl .segs{position:relative;height:30px;margin-top:2px;cursor:crosshair}
.bsl .seg{position:absolute;top:2px;bottom:2px;border-radius:5px;display:flex;align-items:center;justify-content:center;
font-size:10px;color:#fff;font-weight:600;overflow:hidden;cursor:pointer;border:2px solid transparent;user-select:none}
.bsl .seg.sel{border-color:#fff;box-shadow:0 0 0 2px #5b8cff66}
.bsl .seg.off{opacity:.35;background-image:repeating-linear-gradient(45deg,#0004 0 6px,#0000 6px 12px)}
.bsl .seg.long{outline:2px solid #ff5d5d;outline-offset:-2px}
.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 .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 .tip{position:absolute;z-index:6;pointer-events:none;background:#0d0d10ee;border:1px solid #3a3a44;border-radius:6px;padding:3px;font-size:10px;color:#ddd}
.bsl .tip img{display:block;height:84px;border-radius:4px}
.bsl .shots{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:6px}
.bsl .sc{background:#202027;border:1px solid #30303a;border-radius:8px;padding:6px;cursor:pointer;position:relative}
.bsl .sc:hover{border-color:#4a4a58}
.bsl .sc.sel{border-color:#5b8cff;box-shadow:0 0 0 1px #5b8cff}
.bsl .sc .bar{height:3px;border-radius:2px;margin-bottom:5px}
.bsl .sc .t{font-size:10px;color:#9a9aa6}
.bsl .sc .p{font-size:10px;color:#c9c9d3;margin-top:3px;height:28px;overflow:hidden}
.bsl .thumbs{display:flex;gap:4px;margin-top:4px}
.bsl .rt{width:34px;height:34px;border-radius:5px;object-fit:cover;background:#2a2a33;border:1px solid #3a3a45}
.bsl .rt.ph2{display:flex;align-items:center;justify-content:center;color:#666;font-size:9px}
.bsl .refbox{display:flex;gap:8px;align-items:center}
.bsl .refslot{width:78px;height:78px;border-radius:8px;border:1px dashed #44444f;background:#141418;display:flex;align-items:center;
justify-content:center;cursor:pointer;overflow:hidden;color:#6f6f7a;font-size:10px;text-align:center;flex:none}
.bsl .refslot img{width:100%;height:100%;object-fit:cover}
.bsl .refslot:hover{border-color:#5b8cff}
.bsl .modal{position:absolute;inset:0;background:#0b0b0ecc;z-index:20;display:flex;align-items:center;justify-content:center;padding:14px}
.bsl .mbox{background:#1b1b21;border:1px solid #3a3a45;border-radius:10px;width:100%;max-height:100%;display:flex;flex-direction:column}
.bsl .mhd{display:flex;gap:6px;align-items:center;padding:8px;border-bottom:1px solid #2c2c35}
.bsl .mgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px;padding:8px;overflow:auto}
.bsl .mi{border:2px solid transparent;border-radius:7px;overflow:hidden;cursor:pointer;background:#141418}
.bsl .mi:hover{border-color:#5b8cff}
.bsl .mi img{width:100%;height:84px;object-fit:cover;display:block}
.bsl .mi div{font-size:9px;padding:2px 4px;color:#9a9aa6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bsl .prog{height:7px;border-radius:4px;background:#26262e;overflow:hidden}
.bsl .prog>div{height:100%;background:linear-gradient(90deg,#3a63e0,#7fe0a8)}
.bsl .row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.bsl details>summary{cursor:pointer;list-style:none;user-select:none}
.bsl details>summary::-webkit-details-marker{display:none}
`;
document.head.appendChild(el);
}
function Panel(io) {
const plan = reactive({ ...DEFAULTS });
const files = reactive({ videos: [], images: [] });
const an = ref(null); // analysis from the server
const cuts = ref([]);
const detectorUsed = ref("");
const size = reactive({ w: 0, h: 0 });
const busy = ref(""); const error = ref("");
const sel = ref(0); const zoom = ref(1);
const hover = ref(null); // {frame, x}
const gallery = ref(null); // {target: index|'global', field: 'ref'|'ref2'}
const gq = ref("");
const prog = reactive({ done: 0, count: 0 });
const tlEl = ref(null);
const load = () => {
let p = {};
try { p = JSON.parse(io.getPlan() || "{}"); } catch { p = {}; }
Object.assign(plan, DEFAULTS, p);
if (!Array.isArray(plan.bounds)) plan.bounds = [];
if (!Array.isArray(plan.segs)) plan.segs = [];
};
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 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 cs = new Set(cuts.value);
let out = [];
for (let i = 0; i < b.length - 1; i++) {
if (b[i + 1] <= b[i]) continue;
const m = plan.segs[out.length] || {};
out.push({ start: b[i], end: b[i + 1], len: b[i + 1] - b[i], gen: snapUp(b[i + 1] - b[i], plan.grid),
cut: cs.has(b[i]), enabled: m.enabled !== false, ref: m.ref || "", ref2: m.ref2 || "", prompt: m.prompt || "" });
}
if (plan.max_parts > 0) out = out.slice(0, plan.max_parts);
return out;
});
const active = computed(() => segs.value.filter(s => s.enabled));
const pxPerFrame = computed(() => {
const w = (tlEl.value?.clientWidth || 600) - 2;
return Math.max(0.2, (w / Math.max(1, n.value)) * zoom.value);
});
const tlWidth = computed(() => Math.max(1, Math.round(n.value * pxPerFrame.value)));
const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return plan.segs[i]; };
async function refreshFiles() {
try { const r = await api.fetchApi("/bfs/shotloop/files"); Object.assign(files, await r.json()); } catch (e) { /* ignore */ }
}
async function analyze() {
an.value = null; error.value = "";
if (!plan.video) return;
busy.value = "Analysing video…";
try {
const r = await api.fetchApi(`/bfs/shotloop/analyze?video=${encodeURIComponent(plan.video)}&fps=${plan.fps}`);
const j = await r.json();
if (j.error) throw new Error(j.error);
an.value = j;
await autoSplit(plan.bounds.length === 0);
} catch (e) { error.value = String(e.message || e); }
busy.value = "";
}
async function autoSplit(apply = true) {
if (!plan.video) return;
busy.value = "Detecting cuts…"; error.value = "";
try {
const body = { plan: { ...plan, bounds: apply ? [] : plan.bounds } };
const r = await api.fetchApi("/bfs/shotloop/plan", { method: "POST", body: JSON.stringify(body) });
const j = await r.json();
if (j.error) throw new Error(j.error);
cuts.value = j.cuts || []; detectorUsed.value = j.detector || ""; size.w = j.width; size.h = j.height;
if (apply) {
plan.bounds = j.segs.slice(1).map(s => s.start);
const keep = plan.segs; plan.segs = j.segs.map((_, i) => ({ enabled: true, ref: keep[i]?.ref || "", ref2: keep[i]?.ref2 || "", prompt: keep[i]?.prompt || "" }));
sel.value = 0; save();
}
} catch (e) { error.value = String(e.message || e); }
busy.value = "";
}
async function upload(file, cb) {
const fd = new FormData(); fd.append("image", file); fd.append("type", "input"); fd.append("overwrite", "true");
busy.value = `Uploading ${file.name}…`;
try {
const r = await api.fetchApi("/upload/image", { method: "POST", body: fd });
const j = await r.json();
await refreshFiles();
cb(j.subfolder ? `${j.subfolder}/${j.name}` : j.name);
} catch (e) { error.value = `Upload failed: ${e}`; }
busy.value = "";
}
const pickFile = (accept, cb) => {
const inp = document.createElement("input"); inp.type = "file"; inp.accept = accept;
inp.onchange = () => inp.files[0] && upload(inp.files[0], cb); inp.click();
};
async function progress(reset = false) {
if (!plan.video || plan.run !== "queue") return;
try {
const r = await api.fetchApi("/bfs/shotloop/progress", { method: "POST", body: JSON.stringify({ plan: { ...plan }, plan_raw: io.getPlan(), reset }) });
const j = await r.json(); if (!j.error) { prog.done = j.done; prog.count = j.count || active.value.length; }
} catch { /* ignore */ }
}
// ---- editing
const setVideo = v => { plan.video = v; plan.bounds = []; plan.segs = []; save(); analyze(); };
const splitAt = f => {
const N = n.value; f = Math.round(f);
if (f <= 0 || f >= N || 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] || {}) });
sel.value = i + 1; save();
};
const mergeNext = i => {
const s = segs.value[i]; if (!s || i >= segs.value.length - 1) return;
plan.bounds = plan.bounds.filter(b => b !== s.end); plan.segs.splice(i + 1, 1); save();
};
const setMeta = (i, k, v) => { meta(i)[k] = v; save(); };
const applyAll = (k) => { const v = meta(sel.value)[k] || ""; segs.value.forEach((_, i) => { meta(i)[k] = v; }); save(); };
const drag = (k, ev) => {
ev.preventDefault(); ev.stopPropagation();
const box = tlEl.value.getBoundingClientRect();
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;
plan.bounds[k] = Math.max(lo, Math.min(hi, f));
hover.value = { frame: plan.bounds[k], x };
};
const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); save(); };
window.addEventListener("pointermove", move); window.addEventListener("pointerup", up);
};
const frameAt = e => { const box = tlEl.value.getBoundingClientRect(); const x = e.clientX - box.left + tlEl.value.scrollLeft; return { frame: Math.max(0, Math.min(n.value - 1, Math.round(x / pxPerFrame.value))), x }; };
const thumbFor = f => { const t = an.value?.thumbs; if (!t?.length) return ""; let b = t[0]; for (const x of t) { if (x.f <= f) b = x; else break; } return b.src; };
// ---- queue loop events
const onProg = e => { prog.done = e.detail.done; prog.count = e.detail.count; };
const onNext = e => { onProg(e); if (plan.run === "queue" && plan.auto_continue) setTimeout(() => app.queuePrompt(0, 1), 300); };
onMounted(() => {
load(); refreshFiles(); analyze(); progress();
api.addEventListener("bfs-shotloop-progress", onProg); api.addEventListener("bfs-shotloop-next", onNext);
});
onBeforeUnmount(() => { api.removeEventListener("bfs-shotloop-progress", onProg); api.removeEventListener("bfs-shotloop-next", onNext); });
io.expose({ reload: () => { load(); analyze(); progress(); } });
// ---- view helpers
const fld = (label, input, hint) => h("div", { class: "fld" }, [h("label", label), input, hint ? h("div", { class: "hint" }, hint) : null]);
const num = (k, step = 0.1, min = 0) => h("input", { type: "number", step, min, value: plan[k], onChange: e => { plan[k] = parseFloat(e.target.value) || 0; save(); } });
const sel_ = (k, opts, after) => h("select", { value: plan[k], onChange: e => { plan[k] = e.target.value; save(); after && after(); } },
opts.map(o => h("option", { value: Array.isArray(o) ? o[0] : o }, Array.isArray(o) ? o[1] : o)));
const refSlot = (name, label, onClick, onClear) => h("div", { class: "refslot", title: name || label, onClick },
name ? [h("img", { src: viewUrl(name) })] : [label]);
return () => {
const S = segs.value, cur = S[sel.value], N = n.value, ppf = pxPerFrame.value, fps = plan.fps;
const tooLong = S.filter(s => s.len > maxLen.value).length;
const totalGen = active.value.reduce((a, s) => a + s.gen, 0);
const header = h("div", { class: "hdr" }, [
h("span", { class: "ttl" }, "🎬 Shot Planner"),
busy.value ? h("span", { class: "pill warn" }, busy.value) : (an.value ? h("span", { class: "pill ok" }, `${active.value.length} shots · ${(N / fps).toFixed(1)}s`) : null),
h("span", { class: "grow" }),
plan.run === "queue" ? h("span", { class: "pill" }, "queue loop") : h("span", { class: "pill" }, "auto loop"),
]);
const source = h("div", { class: "card" }, [
h("h5", ["Source video", detectorUsed.value ? h("span", { class: "pill" }, detectorUsed.value) : null]),
h("div", { class: "row" }, [
h("div", { style: "flex:1;min-width:180px" }, [h("select", { value: plan.video, onChange: e => setVideo(e.target.value) },
[h("option", { value: "" }, "— choose a video —"), ...files.videos.map(v => h("option", { value: v }, v))])]),
h("button", { onClick: () => pickFile("video/*", setVideo) }, "⬆ Upload"),
h("button", { onClick: refreshFiles, title: "refresh the input folder" }, "↻"),
]),
an.value ? h("div", { class: "hint", style: "margin-top:4px" },
`${an.value.width}×${an.value.height} · ${an.value.fps_src.toFixed(2)} fps · ${an.value.duration.toFixed(2)}s → timeline ${fps} fps, ${an.value.n} frames · generate at ${size.w}×${size.h}`) : null,
]);
const settings = h("details", { class: "card", open: true }, [
h("summary", h("h5", ["Split settings", h("span", { class: "hint", style: "text-transform:none;letter-spacing:0" }, `max ${maxLen.value} frames per shot (${(maxLen.value / fps).toFixed(2)}s)`)])),
h("div", { class: "grid" }, [
fld("Mode", sel_("mode", [["shots", "Camera cuts"], ["fixed", "Fixed length"]])),
fld("Detector", sel_("detector", [["adaptive", "PySceneDetect adaptive"], ["content", "PySceneDetect content"], ["builtin", "Built-in"]])),
fld(`Sensitivity ${Number(plan.sensitivity).toFixed(2)}`, h("input", { type: "range", min: 0, max: 1, step: 0.05, value: plan.sensitivity, onInput: e => { plan.sensitivity = parseFloat(e.target.value); }, onChange: save })),
fld("Frame grid", sel_("grid", Object.keys(GRIDS))),
fld("Max seconds / shot", num("max_s", 0.1, 0.2)),
fld("Min seconds / shot", num("min_s", 0.1, 0)),
fld("Timeline fps", num("fps", 1, 1), "re-analyses"),
fld("Max shots (0 = all)", num("max_parts", 1, 0)),
fld("Max total seconds (0 = all)", num("max_total_s", 0.5, 0)),
fld("Megapixels", num("megapixels", 0.01, 0.02)),
fld("Size multiple", num("multiple", 8, 8)),
fld("Run", sel_("run", [["auto", "Auto loop (one run)"], ["queue", "Queue loop (one shot per run)"]], progress)),
]),
h("div", { class: "row", style: "margin-top:8px" }, [
h("button", { class: "pri", disabled: !plan.video || !!busy.value, onClick: () => autoSplit(true) }, "✂ Auto split"),
h("button", { disabled: !plan.video, onClick: analyze }, "↻ Re-analyse"),
h("span", { class: "hint" }, "Drag the white handles to move a boundary · double-click the shot bar to split there"),
]),
]);
// timeline
const thumbs = an.value?.thumbs || [];
const thumbW = Math.max(8, (an.value?.thumb_w || 96) * 0 + (thumbs.length ? tlWidth.value / thumbs.length : 96));
const score = an.value?.score || [];
const maxS = Math.max(4, ...score.slice(0, N));
const sparkPts = score.slice(0, N).map((v, i) => `${(i * ppf).toFixed(1)},${(24 - Math.min(1, v / maxS) * 22).toFixed(1)}`).join(" ");
const timeline = an.value ? h("div", { class: "card" }, [
h("h5", ["Timeline", h("span", { class: "grow" }), h("span", { class: "hint", style: "text-transform:none" }, "zoom"),
h("input", { type: "range", min: 1, max: 8, step: 0.5, value: zoom.value, style: "width:110px", onInput: e => { zoom.value = parseFloat(e.target.value); } })]),
h("div", { class: "tl", ref: tlEl,
onPointermove: e => { if (!e.buttons) hover.value = frameAt(e); }, onPointerleave: () => { hover.value = null; } }, [
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` }))),
...cuts.value.filter(c => c < N).map(c => h("div", { class: "cut", style: `left:${c * ppf}px`, title: `cut @ ${c}` })),
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" })]),
h("div", { class: "segs", onDblclick: e => splitAt(frameAt(e).frame) }, [
...S.map((s, i) => h("div", {
class: ["seg", i === sel.value && "sel", !s.enabled && "off", s.len > maxLen.value && "long"],
style: `left:${s.start * ppf}px;width:${Math.max(2, s.len * ppf - 1)}px;background:${hue(i)}`,
title: `#${i + 1} · frames ${s.start}-${s.end - 1} · ${s.len} → ${s.gen}`, onClick: () => { sel.value = i; },
}, s.len * ppf > 26 ? `${i + 1}` : "")),
...plan.bounds.filter(b => b < N).map((b, k) => h("div", { class: "hdl", style: `left:${b * ppf}px`, title: `boundary @ ${b}`, onPointerdown: e => drag(k, e) })),
]),
hover.value ? h("div", { class: "ph", style: `left:${hover.value.x}px` }) : null,
]),
hover.value ? h("div", { class: "tip", style: `left:${Math.min(tlWidth.value - 160, Math.max(0, hover.value.x - (tlEl.value?.scrollLeft || 0) - 70))}px;top:2px` },
[h("img", { src: thumbFor(hover.value.frame) }), h("div", `frame ${hover.value.frame} · ${fmtT(hover.value.frame, fps)}`)]) : null,
]),
h("div", { class: "row", style: "margin-top:6px" }, [
h("span", { class: "pill" }, `${S.length} shots`), h("span", { class: "pill" }, `${cuts.value.length} cuts`),
h("span", { class: "pill" }, `generate ${totalGen} frames`),
tooLong ? h("span", { class: "pill warn" }, `${tooLong} shot(s) longer than ${maxLen.value} frames`) : null,
]),
]) : null;
// shot cards
const cards = S.length ? h("div", { class: "card" }, [
h("h5", "Shots"),
h("div", { class: "shots" }, S.map((s, i) => h("div", { class: ["sc", i === sel.value && "sel"], onClick: () => { sel.value = i; } }, [
h("div", { class: "bar", style: `background:${hue(i)}` }),
h("div", { class: "row" }, [h("b", `#${i + 1}`), s.cut ? h("span", { class: "pill" }, "cut") : null, !s.enabled ? h("span", { class: "pill warn" }, "off") : null]),
h("div", { class: "t" }, `${fmtT(s.start, fps)} → ${fmtT(s.end, fps)} · ${s.len}f → ${s.gen}f`),
h("div", { class: "thumbs" }, [
s.ref || plan.global_ref ? h("img", { class: "rt", src: viewUrl(s.ref || plan.global_ref), style: s.ref ? "" : "opacity:.45" }) : h("div", { class: "rt ph2" }, "ref"),
s.ref2 || plan.global_ref2 ? h("img", { class: "rt", src: viewUrl(s.ref2 || plan.global_ref2), style: s.ref2 ? "" : "opacity:.45" }) : h("div", { class: "rt ph2" }, "ref2"),
h("img", { class: "rt", src: thumbFor(s.start + Math.floor(s.len / 2)), style: "width:56px" }),
]),
h("div", { class: "p" }, s.prompt ? s.prompt : (plan.global_prompt ? "↳ global prompt" : "— no prompt —")),
]))),
]) : null;
// editor for the selected shot
const editor = cur ? h("div", { class: "card" }, [
h("h5", [`Shot #${sel.value + 1}`, h("span", { class: "hint", style: "text-transform:none" }, `frames ${cur.start}–${cur.end - 1} · ${cur.len} → generate ${cur.gen}`)]),
h("div", { class: "row", style: "align-items:flex-start;gap:10px" }, [
h("div", { class: "refbox" }, [
refSlot(cur.ref, "+ reference\n(uses global)", () => { gallery.value = { target: sel.value, field: "ref" }; }),
refSlot(cur.ref2, "+ ref 2\n(uses global)", () => { gallery.value = { target: sel.value, field: "ref2" }; }),
]),
h("div", { style: "flex:1;min-width:200px" }, [
h("textarea", { placeholder: "Prompt for this shot (empty = global prompt)", value: cur.prompt, onChange: e => setMeta(sel.value, "prompt", e.target.value) }),
]),
]),
h("div", { class: "row", style: "margin-top:6px" }, [
h("button", { onClick: () => setMeta(sel.value, "enabled", !cur.enabled) }, cur.enabled ? "⏸ Disable" : "▶ Enable"),
h("button", { onClick: () => splitAt(cur.start + Math.floor(cur.len / 2)) }, "✂ Split in half"),
h("button", { disabled: sel.value >= S.length - 1, onClick: () => mergeNext(sel.value) }, "⇥ Merge with next"),
h("button", { onClick: () => applyAll("ref") }, "Ref → all"), h("button", { onClick: () => applyAll("prompt") }, "Prompt → all"),
h("button", { class: "dng", onClick: () => { ["ref", "ref2", "prompt"].forEach(k => { meta(sel.value)[k] = ""; }); save(); } }, "Use global"),
]),
]) : null;
const globals = h("div", { class: "card" }, [
h("h5", "Global (used by shots without their own)"),
h("div", { class: "row", style: "align-items:flex-start;gap:10px" }, [
h("div", { class: "refbox" }, [
refSlot(plan.global_ref, "+ global\nreference", () => { gallery.value = { target: "global", field: "global_ref" }; }),
refSlot(plan.global_ref2, "+ global\nref 2", () => { gallery.value = { target: "global", field: "global_ref2" }; }),
]),
h("div", { style: "flex:1;min-width:200px" }, [h("textarea", { placeholder: "Global prompt (a connected `prompt` input overrides it)", value: plan.global_prompt, onChange: e => { plan.global_prompt = e.target.value; save(); } })]),
]),
h("div", { class: "hint", style: "margin-top:4px" }, "Connected ref_image / ref_image_2 / prompt inputs on the node override these defaults."),
]);
const queue = plan.run === "queue" ? h("div", { class: "card" }, [
h("h5", ["Queue loop", h("span", { class: "grow" }), h("span", { class: "hint", style: "text-transform:none" }, `${prog.done}/${prog.count || active.value.length} shots done`)]),
h("div", { class: "prog" }, [h("div", { style: `width:${(100 * prog.done / Math.max(1, prog.count || active.value.length)).toFixed(1)}%` })]),
h("div", { class: "row", style: "margin-top:6px" }, [
h("label", { class: "row" }, [h("input", { type: "checkbox", checked: plan.auto_continue, onChange: e => { plan.auto_continue = e.target.checked; save(); } }), "Auto-queue the next shot"]),
h("button", { onClick: () => progress(false) }, "↻ Status"), h("button", { class: "dng", onClick: () => progress(true) }, "⟲ Reset loop"),
]),
h("div", { class: "hint", style: "margin-top:4px" }, "Each run generates one shot and stores it. Nodes after BFS Shot Join only run on the last shot, with the full video."),
]) : null;
const modal = gallery.value ? h("div", { class: "modal", onClick: e => { if (e.target === e.currentTarget) gallery.value = null; } }, [
h("div", { class: "mbox" }, [
h("div", { class: "mhd" }, [
h("b", "Choose reference"), h("input", { type: "text", placeholder: "search…", value: gq.value, onInput: e => { gq.value = e.target.value; }, style: "flex:1" }),
h("button", { onClick: () => pickFile("image/*", name => { choose(name); }) }, "⬆ Upload"),
h("button", { onClick: () => choose("") }, "None"), h("button", { onClick: () => { gallery.value = null; } }, "✕"),
]),
h("div", { class: "mgrid" }, files.images.filter(f => f.toLowerCase().includes(gq.value.toLowerCase())).slice(0, 400).map(f =>
h("div", { class: "mi", onClick: () => choose(f), title: f }, [h("img", { src: viewUrl(f), loading: "lazy" }), h("div", f)]))),
]),
]) : null;
function choose(name) {
const g = gallery.value; if (!g) return;
if (g.target === "global") plan[g.field] = name; else meta(g.target)[g.field] = name;
save(); gallery.value = null;
}
return h("div", { class: "bsl" }, [header, error.value ? h("div", { class: "err" }, error.value) : null, source, settings, timeline, cards, editor, globals, queue, modal]);
};
}
app.registerExtension({
name: "BFSNodes.ShotPlanner",
async nodeCreated(node) {
if (node.comfyClass !== "BFSShotPlanner") return;
styles();
const planWidget = node.widgets?.find(w => w.name === "plan");
if (planWidget) { planWidget.type = "hidden"; planWidget.computeSize = () => [0, -4]; }
const host = document.createElement("div");
host.style.cssText = "width:100%;height:100%;min-height:520px";
node.addDOMWidget("bfs_shot_planner", "div", host, { serialize: false, hideOnZoom: false });
const handle = {};
createApp({
setup: () => Panel({
getPlan: () => planWidget?.value ?? "{}",
setPlan: v => { if (planWidget) planWidget.value = v; node.graph?.setDirtyCanvas(true); },
expose: o => Object.assign(handle, o),
}),
}).mount(host);
if (planWidget) {
planWidget.options = planWidget.options || {};
planWidget.options.serialize = true;
planWidget.serializeValue = () => planWidget.value;
}
const prev = node.onConfigure;
node.onConfigure = function () {
const r = prev?.apply(this, arguments);
setTimeout(() => handle.reload?.(), 0);
return r;
};
node.size = [Math.max(node.size[0], 780), Math.max(node.size[1], 860)];
},
});