- 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>
246 lines
12 KiB
JavaScript
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;
|
|
}
|