diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 11738898..2bf2a107 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -537,6 +537,9 @@ class Scene: scene_archive: Optional[dict] = None # Editorial pause (black/silent) after this clip before the next timeline segment. gap_after_sec: float = 0.0 + # Removed from the PLAN but its generated clip stays on the TIMELINE. Also `excluded`, + # so generation/prompt skip it everywhere; export keeps it because it has a render. + removed_from_plan: bool = False @staticmethod def from_dict(d: dict) -> "Scene": @@ -566,6 +569,7 @@ class Scene: source_dur=d.get("source_dur"), scene_archive=d.get("scene_archive"), gap_after_sec=float(d.get("gap_after_sec") or 0), + removed_from_plan=bool(d.get("removed_from_plan", False)), ) def to_dict(self) -> dict: diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 3fc86544..784c45fa 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -265,6 +265,15 @@ : "This scene has editorial cuts — Generate regens the whole uncut scene.")); } + if (root.removed_from_plan) { + const banner = el("div", "insp-hint"); + banner.append(el("span", null, "Removed from plan — its generated clip stays on the timeline. ")); + const restore = el("button", "btn ghost tiny", "Restore to plan"); + restore.onclick = () => S.restoreToPlan(scene.id); + banner.append(restore); + body.append(banner); + } + const ta = el("textarea"); ta.rows = 4; ta.value = root.text || ""; ta.placeholder = "Describe this scene…"; ta.dataset.k = "sc-text"; ta.oninput = () => S.patchSceneQuiet(scene.id, { text: ta.value }); body.append(field("Prompt", ta)); diff --git a/movie_editor/frontend/plan_strip.js b/movie_editor/frontend/plan_strip.js index 5c35f0e4..86be7c29 100644 --- a/movie_editor/frontend/plan_strip.js +++ b/movie_editor/frontend/plan_strip.js @@ -147,9 +147,9 @@ 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); } }; + const rm = el("button", "btn ghost tiny danger", "Remove from plan"); + rm.title = "Remove from the plan. A generated clip stays on the timeline; an ungenerated scene is deleted."; + rm.onclick = () => { expandedId = null; S.removeFromPlan(sc.id); }; actions.append(gen, mvL, mvR, adv, rm); ed.append(actions); return ed; @@ -187,7 +187,7 @@ if (collapseBtn) collapseBtn.textContent = collapsed ? "Show" : "Hide"; clear(body); if (collapsed || !st.project) return; - const scenes = st.project.scenes || []; + const scenes = (st.project.scenes || []).filter((s) => !s.removed_from_plan); const rowEl = el("div", "plan-row"); scenes.forEach((sc, i) => rowEl.append(cardEl(st, sc, i))); const add = el("button", "plan-add", "+ Add scene"); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 598725d3..18a1ffda 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -1329,6 +1329,35 @@ notify(); scheduleSave(); } + // Remove a scene from the PLAN. If its gen-unit has a generated clip, KEEP that clip on the + // timeline: mark the unit `excluded` (so generation + prompt skip it everywhere) and + // `removed_from_plan` (hide from the plan strip; export keeps it because it has a render). + // If nothing was generated, it's a plain delete. + function removeFromPlan(id) { + if (!state.project) return; + const s = scene(id); if (!s) return; + const uid = genUnitId(s); + const unit = (state.project.scenes || []).filter((x) => genUnitId(x) === uid); + const rendered = isVideoClip(s) || unit.some((x) => state.sceneRenders[x.id] && state.sceneRenders[x.id].media); + if (!rendered) { removeScene(id); return; } + _historyRecord(); + unit.forEach((x) => { x.excluded = true; x.removed_from_plan = true; }); + syncGlobalPromptFromTimeline(); + notify(); scheduleSave(); + } + + // Bring a removed-from-plan scene back into the plan (re-enable generation). + function restoreToPlan(id) { + if (!state.project) return; + const s = scene(id); if (!s) return; + const uid = genUnitId(s); + _historyRecord(); + (state.project.scenes || []).filter((x) => genUnitId(x) === uid) + .forEach((x) => { x.excluded = false; x.removed_from_plan = false; }); + syncGlobalPromptFromTimeline(); + notify(); scheduleSave(); + } + function removeSelectedScenes() { if (!state.project) return; let ids = [...new Set(state.selectedSceneIds || [])].filter((sid) => scene(sid)); @@ -3122,7 +3151,7 @@ const p = state.project; const out = []; for (const sc of orderedTimelineScenes()) { - if (sc.excluded) continue; + if (sc.excluded && !sc.removed_from_plan) continue; // removed-from-plan clips stay in the cut const r = state.sceneRenders[sc.id]; const fps = (sc.fps_mode !== "project" && sc.fps != null ? sc.fps : p.frame_rate) || 25; const tFrames = sc.transition_frames || 0; @@ -3719,7 +3748,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, moveTimelineClip, scene, + patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, removeSelectedScenes, removeFromPlan, restoreToPlan, dismissGhost, moveScene, moveSceneTo, moveTimelineClip, scene, addVideoClip, addImageClip, convertToVideo, convertToScene, isVideoClip, isGenerativeScene, sceneCharacterIds, toggleSceneCharacter, genUnitId, isGenSubclip, genUnitRoot, genUnitSceneIds, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 39bece6a..72a80dd1 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -1452,4 +1452,5 @@ body.tour-mode { overflow: hidden; } .plan-edit-prompt { width: 100%; box-sizing: border-box; } .plan-editor-note { font-size: 11px; color: var(--faint); margin: 8px 0; } .plan-editor-actions { display: flex; gap: 6px; flex-wrap: wrap; } +.clip.off-plan { outline: 1px dashed color-mix(in srgb, var(--teal) 45%, transparent); outline-offset: -2px; } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 446b468d..4ec9e959 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -537,7 +537,8 @@ const srcType = src.type || "empty"; const clip = el("div", "clip" + clipSelClass(st, scene.id) + (S.isVideoClip(scene) ? " clip-video" : "") - + (scene.excluded ? " excluded" : "") + + (scene.excluded && !scene.removed_from_plan ? " excluded" : "") + + (scene.removed_from_plan ? " off-plan" : "") + (hasRender(st, scene.id) ? " rendered" : (!scene.excluded && S.isGenerativeScene(scene) ? " pending" : "")) + (hasRender(st, scene.id) && S.renderIsStale?.(scene.id) ? " stale-render" : "") + (unitCuts > 1 ? " gen-cut" : "") + (subclip ? " subclip" : "")