From a4fcafe0144872f3f5789cba1d18e2d5121c0f66 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:10:07 +0200 Subject: [PATCH] Timeline: Rate scene, Upscale and Interpolate in the header after Takes; From bin, Effect and Overlay folded into one + Add menu --- app/ui/boot.js | 2 +- app/ui/features/clipfx/index.js | 15 ++++++--------- app/ui/features/imports/index.js | 7 +++---- app/ui/features/interpolate/index.js | 2 +- app/ui/features/overlays/index.js | 27 +++++++++++++-------------- app/ui/features/rating/index.js | 2 +- app/ui/features/timeline/index.js | 10 +++++++++- app/ui/features/upscale/index.js | 2 +- app/ui/modules.js | 2 +- 9 files changed, 36 insertions(+), 33 deletions(-) diff --git a/app/ui/boot.js b/app/ui/boot.js index 36422c5f..c23100bf 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -43,7 +43,7 @@ const link = linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if const frame = build(document.getElementById("app")); const selection = createSelection({ project }); selection.on(() => say("select")); -const app = { editorSettings: [], timelineLanes: [], timelineView: {}, monitor: {}, mediaMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], beforeRun: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, pipelineOwned: link.owns, pipelineWhy: link.why, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), dom: createDom(), peek: createPeek(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; +const app = { editorSettings: [], timelineLanes: [], timelineView: {}, monitor: {}, mediaMenu: [], addMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], beforeRun: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, pipelineOwned: link.owns, pipelineWhy: link.why, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), dom: createDom(), peek: createPeek(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; // Which backend modules the server has (loaded, even if this model cannot use them). Unknown (server down) = assume all, so nothing is hidden by a failed fetch. const installed = await Promise.race([api.modules(), new Promise((_, no) => setTimeout(() => no(new Error("slow")), 4000))]) // a stalled server must not leave the page blank .then((m) => (Array.isArray(m.modules) ? new Set([...m.modules, ...(m.incompatible || [])].map((x) => x.id)) : null)).catch(() => null); diff --git a/app/ui/features/clipfx/index.js b/app/ui/features/clipfx/index.js index 8598c1bd..9dfa3795 100644 --- a/app/ui/features/clipfx/index.js +++ b/app/ui/features/clipfx/index.js @@ -37,14 +37,11 @@ export default { } })] }); } - const menu = c.button.menu({ label: "+ Effect", tone: "ghost", onClick: () => c.menu.dropdown({ anchor: menu, items: [ - { id: "fx", label: "Effect…", disabled: !p.selected }, { id: "tr", label: "Transition…", disabled: !p.selected }, - { separator: true }, { id: "off", label: "Remove effects and transition", danger: true, disabled: !p.selected || !tags(p.selected).length }], - onPick: (id) => { - if (id === "fx") ask("effects"); else if (id === "tr") ask("video_transitions"); - else p.edit((pr) => { const s = pr.scenes.find((x) => x.id === p.selectedId); if (!s) return false; applyEffect(s, "reset"); return clearTransition(s); }); - } }) }); - host.append(menu.node); + const entry = { order: 20, items: () => [ + { id: "fx", label: "Effect…", disabled: !p.selected, run: () => ask("effects") }, { id: "tr", label: "Transition…", disabled: !p.selected, run: () => ask("video_transitions") }, + { id: "fx-off", label: "Remove effects and transition", danger: true, disabled: !p.selected || !tags(p.selected).length, + run: () => p.edit((pr) => { const s = pr.scenes.find((x) => x.id === p.selectedId); if (!s) return false; applyEffect(s, "reset"); return clearTransition(s); }) }] }; + app.addMenu.push(entry); // listed under the timeline's "+ Add" app.clipTags.push(tags); const section = { key: (sc) => `${sc.audio_volume}|${sc.audio_separated}`, rows: (sc) => [ @@ -54,7 +51,7 @@ export default { app.sceneSections.push(section); return () => { for (const [list, x] of [[app.clipTags, tags], [app.sceneSections, section]]) { const i = list.indexOf(x); if (i >= 0) list.splice(i, 1); } - menu.node.remove(); + const k = app.addMenu.indexOf(entry); if (k >= 0) app.addMenu.splice(k, 1); }; }, }; diff --git a/app/ui/features/imports/index.js b/app/ui/features/imports/index.js index cfe469c3..d3641d6b 100644 --- a/app/ui/features/imports/index.js +++ b/app/ui/features/imports/index.js @@ -33,9 +33,8 @@ export default { if (seconds == null) c.toast.warn({ text: asset.kind === "video" ? "The file's length could not be read; the clip uses the project's length." : "The file's length could not be read: the lane cannot be trimmed, and plays to the end of the file." }); } - const menu = c.button.menu({ label: "+ From bin", tone: "ghost", onClick: () => c.menu.dropdown({ anchor: menu, items: [{ id: "video", label: "Video clip…", disabled: !p.project }, { id: "audio", label: "Audio track…", disabled: !p.project }], - onPick: (id) => choose(id) }) }); - host.append(menu.node); + const entry = { order: 10, items: () => [{ id: "bin-video", label: "Video clip from bin…", disabled: !p.project, run: () => choose("video") }, { id: "bin-audio", label: "Audio track from bin…", disabled: !p.project, run: () => choose("audio") }] }; + app.addMenu.push(entry); // listed under the timeline's "+ Add" // Dropping a bin tile on the timeline: a video makes a clip, a sound a lane, a picture a scene's anchor (on a clip) or a new scene (beside them). const at = (e) => (app.timelineView && app.timelineView.timeAt ? app.timelineView.timeAt(e.clientX) : app.playhead.at); @@ -63,6 +62,6 @@ export default { p.edit((pr) => addAudioTrack(pr, a, at(e), seconds)); }), ]; - return () => { offs.forEach((f) => f()); menu.node.remove(); }; + return () => { offs.forEach((f) => f()); const i = app.addMenu.indexOf(entry); if (i >= 0) app.addMenu.splice(i, 1); }; }, }; diff --git a/app/ui/features/interpolate/index.js b/app/ui/features/interpolate/index.js index 701c8df6..c5605f60 100644 --- a/app/ui/features/interpolate/index.js +++ b/app/ui/features/interpolate/index.js @@ -7,7 +7,7 @@ import { DEFAULTS, RTX, interpolate, modeLabel } from "./job.js"; export default { id: "interpolate", - mount: "timeline.toolbar.end", + mount: "timeline.actions", needs: ["project", "api", "selection"], modules: ["interpolate"], setup({ host, app }) { diff --git a/app/ui/features/overlays/index.js b/app/ui/features/overlays/index.js index d095aea0..9b0566c3 100644 --- a/app/ui/features/overlays/index.js +++ b/app/ui/features/overlays/index.js @@ -46,18 +46,18 @@ export default { app.timelineLanes.push(entry); app.say("timeline.lanes"); // the stage draws what it has; tell it there is more - const add = c.button.menu({ label: "+ Overlay", tone: "ghost", disabled: !p.project, onClick: () => c.menu.dropdown({ anchor: add, - items: [{ id: "text", label: "Text…" }, { id: "image", label: "Picture…" }, { separator: true }, { id: "lane", label: "New lane on top" }, - { id: "unlane", label: "Remove top lane", danger: true, disabled: !ov.lanesOf(p.project).length }], - onPick: (id) => { - if (id === "text" || id === "image") return open(id); - if (id === "lane") return edit((pr) => ov.addLane(pr)); - const top = ov.lanesOf(p.project).slice(-1)[0]; - if (!top) return; - if (top && !ov.overlaysOf(p.project).some((o) => o.lane_id === top.id)) return edit((pr) => ov.removeLane(pr, top.id)); - c.modal.dialogue({ title: "Remove lane", message: `Remove “${top.label}” and everything on it?`, tone: "danger", confirmLabel: "Remove" }).result.then((yes) => yes && edit((pr) => ov.removeLane(pr, top.id))); - } }) }); - host.append(add.node); + const removeTop = () => { + const top = ov.lanesOf(p.project).slice(-1)[0]; + if (!top) return; + if (!ov.overlaysOf(p.project).some((o) => o.lane_id === top.id)) return edit((pr) => ov.removeLane(pr, top.id)); + c.modal.dialogue({ title: "Remove lane", message: `Remove “${top.label}” and everything on it?`, tone: "danger", confirmLabel: "Remove" }).result.then((yes) => yes && edit((pr) => ov.removeLane(pr, top.id))); + }; + const addEntry = { order: 30, items: () => [ + { id: "ov-text", label: "Text overlay…", disabled: !p.project, run: () => open("text") }, + { id: "ov-image", label: "Picture overlay…", disabled: !p.project, run: () => open("image") }, + { id: "ov-lane", label: "New overlay lane on top", disabled: !p.project, run: () => edit((pr) => ov.addLane(pr)) }, + { id: "ov-unlane", label: "Remove top overlay lane", danger: true, disabled: !p.project || !ov.lanesOf(p.project).length, run: removeTop }] }; + app.addMenu.push(addEntry); // listed under the timeline's "+ Add" const offDrop = onMediaDrop("lane:overlay", async (item, lane, e, where) => { // a picture dropped on an overlay lane becomes an overlay there if (item.kind !== "image" || !p.project) return c.toast.warn({ text: "Only a picture can be laid over the cut." }); let name = "Image"; try { name = ((await app.api.media()).media || []).find((m) => m.id === item.id).name; } catch { /* keep the generic label */ } @@ -65,7 +65,6 @@ export default { const sec = app.timelineView && app.timelineView.timeAt ? app.timelineView.timeAt(e.clientX) : app.playhead.at; edit((pr) => ov.addImage(pr, item.id, name, sec, laneId)); }); - const off = app.on(() => { add.setDisabled && add.setDisabled(!p.project); }); - return () => { off(); offDrop(); const at = app.timelineLanes.indexOf(entry); if (at >= 0) app.timelineLanes.splice(at, 1); add.node.remove(); app.say("timeline.lanes"); }; + return () => { const k = app.addMenu.indexOf(addEntry); if (k >= 0) app.addMenu.splice(k, 1); offDrop(); const at = app.timelineLanes.indexOf(entry); if (at >= 0) app.timelineLanes.splice(at, 1); app.say("timeline.lanes"); }; }, }; diff --git a/app/ui/features/rating/index.js b/app/ui/features/rating/index.js index 989b98dd..5b86abe3 100644 --- a/app/ui/features/rating/index.js +++ b/app/ui/features/rating/index.js @@ -16,7 +16,7 @@ export function notTaught(r, ps) { export default { id: "rating", - mount: "timeline.toolbar.end", + mount: "timeline.actions", needs: ["project", "api", "selection", "pipeline"], setup({ host, app }) { const p = app.project, sel = app.selection; diff --git a/app/ui/features/timeline/index.js b/app/ui/features/timeline/index.js index 641f1d0e..d0b70498 100644 --- a/app/ui/features/timeline/index.js +++ b/app/ui/features/timeline/index.js @@ -77,10 +77,18 @@ const tools = { const inside = head.at > seg.start && head.at < seg.start + seg.dur; if (!p.edit((pr) => edits.split(pr, seg.id, inside ? framesAt(seg.scene, pr, head.at - seg.start) : undefined, gridFor(app)))) c.toast.warn({ text: "Too close to the clip's edge to cut there." }); }; + // One "+ Add" for everything that can go on the timeline: a scene, a clip or sound from the bin, an effect, an overlay. + const addButton = c.button.menu({ label: "+ Add", tone: "ghost", onClick: () => { + const groups = [...(app.addMenu || [])].sort((a, b) => a.order - b.order).map((g) => { try { return g.items(); } catch { return []; } }).filter((g) => g.length); + const items = [{ id: "scene", label: "Scene", disabled: !p.project, run: () => { const sc = p.edit((pr) => edits.addScene(pr)); if (sc) p.select(sc.id); } }, + ...groups.flatMap((g) => [{ separator: true }, ...g])]; + c.menu.dropdown({ anchor: addButton, items, onPick: (id) => { const hit = items.find((i) => i.id === id); if (hit && hit.run) hit.run(); } }); + } }); const draw = () => { const open = p.project, ids = open ? sel.ids : []; + if (addButton.setDisabled) addButton.setDisabled(!open); put(c.hint.default({ text: `${clock(head.at)} / ${clock(open ? totalSeconds(open) : 0)}` }).node, - btn("+ Add", () => { const sc = p.edit((pr) => edits.addScene(pr)); if (sc) p.select(sc.id); }, !open), + addButton.node, btn("Split", split, !ids.length, "Cut the selected clip at the playhead (in the middle when the playhead is elsewhere)"), btn("Remove", () => ids.forEach((id) => p.edit((pr) => edits.removeScene(pr, id))), !ids.length), c.chip.neutral({ label: ids.length ? `${ids.length} clip${ids.length > 1 ? "s" : ""} selected` : "no clip selected" }).node); diff --git a/app/ui/features/upscale/index.js b/app/ui/features/upscale/index.js index 4e3b9c61..79536688 100644 --- a/app/ui/features/upscale/index.js +++ b/app/ui/features/upscale/index.js @@ -7,7 +7,7 @@ import { RTX, RTX_DEFAULTS, keepsSize, keyOf, qualityLabel, swap, upscale } from export default { id: "upscale", - mount: "timeline.toolbar.end", + mount: "timeline.actions", needs: ["project", "api", "selection"], modules: ["render"], setup({ host, app }) { diff --git a/app/ui/modules.js b/app/ui/modules.js index ae9f4d9f..c4696baf 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -14,8 +14,8 @@ export default [ "./features/menus/index.js", "./features/render/index.js", "./features/render/selected.js", - "./features/rating/index.js", "./features/takes/index.js", + "./features/rating/index.js", "./features/subjects/index.js", "./features/upscale/index.js", "./features/interpolate/index.js",