Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
107 lines
5.5 KiB
JavaScript
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 montage piece 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)}`;
|
|
}
|