diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 9471df53..e9859a49 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -2178,10 +2178,23 @@ return out; } + function _selectedClipFocusId() { + const ids = state.selectedSceneIds?.length + ? state.selectedSceneIds + : (state.selectedSceneId ? [state.selectedSceneId] : []); + if (!ids.length) return null; + if (state.selectedSceneId && ids.includes(state.selectedSceneId)) return state.selectedSceneId; + return ids[0]; + } + + function _hasTimelineClipSelection() { + return !!_selectedClipFocusId(); + } + // Build export/import clip spec for the selected scene (render trim or media-bin video). function _selectedClipExportSpec() { if (!state.project) return null; - const id = state.selectedSceneId; + const id = _selectedClipFocusId(); if (!id) return null; const sc = scene(id); if (!sc) return null; @@ -2222,10 +2235,12 @@ // Export the render covering the selected clip via a Save dialog. async function exportSelected() { if (!state.project) return; + if (!_hasTimelineClipSelection()) { alert("Select a clip first."); return; } const spec = _selectedClipExportSpec(); - if (!spec) { alert("Select a clip with a render to export."); return; } - const sc = scene(state.selectedSceneId); - if (isVideoClip(sc) && sc.source?.media_ref && !state.sceneRenders[state.selectedSceneId]?.media) { + if (!spec) { alert("Generate the clip first, or select a video clip with media."); return; } + const focusId = _selectedClipFocusId(); + const sc = scene(focusId); + if (isVideoClip(sc) && sc.source?.media_ref && !state.sceneRenders[focusId]?.media) { const asset = (state.mediaBin || []).find((m) => m.id === sc.source.media_ref); const name = asset?.name || spec.name; await _saveBlobAs(API.mediaUrl(sc.source.media_ref), name.replace(/[^\w.-]+/g, "_")); @@ -2241,9 +2256,10 @@ async function saveSelectedToMediaBin() { if (!state.project) return; + if (!_hasTimelineClipSelection()) { alert("Select a clip first."); return; } const spec = _selectedClipExportSpec(); if (!spec) { - alert("Nothing to save — generate the clip first, or select a video clip."); + alert("Generate the clip first, or select a video clip with media."); return; } try { diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index fa3b6e70..0e312250 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -521,6 +521,8 @@ input[type=number] { font-family: var(--font-mono); } .timeline .zone-body { padding: 0; display: flex; flex-direction: column; } .tl-toolbar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line); flex: 0 0 auto; } +.tl-toolbar .btn:disabled { opacity: 0.42; pointer-events: none; cursor: default; } +.tl-toolbar .btn.ghost:disabled:hover { color: var(--muted); background: transparent; border-color: transparent; } .tl-toolbar .tl-spacer { flex: 1; } .tl-tc { font-family: var(--font-mono); font-size: 11px; color: var(--muted); } .tl-zlabel { font-family: var(--font-mono); font-size: 10px; letter-spacing: 1px; text-transform: uppercase; color: var(--faint); } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 672d159c..f7c2b77e 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -179,6 +179,18 @@ function isSceneFocus(st, sceneId) { return normId(sceneId) === normId(st.selectedSceneId); } + /** Selection state shared by toolbar build + live sync. */ + function clipToolbarState(st) { + const ids = selectedIds(st); + const hasSel = ids.length > 0; + const focusId = hasSel + ? (st.selectedSceneId && ids.some((sid) => normId(sid) === normId(st.selectedSceneId)) + ? st.selectedSceneId + : ids[0]) + : null; + const saveable = !!(focusId && S.clipSaveableToMediaBin?.(focusId)); + return { ids, hasSel, focusId, saveable }; + } function clipSelClass(st, sceneId) { let cls = ""; if (isSceneSelected(st, sceneId)) cls += " selected"; @@ -272,20 +284,34 @@ function syncToolbarSelection(st) { const bar = body.querySelector(".tl-toolbar"); if (!bar || !st.project) return; - const hasSel = !!st.selectedSceneId; + const { hasSel, focusId, saveable } = clipToolbarState(st); bar.querySelectorAll("[data-needs-sel]").forEach((b) => { b.disabled = !hasSel; }); const exp = bar.querySelector("[data-export-scene]"); - if (exp) exp.disabled = !(hasSel && S.clipSaveableToMediaBin?.(st.selectedSceneId)); + if (exp) { + exp.disabled = !(hasSel && saveable); + exp.title = !hasSel + ? "Select a clip first" + : saveable + ? "Save the selected clip's rendered video to disk (renders are temporary)" + : "Generate the clip first, or select a video clip with media"; + } const saveBin = bar.querySelector("[data-save-mediabin]"); - if (saveBin) saveBin.disabled = !(hasSel && S.clipSaveableToMediaBin?.(st.selectedSceneId)); + if (saveBin) { + saveBin.disabled = !(hasSel && saveable); + saveBin.title = !hasSel + ? "Select a clip first" + : saveable + ? "Copy the selected clip into the Media bin — add it back as a plain video clip" + : "Generate the clip first, or select a video clip with media"; + } const sep = bar.querySelector("[data-separate-audio]"); if (sep) { - const sc = hasSel ? S.scene(st.selectedSceneId) : null; - sep.disabled = !(sc && S.isGenerativeScene(sc) && hasRender(st, st.selectedSceneId) && !sc?.audio_separated); + const sc = focusId ? S.scene(focusId) : null; + sep.disabled = !(sc && S.isGenerativeScene(sc) && hasRender(st, focusId) && !sc?.audio_separated); } const rmSep = bar.querySelector("[data-remove-sep-audio]"); if (rmSep) { - const sc = hasSel ? S.scene(st.selectedSceneId) : null; + const sc = focusId ? S.scene(focusId) : null; const sepTrack = sc && S.separatedTrackForScene ? S.separatedTrackForScene(sc.id) : null; rmSep.disabled = !sepTrack; } @@ -1104,33 +1130,42 @@ bar.append(addMenuDropdown(st, p)); // Clip actions on the selected clip (also bound to S / Delete). - const selIds = selectedIds(st); - const hasSel = selIds.length > 0; + const { hasSel, focusId, saveable } = clipToolbarState(st); const split = el("button", "btn ghost tiny", "Split"); split.dataset.needsSel = "1"; - split.title = "Split the selected clip at the playhead (S)"; split.disabled = !hasSel; + split.title = hasSel ? "Split the selected clip at the playhead (S)" : "Select a clip first"; + split.disabled = !hasSel; split.onclick = () => splitSelectedAtPlayhead(); const del = el("button", "btn ghost tiny danger", "Remove"); del.dataset.needsSel = "1"; - del.title = "Remove selected clip(s) (Delete / Backspace)"; del.disabled = !hasSel; + del.title = hasSel ? "Remove selected clip(s) (Delete / Backspace)" : "Select a clip first"; + del.disabled = !hasSel; del.onclick = () => S.removeSelectedScenes(); const exp = el("button", "btn ghost tiny", "⤓ Export"); exp.dataset.exportScene = "1"; - exp.title = "Save the selected clip's rendered video to disk (renders are temporary)"; - exp.disabled = !(hasSel && S.clipSaveableToMediaBin?.(st.selectedSceneId)); + exp.title = !hasSel + ? "Select a clip first" + : saveable + ? "Save the selected clip's rendered video to disk (renders are temporary)" + : "Generate the clip first, or select a video clip with media"; + exp.disabled = !(hasSel && saveable); exp.onclick = () => S.exportSelected(); const saveBin = el("button", "btn ghost tiny", "Save to media bin"); saveBin.dataset.saveMediabin = "1"; - saveBin.title = "Copy the selected clip into the Media bin — add it back as a plain video clip"; - saveBin.disabled = !(hasSel && S.clipSaveableToMediaBin?.(st.selectedSceneId)); + saveBin.title = !hasSel + ? "Select a clip first" + : saveable + ? "Copy the selected clip into the Media bin — add it back as a plain video clip" + : "Generate the clip first, or select a video clip with media"; + saveBin.disabled = !(hasSel && saveable); saveBin.onclick = () => S.saveSelectedToMediaBin(); const sepAud = el("button", "btn ghost tiny", "⊟ Separate audio"); sepAud.dataset.needsSel = "1"; sepAud.dataset.separateAudio = "1"; sepAud.title = "Detach this clip's audio onto its own track (video keeps picture only)"; - const selSc = hasSel ? S.scene(st.selectedSceneId) : null; - sepAud.disabled = !(selSc && hasRender(st, st.selectedSceneId) && !selSc?.audio_separated && S.isGenerativeScene(selSc)); - sepAud.onclick = () => S.separateSceneAudio(st.selectedSceneId); + const selSc = focusId ? S.scene(focusId) : null; + sepAud.disabled = !(selSc && hasRender(st, focusId) && !selSc?.audio_separated && S.isGenerativeScene(selSc)); + sepAud.onclick = () => { if (focusId) S.separateSceneAudio(focusId); }; const sepTrack = selSc && S.separatedTrackForScene ? S.separatedTrackForScene(selSc.id) : null; const rmSepAud = el("button", "btn ghost tiny danger", "Remove audio"); rmSepAud.dataset.removeSepAudio = "1";