Files
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

246 lines
12 KiB
JavaScript

// Changes to a project's scenes, as plain functions on the open project: `project.edit((p) => addScene(p))`.
// Each mutates in place and answers truthy when it changed something. They know what a scene IS (gen units,
// ghosts, the cut order) and nothing about the screen.
import { trackFor } from "./audio.js";
import { segments } from "./scenes.js";
import {
genUnitId, isVideoClip, isSubclip, effFps, effFrames, seconds, snapFrames, LTX_GRID,
} from "./scenes.js";
const clone = (x) => JSON.parse(JSON.stringify(x));
const newId = () => "c" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
const index = (p, id) => (p.scenes || []).findIndex((s) => s.id === id);
/** A fresh, empty scene on the end of the plan. */
export function addScene(p, sourceType = "carry") {
const sc = { id: newId(), text: "", transition_to_next: "", source: { type: sourceType }, excluded: false, frames_mode: "project", fps_mode: "project" };
if (p.fixed_source_image) sc.source_image = p.fixed_source_image; // a pinned aspect ratio source goes to every scene, new ones too
p.scenes.push(sc);
return sc;
}
/** Every picture in any scene's own reference list. The pinned ones (project.fixed_references) are a separate setting and are not counted. */
export function allReferences(p) {
return new Set((p.scenes || []).flatMap((s) => s.references || []));
}
/** Empty every scene's own reference list; the pins stay. -> how many pictures were in use (0: nothing changed). */
export function clearReferences(p) {
const n = allReferences(p).size;
for (const s of p.scenes || []) if ((s.references || []).length) s.references = [];
return n;
}
// A scene that had a render leaves a ghost clip behind; one that never rendered just goes.
function ghostOrDrop(p, sc, afterId, pending) {
const renders = p.scene_renders || (p.scene_renders = {});
const r = renders[sc.id];
delete renders[sc.id];
if (sc.reused) return; // a reused copy is a copy: nothing to regenerate, so nothing to leave behind
p.scene_ghosts = (p.scene_ghosts || []).filter((g) => g.id !== sc.id);
if (!(r && r.media) && !pending) return;
p.scene_ghosts.push({
id: sc.id, afterSceneId: afterId || null, gen_unit_id: genUnitId(sc), text: sc.text || "",
frames: sc.frames, frames_mode: sc.frames_mode, fps: sc.fps, fps_mode: sc.fps_mode,
effects: clone(sc.effects || {}), audio_volume: sc.audio_volume,
media: (r && r.media) || null, inSec: (r && r.inSec) || 0, pendingGen: Boolean(pending && !(r && r.media)),
});
}
/** Remove one scene. A root that owned a cut unit hands its prompt, source and offset to the next cut. */
export function removeScene(p, id, { pending = false } = {}) {
const at = index(p, id);
if (at < 0) return false;
const sc = p.scenes[at];
const unit = genUnitId(sc);
const mates = p.scenes.filter((s) => genUnitId(s) === unit);
if (!isSubclip(sc) && mates.length > 1) {
const next = mates.find((s) => s.id !== id);
next.cut_offset_frames = 0;
next.text = sc.text || next.text;
if (sc.rating) { next.rating = sc.rating; next.rated = sc.rated || {}; }
next.source = clone(sc.source || next.source || {});
if (p.scene_variants && p.scene_variants[id]) { p.scene_variants[next.id] = p.scene_variants[id]; delete p.scene_variants[id]; } // the takes follow the unit's new root
const removedFrames = sc.frames || 0;
mates.filter((s) => s.id !== id && (s.cut_offset_frames || 0) > (sc.cut_offset_frames || 0))
.forEach((s) => { s.cut_offset_frames = Math.max(0, s.cut_offset_frames - removedFrames); });
}
p.audio_tracks = (p.audio_tracks || []).filter((t) => !(t.kind === "separated" && t.scene_id === id)); // its sound goes with it: a lane with no clip could not be reached
const anchor = at > 0 ? p.scenes[at - 1].id : null;
p.scene_ghosts = (p.scene_ghosts || []).map((g) => (g.afterSceneId === id ? { ...g, afterSceneId: anchor } : g));
ghostOrDrop(p, sc, anchor, pending);
p.scenes = p.scenes.filter((s) => s.id !== id);
if (p.timeline_order) p.timeline_order = p.timeline_order.filter((x) => x !== id);
return true;
}
/** Out of the plan but still on the timeline: a unit that has a render is kept, excluded from generation. */
export function removeFromPlan(p, id) {
const sc = p.scenes.find((s) => s.id === id);
if (!sc) return false;
const unit = p.scenes.filter((s) => genUnitId(s) === genUnitId(sc));
const rendered = isVideoClip(sc) || unit.some((s) => p.scene_renders && p.scene_renders[s.id] && p.scene_renders[s.id].media);
if (!rendered) return removeScene(p, id);
unit.forEach((s) => { s.excluded = true; s.removed_from_plan = true; });
return true;
}
export function restoreToPlan(p, id) {
const sc = p.scenes.find((s) => s.id === id);
if (!sc) return false;
p.scenes.filter((s) => genUnitId(s) === genUnitId(sc)).forEach((s) => { s.excluded = false; s.removed_from_plan = false; });
return true;
}
/** Move a clip within the CUT (never the plan) to `to`, an index among the other clips. */
export function reorder(p, id, to, order) {
const ids = order.slice();
const from = ids.indexOf(id);
if (from < 0) return false;
ids.splice(from, 1);
const at = Math.max(0, Math.min(ids.length, to));
ids.splice(at, 0, id);
if (ids.every((x, i) => x === order[i])) return false;
p.timeline_order = ids;
p.timeline_manually_ordered = true; // from now on an edit to the plan must keep this order
return true;
}
/** Start a clip at `startSec` on the timeline: the pause before it (kept on the clip's predecessor) grows or shrinks, and what follows
* moves along. A clip cannot start before the one in front of it ends, and the first clip stays at zero. */
export function nudge(p, id, startSec) {
const segs = segments(p), at = segs.findIndex((s) => s.id === id && s.kind === "scene");
if (at < 0) return false;
let i = at - 1;
while (i >= 0 && segs[i].kind === "gap") i--; // the pause already in front of it
const held = i >= 0 ? segs.slice(0, i + 1).reverse().find((s) => s.kind === "scene") : null; // the pause lives on the last scene before it (ghosts sit between)
if (!held) return false;
const before = segs[at - 1] && segs[at - 1].kind === "gap" ? segs[at - 1].dur : 0;
const ends = segs[at].start - before;
const gap = Math.round(Math.max(0, startSec - ends) * 100) / 100;
const sc = p.scenes.find((x) => x.id === held.id);
if (!sc || (Number(sc.gap_after_sec) || 0) === gap) return false;
sc.gap_after_sec = gap;
return true;
}
const modeToTimeline = (sc) => { if ((sc.frames_mode || "project") === "project") sc.frames_mode = "timeline"; };
/** Run for `sec` (from dragging an edge): whole grid steps, at least one step in the direction of the drag. */
export function resize(p, id, sec, grid = LTX_GRID) {
const sc = p.scenes.find((s) => s.id === id);
if (!sc || sc.frames_mode === "custom") return false;
const fps = effFps(sc, p);
const now = seconds(sc, p);
const target = Math.max(0.1, sec);
if (Math.abs(target - now) <= 0.02) return false;
const cur = Math.max(1, Math.round(now * fps));
if (isVideoClip(sc)) {
sc.source_dur = target;
sc.frames = snapFrames(target * fps, "round", grid);
} else {
const raw = target * fps;
let frames = raw < cur - 0.01 ? snapFrames(raw, "floor", grid) : raw > cur + 0.01 ? snapFrames(raw, "ceil", grid) : cur;
if (frames === cur) frames = raw < cur ? snapFrames(cur - grid.step, "floor", grid) : cur + grid.step;
if (frames === cur) return false;
sc.frames = frames;
}
modeToTimeline(sc);
return true;
}
/** A separated clip's sound follows its picture's in-point (`shorten`: the head was cut off, so the sound loses its head too). */
function followLane(p, id, d, shorten) {
const lane = trackFor(p, id);
if (!lane) return;
lane.pinned_in_sec = (lane.pinned_in_sec || 0) + d; lane.source_in_sec = lane.pinned_in_sec;
if (shorten && lane.full_dur != null) lane.full_dur = Math.max(0.1, lane.full_dur - d);
if (shorten && lane.pinned_dur != null) { lane.pinned_dur = Math.max(0.1, lane.pinned_dur - d); lane.source_dur = lane.pinned_dur; }
}
/** Cut `sec` off the start: the clip begins later in its source and runs shorter. */
export function trimLeft(p, id, sec, grid = LTX_GRID) {
const sc = p.scenes.find((s) => s.id === id);
if (!sc || sc.frames_mode === "custom") return false;
const trim = Math.max(0.05, sec);
if (isVideoClip(sc)) {
const dur = sc.source_dur != null ? sc.source_dur : seconds(sc, p);
if (trim >= dur - 0.05) return false;
sc.source_in = (sc.source_in || 0) + trim;
followLane(p, id, trim, true);
sc.source_dur = Math.max(0.1, dur - trim);
modeToTimeline(sc);
return true;
}
const fps = effFps(sc, p);
const cur = Math.max(1, Math.round(seconds(sc, p) * fps));
const raw = cur - Math.max(1, Math.round(trim * fps));
if (raw < grid.step + grid.base) return false;
let next = snapFrames(raw, "floor", grid);
if (next >= cur) next = Math.max(grid.step + grid.base, cur - grid.step);
if (next >= cur) return false;
sc.source_in = (sc.source_in || 0) + (cur - next) / fps;
followLane(p, id, (cur - next) / fps, true);
sc.frames = next;
modeToTimeline(sc);
return true;
}
/** Slide what plays within the clip without changing its length. */
export function slip(p, id, deltaSec) {
const sc = p.scenes.find((s) => s.id === id);
if (!sc || !deltaSec) return false;
const before = sc.source_in || 0;
sc.source_in = Math.max(0, before + deltaSec);
followLane(p, id, sc.source_in - before, false);
return true;
}
/** Cut a clip in two at `atFrames` (default the middle). Both halves stay one generated unit. */
export function split(p, id, atFrames, grid = LTX_GRID) {
const at = index(p, id);
if (at < 0) return false;
const sc = p.scenes[at];
const fps = effFps(sc, p);
let frames = effFrames(sc, p);
if (isVideoClip(sc) && sc.source_dur != null) frames = snapFrames(sc.source_dur * fps, "round", grid);
const cut = snapFrames(atFrames != null ? atFrames : frames / 2, "round", grid);
if (cut <= grid.step + grid.base || cut >= frames) return false;
const cutSec = cut / fps;
const unit = genUnitId(sc);
const second = clone(sc);
second.id = newId();
second.gen_unit_id = unit;
second.cut_offset_frames = (sc.cut_offset_frames || 0) + cut;
second.frames = snapFrames(frames - cut, "round", grid);
second.text = "";
second.rating = ""; second.rated = {};
if (sc.audio_separated) { const lane = trackFor(p, sc.id); second.audio_separated = false; second.audio_volume = lane && lane.volume != null ? lane.volume : 1; } // the lane stays with the first half; the second plays its own sound
sc.gen_unit_id = unit;
// The seam between the halves is an internal hard cut: the outgoing edge now belongs to the second half.
sc.frames = cut;
sc.transition_to_next = ""; sc.transition_frames = null; sc.video_transition = "";
modeToTimeline(sc); modeToTimeline(second);
const playsFromSource = isVideoClip(sc) || (sc.source && sc.source.type === "v2v" && sc.source.media_ref);
if (playsFromSource) {
const srcIn = sc.source_in || 0;
const total = sc.source_dur != null ? sc.source_dur : frames / fps;
sc.source_dur = cutSec;
second.source_in = srcIn + cutSec;
second.source_dur = Math.max(0.1, Math.min(second.frames / fps, total - cutSec));
}
p.scenes.splice(at + 1, 0, second);
if (p.timeline_manually_ordered && Array.isArray(p.timeline_order)) {
const oi = p.timeline_order.indexOf(sc.id);
if (oi >= 0) p.timeline_order.splice(oi + 1, 0, second.id);
}
// The render survives the cut: the second half plays the same video from where the first ends.
const renders = p.scene_renders || {};
const r = renders[id];
if (r && r.media) {
delete r.durationSec; // a split is an explicit re-length: both halves go back to plan layout
renders[second.id] = { media: r.media, inSec: (r.inSec || 0) + cutSec, renderPrompt: r.renderPrompt ? { ...r.renderPrompt } : null, ...(r.promptId ? { promptId: r.promptId } : {}) };
}
return second;
}