Timeline: Rate scene, Upscale and Interpolate in the header after Takes; From bin, Effect and Overlay folded into one + Add menu

This commit is contained in:
digital-garbage
2026-11-08 02:10:07 +02:00
parent 1f8ba6b3a4
commit a4fcafe014
9 changed files with 36 additions and 33 deletions
+1 -1
View File
@@ -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);
+6 -9
View File
@@ -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);
};
},
};
+3 -4
View File
@@ -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); };
},
};
+1 -1
View File
@@ -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 }) {
+13 -14
View File
@@ -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"); };
},
};
+1 -1
View File
@@ -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;
+9 -1
View File
@@ -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);
+1 -1
View File
@@ -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 }) {
+1 -1
View File
@@ -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",