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:
co-authored by
Claude Opus 4.8
parent
93c8068b90
commit
07eaec5690
@@ -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())),
|
||||
)
|
||||
|
||||
@@ -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 }); } },
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user