From 07eaec5690185b6731dcb231f95d3af5cf578d2b Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:16:28 +0300 Subject: [PATCH] Movie Editor: timeline cut order independent of plan order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- movie_editor/backend/timeline.py | 4 +++ movie_editor/frontend/menubar.js | 4 +-- movie_editor/frontend/plan_strip.js | 8 ++++- movie_editor/frontend/store.js | 48 ++++++++++++++++++++++++++--- movie_editor/frontend/view_bus.js | 2 ++ 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 868d3b3e..11738898 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -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())), ) diff --git a/movie_editor/frontend/menubar.js b/movie_editor/frontend/menubar.js index 598312d8..d626f88f 100644 --- a/movie_editor/frontend/menubar.js +++ b/movie_editor/frontend/menubar.js @@ -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 }); } }, ], diff --git a/movie_editor/frontend/plan_strip.js b/movie_editor/frontend/plan_strip.js index 178702d4..5c35f0e4 100644 --- a/movie_editor/frontend/plan_strip.js +++ b/movie_editor/frontend/plan_strip.js @@ -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; } diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index f07a22e6..598725d3 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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, diff --git a/movie_editor/frontend/view_bus.js b/movie_editor/frontend/view_bus.js index 86f3dc47..6e9bceca 100644 --- a/movie_editor/frontend/view_bus.js +++ b/movie_editor/frontend/view_bus.js @@ -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,