Movie Editor: timeline cut order independent of plan order

The timeline is the result; its order is now project.timeline_order (scene ids),
which plan reorder never touches. buildPreviewSegments + _renderClips (export) read
this cut order via orderedTimelineScenes(); it's self-healing (drop gone ids, append
new scenes in plan order) and maintained on load + structure change, with a backend
field so it persists.

- Menubar 'Move Clip Left/Right' reorders the CUT (moveTimelineClip → timeline_order).
- Plan-card '← Plan / Plan →' reorders the PLAN (moveScene → scenes / generation order)
  without moving the timeline clip.
- view_bus fingerprints include timeline_order so a cut reorder re-renders.

Reordering the plan no longer moves or alters already-generated videos on the
timeline. Backend tests green (62). Empty timeline_order falls back to plan order
(back-compat). Still TODO: 'remove from plan keeps the clip on the timeline'
(ghost retirement) — entangled with generation/export skip semantics, do next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-19 10:16:28 +03:00
co-authored by Claude Opus 4.8
parent 93c8068b90
commit 07eaec5690
5 changed files with 59 additions and 7 deletions
+4
View File
@@ -644,6 +644,9 @@ class Project:
# Persisted editor session: generated clip refs + preview ghosts (survive reload).
scene_renders: dict = field(default_factory=dict)
scene_ghosts: list = field(default_factory=list)
# Cut order: scene ids in TIMELINE (result) order, independent of plan (scenes) order.
# Empty = follow plan order (back-compat). Reordering the plan never touches this.
timeline_order: list = field(default_factory=list)
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
@@ -679,6 +682,7 @@ class Project:
generation_meta=dict(d.get("generation_meta") or {}),
scene_renders=dict(d.get("scene_renders") or {}),
scene_ghosts=list(d.get("scene_ghosts") or []),
timeline_order=list(d.get("timeline_order") or []),
created_at=float(d.get("created_at", time.time())),
updated_at=float(d.get("updated_at", time.time())),
)
+2 -2
View File
@@ -218,8 +218,8 @@
{ label: "Add Scene", hint: "+", disabled: !hasProject(), action: () => S.addScene() },
{ label: "Delete Scene", disabled: !(st.selectedSceneIds?.length || st.selectedSceneId), action: () => S.removeSelectedScenes() },
{ sep: true },
{ label: "Move Scene Left", disabled: !sel(), action: () => S.moveScene(sel(), -1) },
{ label: "Move Scene Right", disabled: !sel(), action: () => S.moveScene(sel(), 1) },
{ label: "Move Clip Left", hint: "timeline cut", disabled: !sel(), action: () => S.moveTimelineClip(sel(), -1) },
{ label: "Move Clip Right", hint: "timeline cut", disabled: !sel(), action: () => S.moveTimelineClip(sel(), 1) },
{ sep: true },
{ label: "Toggle Exclude", disabled: !sel(), action: () => { const s = S.scene(sel()); if (s) S.patchScene(s.id, { excluded: !s.excluded }); } },
],
+7 -1
View File
@@ -138,13 +138,19 @@
const actions = el("div", "plan-editor-actions");
const gen = el("button", "btn primary tiny", "Generate this scene");
gen.onclick = () => S.generate(sc.id);
const mvL = el("button", "btn ghost tiny", "← Plan");
mvL.title = "Move earlier in the plan (generation order). Does NOT move the timeline clip.";
mvL.onclick = () => S.moveScene(sc.id, -1);
const mvR = el("button", "btn ghost tiny", "Plan →");
mvR.title = "Move later in the plan (generation order). Does NOT move the timeline clip.";
mvR.onclick = () => S.moveScene(sc.id, 1);
const adv = el("button", "btn ghost tiny", "Advanced ↗");
adv.title = "Open full scene settings (guides, effects, transitions) in the inspector";
adv.onclick = () => { S.selectScene(sc.id); if (window.DockLayout) window.DockLayout.set({ settings: true }); };
const rm = el("button", "btn ghost tiny danger", "Remove");
rm.title = "Remove this scene from the plan";
rm.onclick = () => { if (confirm("Remove this scene from the plan?")) { expandedId = null; S.removeScene(sc.id); } };
actions.append(gen, adv, rm);
actions.append(gen, mvL, mvR, adv, rm);
ed.append(actions);
return ed;
}
+44 -4
View File
@@ -450,6 +450,7 @@
function _afterTimelineStructureChange() {
_pinnedGlobalPrompt = null;
_ensureTimelineOrder();
_syncSeparatedAudioTracks();
syncGlobalPromptFromTimeline();
}
@@ -642,6 +643,7 @@
state.gen = { state: "idle", promptId: null, media: [], msg: "" };
state.sceneRenders = JSON.parse(JSON.stringify(state.project.scene_renders || {}));
state.sceneGhosts = JSON.parse(JSON.stringify(state.project.scene_ghosts || []));
_ensureTimelineOrder();
normalizeOverlayLanes(state.project);
state.models = JSON.parse(JSON.stringify(state.project.models || { slots: [] }));
if (_clearOrphanRatings()) {
@@ -1363,7 +1365,45 @@
return _ghostDurationSec(seg.ghost);
}
// Preview/timeline layout: live scenes interleaved with removed-scene ghosts.
// ── timeline (cut) order — independent of plan (scenes) order ─────────────────
// The timeline is the RESULT; its order is project.timeline_order (scene ids), which the
// plan can never move. Self-healing: keep existing order, drop ids whose scene is gone,
// append new scenes in plan order. Empty falls back to plan order (back-compat / fresh).
function _ensureTimelineOrder() {
const p = state.project; if (!p) return;
const ids = (p.scenes || []).map((s) => s.id);
const idSet = new Set(ids);
const cur = (Array.isArray(p.timeline_order) ? p.timeline_order : []).filter((id) => idSet.has(id));
const inCur = new Set(cur);
for (const id of ids) if (!inCur.has(id)) cur.push(id);
p.timeline_order = cur;
}
function orderedTimelineScenes() {
const p = state.project; if (!p) return [];
const byId = new Map((p.scenes || []).map((s) => [s.id, s]));
const order = (Array.isArray(p.timeline_order) && p.timeline_order.length)
? p.timeline_order : (p.scenes || []).map((s) => s.id);
const out = []; const seen = new Set();
for (const id of order) { const s = byId.get(id); if (s && !seen.has(id)) { out.push(s); seen.add(id); } }
for (const s of (p.scenes || [])) if (!seen.has(s.id)) { out.push(s); seen.add(s.id); }
return out;
}
// Reorder the CUT (timeline_order) only — never the plan. Selected clip ± 1 slot.
function moveTimelineClip(id, delta) {
const p = state.project; if (!p) return;
_ensureTimelineOrder();
const order = p.timeline_order;
const i = order.indexOf(id); if (i < 0) return;
const j = Math.max(0, Math.min(order.length - 1, i + delta));
if (i === j) return;
_historyRecord();
order.splice(i, 1); order.splice(j, 0, id);
notify(); scheduleSave();
}
// Preview/timeline layout: result clips (cut order) interleaved with removed-scene ghosts.
function buildPreviewSegments() {
const p = state.project;
if (!p) return [];
@@ -1382,7 +1422,7 @@
ordered.push({ kind: "ghost", ghost: g, id: `ghost:${g.id}` });
};
for (const g of (byAnchor.get("__start__") || [])) pushGhost(g);
for (const sc of (p.scenes || [])) {
for (const sc of orderedTimelineScenes()) {
ordered.push({ kind: "scene", scene: sc, id: sc.id });
for (const g of (byAnchor.get(sc.id) || [])) pushGhost(g);
const gapSec = Math.max(0, +(sc.gap_after_sec || 0));
@@ -3081,7 +3121,7 @@
function _renderClips() {
const p = state.project;
const out = [];
for (const sc of (p.scenes || [])) {
for (const sc of orderedTimelineScenes()) {
if (sc.excluded) continue;
const r = state.sceneRenders[sc.id];
const fps = (sc.fps_mode !== "project" && sc.fps != null ? sc.fps : p.frame_rate) || 25;
@@ -3679,7 +3719,7 @@
get, set, subscribe, notify, init,
scheduleSaveFromHistory, notifyHistoryState,
refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject,
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, removeSelectedScenes, dismissGhost, moveScene, moveSceneTo, scene,
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, removeSelectedScenes, dismissGhost, moveScene, moveSceneTo, moveTimelineClip, scene,
addVideoClip, addImageClip, convertToVideo, convertToScene, isVideoClip, isGenerativeScene,
sceneCharacterIds, toggleSceneCharacter,
genUnitId, isGenSubclip, genUnitRoot, genUnitSceneIds,
+2
View File
@@ -10,6 +10,7 @@
return hash({
pid: p.id,
timing: [p.num_frames_per_scene, p.frame_rate, p.width, p.height],
tlorder: p.timeline_order,
scenes: (p.scenes || []).map((s) => [
s.id, s.text, s.excluded, s.frames, s.fps, s.frames_mode, s.fps_mode,
s.video_transition, s.transition_frames, s.transition_to_next, s.effects,
@@ -87,6 +88,7 @@
return hash({
pid: p.id,
timing: [p.num_frames_per_scene, p.frame_rate],
tlorder: p.timeline_order,
scenes: (p.scenes || []).map((s) => [
s.id, s.excluded, s.frames, s.fps, s.frames_mode, s.fps_mode,
s.audio_volume, s.audio_separated, s.source_in, s.source_dur, s.effects,