Files
olivv-cs-ComfyUI-FunPack/app/ui/shell/scenes.js
T
digital-garbageandClaude Sonnet 5.5 aa31b95ea5 Timeline header on one line; kit literals into tokens; Composer fits; running-state viewport test; remove Auto Montage
- Header: view toggles (Assets/Preview/Properties/Auto-hide) move to the top bar; progress, the prompt warning and scene actions (rate, upscale, interpolate) move to the row under the toolbar; the timeline head never wraps from 1000px up and never clips its controls. The top bar grows a line instead of spilling when it wraps.
- Kit: every literal pixel size in element CSS is a token (the ratchet test is now zero); Composer's text box follows the viewport; the catalogue shows the component sizes.
- test:viewport adds a fake ComfyUI queue and socket for the render-running state and asserts the header is one row.
- Auto Montage removed (button, window, code, README/docs, tests). Saved projects holding pieces it made still load: the reused flag is kept.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 14:28:45 +02:00

107 lines
5.5 KiB
JavaScript

// What a project's scenes are, as numbers: how long each runs, in what order the cut plays them, and where
// each begins on the timeline. Pure -- every function takes the project (and renders) it reads, so the same
// answers come out on the timeline, in the player and in a run.
/** A pipeline role that `drives` the plan's length or rate is fed from the project number the timeline draws. */
export const DRIVEN = { frames: "num_frames_per_scene", fps: "frame_rate" };
export const genUnitId = (sc) => (sc && (sc.gen_unit_id || sc.id)) || "";
export const isVideoClip = (sc) => Boolean(sc && sc.source && sc.source.type === "video");
export const isGenerative = (sc) => Boolean(sc) && !isVideoClip(sc) && !sc.reused; // a reused copy (from older projects) only replays media that already exists
/** A timeline cut of one generated clip: only its root owns the prompt, rating and source. */
export const isSubclip = (sc) => Boolean(sc) && (sc.cut_offset_frames || 0) > 0;
/** The unit's root scene (the one that owns text/source), or the first of the unit. */
export function unitRoot(p, unit) {
const group = (p.scenes || []).filter((s) => genUnitId(s) === unit);
return group.find((s) => !isSubclip(s)) || group[0] || null;
}
export const unitSceneIds = (p, unit) => (p.scenes || []).filter((s) => genUnitId(s) === unit && !s.excluded).map((s) => s.id);
/** Frame counts a model can make: `base + k * step`. LTX is 8k+1; a family says its own (see the probe). */
export const LTX_GRID = { step: 8, base: 1, fps: null };
export function snapFrames(n, mode = "round", grid = LTX_GRID) {
const k = (Math.round(Number(n) || 0) - grid.base) / grid.step;
const k2 = mode === "floor" ? Math.floor(k) : mode === "ceil" ? Math.ceil(k) : Math.round(k);
return Math.max(grid.step + grid.base, k2 * grid.step + grid.base);
}
// "project" follows the project; a trimmed scene (timeline/custom) owns its number.
export function effFrames(sc, p) {
if (!sc || (sc.frames_mode || "project") === "project") return p.num_frames_per_scene || 97;
return sc.frames != null ? sc.frames : p.num_frames_per_scene || 97;
}
export function effFps(sc, p) {
if (!sc || (sc.fps_mode || "project") === "project") return p.frame_rate || 25;
return sc.fps != null ? sc.fps : p.frame_rate || 25;
}
/** What the next Generate would make: ignores any render already there. */
export function planSeconds(sc, p) {
if (isVideoClip(sc) && sc.source_dur != null) return sc.source_dur;
return effFrames(sc, p) / (effFps(sc, p) || 25);
}
/** How long the clip plays. A render is IMMUTABLE: it keeps the length it was made at, so changing the project
* can never cut off its tail -- unless the scene has an explicit length of its own (trim/slip/split). */
export function seconds(sc, p, renders = p.scene_renders || {}) {
const made = renders[sc.id] && renders[sc.id].durationSec;
if (made != null && !isVideoClip(sc) && sc.source_dur == null && (sc.frames_mode || "project") === "project") return made;
return planSeconds(sc, p);
}
/** The cut: scenes in timeline order. Until a person pins an order by hand it simply follows the plan. */
export function orderedScenes(p) {
const scenes = p.scenes || [];
if (!p.timeline_manually_ordered) return scenes;
const byId = new Map(scenes.map((s) => [s.id, s]));
const seen = new Set();
const out = [];
for (const id of p.timeline_order || []) { const s = byId.get(id); if (s && !seen.has(id)) { out.push(s); seen.add(id); } }
for (const s of scenes) if (!seen.has(s.id)) out.push(s);
return out;
}
const ghostSeconds = (g, p) => g.durationSec != null ? g.durationSec
: ((g.frames_mode !== "project" && g.frames != null ? g.frames : p.num_frames_per_scene) || 1)
/ ((g.fps_mode !== "project" && g.fps != null ? g.fps : p.frame_rate) || 25);
/** What the timeline plays, in order, with where each part starts: scenes, the ghosts of removed scenes that
* still have a clip, and the pauses between. -> [{ kind, id, scene?, ghost?, start, dur }] */
export function segments(p) {
const ghosts = p.scene_ghosts || [];
const after = new Map();
for (const g of ghosts) {
const key = g.afterSceneId || "";
if (!after.has(key)) after.set(key, []);
after.get(key).push(g);
}
const placed = new Set();
const flat = [];
const addGhosts = (key) => (after.get(key) || []).forEach((g) => {
if (!placed.has(g.id)) { placed.add(g.id); flat.push({ kind: "ghost", id: `ghost:${g.id}`, ghost: g, dur: ghostSeconds(g, p) }); }
});
addGhosts("");
for (const sc of orderedScenes(p)) {
flat.push({ kind: "scene", id: sc.id, scene: sc, dur: seconds(sc, p) });
addGhosts(sc.id);
const gap = Math.max(0, +sc.gap_after_sec || 0);
if (gap > 0.001) flat.push({ kind: "gap", id: `gap:${sc.id}`, dur: gap });
}
for (const g of ghosts) addGhosts(g.afterSceneId || ""); // orphans (stale anchor) still show, after the rest
for (const g of ghosts) if (!placed.has(g.id)) { placed.add(g.id); flat.push({ kind: "ghost", id: `ghost:${g.id}`, ghost: g, dur: ghostSeconds(g, p) }); }
let at = 0;
return flat.map((seg) => { const out = { ...seg, start: at }; at += seg.dur; return out; });
}
export const totalSeconds = (p) => { const s = segments(p); return s.length ? s[s.length - 1].start + s[s.length - 1].dur : 0; };
/** Seconds to `HH:MM:SS` (or `MM:SS` under an hour). */
export function clock(sec) {
const whole = Math.max(0, Math.floor(sec));
const h = Math.floor(whole / 3600), m = Math.floor(whole / 60) % 60, s = whole % 60;
const two = (n) => String(n).padStart(2, "0");
return h ? `${two(h)}:${two(m)}:${two(s)}` : `${two(m)}:${two(s)}`;
}