fix(ui): wheel picks run, lane slide/trim clamp at 0, wizard/tour menu items only when loaded; feat: text and picture overlay lanes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
308f133919
commit
edbd8dc1c3
+1
-1
@@ -29,7 +29,7 @@ linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "o
|
||||
const frame = build(document.getElementById("app"));
|
||||
const selection = createSelection({ project });
|
||||
selection.on(() => say("select"));
|
||||
const app = { editorSettings: [], actions: [], inputHooks: [], lastRun: { typed: undefined, n: 0 }, project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, 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(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
|
||||
const app = { editorSettings: [], timelineLanes: [], has: new Set(), actions: [], inputHooks: [], lastRun: { typed: undefined, n: 0 }, project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, 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(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
|
||||
const settings = createSettings({ menubar: hostFor("menubar.menus") });
|
||||
app.openSettings = (id) => settings.open(id);
|
||||
const result = await loadFeatures(paths, { app });
|
||||
|
||||
@@ -60,7 +60,7 @@ export default {
|
||||
const menus = {
|
||||
File: async () => {
|
||||
const recent = await list().catch(() => []);
|
||||
return [{ id: "new", label: "New Project" }, { id: "wizard", label: "Project Setup Wizard…" }, { separator: true },
|
||||
return [{ id: "new", label: "New Project" }, ...(app.has.has("wizard") ? [{ id: "wizard", label: "Project Setup Wizard…" }] : []), { separator: true },
|
||||
{ heading: "Open recent" }, ...(recent.length ? recent.slice(0, 8).map((r) => ({ id: `open:${r.id}`, label: r.name })) : [{ id: "-", label: "No projects", disabled: true }]),
|
||||
{ separator: true }, { id: "save", label: "Save Project File…", hint: "⬇", disabled: !p.project }, { id: "load", label: "Load Project File…" }, { separator: true }, { id: "import", label: "Import Media…" },
|
||||
{ id: "delete", label: "Delete Current Project", danger: true, disabled: !p.project }];
|
||||
@@ -70,7 +70,7 @@ export default {
|
||||
{ id: "left", label: "Move Clip Left", hint: "timeline cut", disabled: !sel.focus }, { id: "right", label: "Move Clip Right", hint: "timeline cut", disabled: !sel.focus }, { separator: true },
|
||||
{ id: "exclude", label: "Toggle Exclude", disabled: !sel.focus }],
|
||||
View: () => [{ id: "refresh", label: "Refresh Preview" }, { id: "reset", label: "Reset Layout" }],
|
||||
Help: () => [{ id: "tour", label: "Restart tour" }, { id: "faq", label: "Skip to FAQ" }, { id: "exit", label: "Exit tour" }, { separator: true }, { id: "welcome", label: "Welcome tour…" }],
|
||||
Help: () => [...(app.has.has("tour") ? [{ id: "tour", label: "Restart tour" }, { id: "faq", label: "Skip to FAQ" }, { id: "exit", label: "Exit tour" }, { separator: true }] : []), { id: "welcome", label: "Welcome tour…" }],
|
||||
};
|
||||
const pick = (id) => (id.startsWith("open:") ? p.open(id.slice(5)) : actions[id] && actions[id]());
|
||||
for (const [label, items] of Object.entries(menus)) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// The window that makes or edits one overlay. Nothing changes in the project until Save / Add.
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { FONTS, TEXT_DEFAULTS } from "../../shell/overlays.js";
|
||||
|
||||
const field = (label, control, hint) => c.field.default({ label, hint, control });
|
||||
const options = (list) => list.map((v) => ({ value: v, label: v[0].toUpperCase() + v.slice(1) }));
|
||||
|
||||
function textForm(s) {
|
||||
const num = (label, key, min, max, step) => field(label, c.number.md({ label, value: s[key], min, max, step, onChange: (v) => { s[key] = v; } }));
|
||||
const tog = (label, key) => c.toggle.default({ label, checked: Boolean(s[key]), onChange: (v) => { s[key] = v; } });
|
||||
const col = (label, key) => field(label, c.color.swatch({ label, value: s[key], onChange: (v) => { s[key] = v; } }));
|
||||
return [
|
||||
field("Text", c.textarea.md({ label: "Text", value: s.text, rows: 3, onInput: (v) => { s.text = v; } })),
|
||||
num("Font size (px)", "font_size", 8, 400, 1),
|
||||
field("Font", c.select.md({ label: "Font", value: s.font_family, options: options(FONTS), onChange: (v) => { s.font_family = v; } })),
|
||||
col("Colour", "color"),
|
||||
field("Opacity", c.slider.readout({ label: "Opacity", value: s.opacity, min: 0, max: 1, step: 0.05, precision: 2, onCommit: (v) => { s.opacity = v; } })),
|
||||
tog("Bold", "bold"), tog("Italic", "italic"),
|
||||
field("Alignment", c.segmented.md({ label: "Alignment", value: s.text_align, options: options(["left", "center", "right"]), onChange: (v) => { s.text_align = v; } })),
|
||||
num("Line spacing", "line_spacing", 0.8, 3, 0.1),
|
||||
num("Outline width (px)", "stroke_width", 0, 40, 1), col("Outline colour", "stroke_color"),
|
||||
tog("Shadow", "shadow"), col("Shadow colour", "shadow_color"),
|
||||
tog("Background box", "bg_enabled"), col("Box colour", "bg_color"),
|
||||
field("Box opacity", c.slider.readout({ label: "Box opacity", value: s.bg_opacity, min: 0, max: 1, step: 0.05, precision: 2, onCommit: (v) => { s.bg_opacity = v; } })),
|
||||
];
|
||||
}
|
||||
|
||||
function imageForm(s, bin) {
|
||||
return [
|
||||
field("Picture", c.select.md({ label: "Picture", value: s.media_ref || "", options: [{ value: "", label: "Choose a picture…" }, ...bin.map((m) => ({ value: m.id, label: m.name }))],
|
||||
onChange: (v) => { s.media_ref = v; s.label = (bin.find((m) => m.id === v) || {}).name || "Image"; } }), bin.length ? undefined : "The media bin has no pictures yet."),
|
||||
field("Width (px)", c.number.md({ label: "Width", value: s.width_px, min: 8, step: 1, precision: 0, onChange: (v) => { s.width_px = v; } })),
|
||||
c.toggle.default({ label: "Keep the picture's proportions", checked: s.keep_aspect !== false, onChange: (v) => { s.keep_aspect = v; if (!v && s.height_px == null) s.height_px = s.width_px; } }),
|
||||
field("Height (px)", c.number.md({ label: "Height", value: s.height_px ?? s.width_px, min: 8, step: 1, precision: 0, onChange: (v) => { s.height_px = v; } }), "Used only when the proportions are not kept."),
|
||||
field("Opacity", c.slider.readout({ label: "Opacity", value: s.opacity ?? 1, min: 0, max: 1, step: 0.05, precision: 2, onCommit: (v) => { s.opacity = v; } })),
|
||||
c.toggle.default({ label: "Flip left–right", checked: Boolean(s.flip_h), onChange: (v) => { s.flip_h = v; } }),
|
||||
c.toggle.default({ label: "Flip upside down", checked: Boolean(s.flip_v), onChange: (v) => { s.flip_v = v; } }),
|
||||
];
|
||||
}
|
||||
|
||||
/** kind "text"|"image", `existing` an overlay (edit) or null (new), `bin` the pictures to choose from.
|
||||
* `onSave(patch)` runs once with the chosen values; the window closes after it. */
|
||||
export function openDialog({ kind, existing, bin = [], onSave }) {
|
||||
const s = kind === "text" ? { ...TEXT_DEFAULTS, text: "Title", ...(existing || {}) } : { width_px: 270, keep_aspect: true, opacity: 1, ...(existing || {}) };
|
||||
const where = field("Starts at (s)", c.number.md({ label: "Starts at", value: existing ? existing.start_sec : undefined, min: 0, step: 0.1, onChange: (v) => { s.start_sec = v; } }));
|
||||
const dur = field("Shown for (s)", c.number.md({ label: "Shown for", value: s.duration_sec ?? 3, min: 0.1, step: 0.1, onChange: (v) => { s.duration_sec = v; } }));
|
||||
const body = c.region.stack({ gap: "sm", children: [...(kind === "text" ? textForm(s) : imageForm(s, bin)), ...(existing ? [where, dur] : [])] });
|
||||
const save = () => {
|
||||
if (kind === "image" && !s.media_ref) return c.toast.warn({ text: "Choose a picture first." });
|
||||
onSave(s);
|
||||
win.close("done");
|
||||
};
|
||||
const win = c.modal.generic({ title: `${existing ? "Edit" : "Add"} ${kind} overlay`, size: "md", body });
|
||||
win.setFooter({ actions: [c.button.sm({ label: "Cancel", tone: "ghost", onClick: () => win.close("cancel") }), c.button.sm({ label: existing ? "Save" : "Add", tone: "primary", onClick: save })] });
|
||||
return win;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Overlays: text and pictures laid over the cut. Adds lanes to the timeline (through app.timelineLanes) and a "+ Overlay" button.
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import * as ov from "../../shell/overlays.js";
|
||||
import { learn } from "../../shell/bin.js";
|
||||
import { openDialog } from "./dialog.js";
|
||||
|
||||
const ID = "o:";
|
||||
const short = (o) => (o.kind === "text" ? o.text : o.label || "Image").split("\n")[0].slice(0, 40);
|
||||
|
||||
export default {
|
||||
id: "overlays",
|
||||
mount: "timeline.toolbar",
|
||||
needs: ["project", "playhead", "api"],
|
||||
setup({ host, app }) {
|
||||
const p = app.project;
|
||||
const edit = (fn) => p.edit(fn);
|
||||
const pictures = async () => { const list = (await app.api.media()).media || []; learn(list); return list.filter((m) => m.kind === "image"); };
|
||||
|
||||
async function open(kind, existing) {
|
||||
let bin = [];
|
||||
if (kind === "image") { try { bin = await pictures(); } catch (err) { return c.toast.warn({ text: `Could not read the media bin: ${err.message}` }); } }
|
||||
openDialog({ kind, existing, bin, onSave: (s) => edit((pr) => {
|
||||
if (existing) return ov.update(pr, existing.id, s);
|
||||
const at = app.playhead.at, made = kind === "text" ? ov.addText(pr, at, s) : ov.addImage(pr, s.media_ref, s.label, at);
|
||||
return ov.update(pr, made.id, s);
|
||||
}) });
|
||||
}
|
||||
const confirmRemove = async (o) => { if (await c.modal.dialogue({ title: "Remove overlay", message: `Remove “${short(o)}” from the cut?`, tone: "danger", confirmLabel: "Remove" }).result) edit((pr) => ov.remove(pr, o.id)); };
|
||||
|
||||
const entry = {
|
||||
owns: (id) => id.startsWith(ID),
|
||||
lanes(pr) {
|
||||
const lanes = ov.lanesOf(pr);
|
||||
return [...lanes].reverse().map((lane) => ({ id: `ov:${lane.id}`, label: lane.label, kind: "overlay", move: true,
|
||||
clips: ov.overlaysOf(pr).filter((o) => o.lane_id === lane.id).map((o) => ({ id: ID + o.id, start: o.start_sec || 0, dur: o.duration_sec || 0.1, trim: true, title: short(o),
|
||||
head: [o.kind === "text" ? "T" : "▣"],
|
||||
actions: [{ label: "✎", title: "Edit", onClick: () => open(o.kind, o) }, { label: "▲", title: "Draw above the others (higher lane)", onClick: () => edit((x) => ov.restack(x, o.id, 1)) },
|
||||
{ label: "▼", title: "Draw below the others (lower lane)", onClick: () => edit((x) => ov.restack(x, o.id, -1)) }, { label: "✕", title: "Remove", onClick: () => confirmRemove(o) }] })) }));
|
||||
},
|
||||
move: (id, d) => edit((pr) => ov.move(pr, id.slice(ID.length), d)),
|
||||
trim: (id, edge, d) => edit((pr) => ov.trim(pr, id.slice(ID.length), edge, d)),
|
||||
select: (id) => { const o = ov.find(p.project, id.slice(ID.length)); if (o) open(o.kind, o); },
|
||||
};
|
||||
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 off = app.on(() => { add.setDisabled && add.setDisabled(!p.project); });
|
||||
return () => { off(); const at = app.timelineLanes.indexOf(entry); if (at >= 0) app.timelineLanes.splice(at, 1); add.node.remove(); app.say("timeline.lanes"); };
|
||||
},
|
||||
};
|
||||
@@ -19,12 +19,16 @@ const stage = {
|
||||
const p = app.project, sel = app.selection, head = app.playhead;
|
||||
let zoom = 2;
|
||||
const edit = (fn) => p.edit(fn);
|
||||
const extra = app.timelineLanes || []; // lanes other features add: { owns(id), lanes(project), select/move/trim(id, ...) }
|
||||
const owner = (id) => extra.find((e) => e.owns(id));
|
||||
const view = c.timeline.stage({
|
||||
pxPerSecond: ZOOM[zoom],
|
||||
onSeek: (sec) => head.set(sec),
|
||||
onSelect: (id, how) => sel.pick(id, how, sceneIds(p.project)),
|
||||
onMove: (id, d) => id.startsWith("t:") && edit((pr) => moveLane(pr, id.slice(2), d)),
|
||||
onSelect: (id, how) => { const own = owner(id); if (own) own.select(id); else sel.pick(id, how, sceneIds(p.project)); },
|
||||
onMove: (id, d) => { const own = owner(id); if (own) own.move(id, d); else id.startsWith("t:") && edit((pr) => moveLane(pr, id.slice(2), d)); },
|
||||
onTrim: (id, edge, d) => {
|
||||
const own = owner(id);
|
||||
if (own) return own.trim(id, edge, d);
|
||||
if (id.startsWith("t:")) return edit((pr) => trimLane(pr, id.slice(2), edge, d));
|
||||
const dur = segments(p.project).find((s) => s.id === id).dur;
|
||||
edit((pr) => edge === "out" ? edits.resize(pr, id, dur + d) : d > 0 && edits.trimLeft(pr, id, d)); // the left edge only cuts in
|
||||
@@ -40,7 +44,7 @@ const stage = {
|
||||
const ghostActions = (g) => [{ label: "✕", title: "Remove from the timeline",
|
||||
onClick: () => edit((pr) => { pr.scene_ghosts = (pr.scene_ghosts || []).filter((x) => x.id !== g.id); return true; }) }];
|
||||
|
||||
const draw = () => { const open = p.project; view.setLanes(open ? [videoLane(open, sel.ids, sel.focus, clipActions, ghostActions), audioLane(open, sel.ids), tracksLane(open)].filter(Boolean) : []); };
|
||||
const draw = () => { const open = p.project; view.setLanes(open ? [videoLane(open, sel.ids, sel.focus, clipActions, ghostActions), audioLane(open, sel.ids), tracksLane(open), ...extra.flatMap((e) => e.lanes(open))].filter(Boolean) : []); };
|
||||
const setZoom = (z) => { zoom = Math.min(ZOOM.length - 1, Math.max(0, z)); view.setZoom(ZOOM[zoom]); };
|
||||
host.append(view.node);
|
||||
const off = [app.on((what) => {
|
||||
|
||||
@@ -34,10 +34,12 @@ export default {
|
||||
trailing: [c.button.sm({ label: "Exit tour", tone: "ghost", onClick: stop })] })] });
|
||||
pop = c.popover.anchored({ anchor: target, body, side: s.side, align: "center", gap: 10, closeOnOutside: false, onClose: () => { if (pop) { pop = null; at = -1; } } });
|
||||
}
|
||||
return app.on((what) => {
|
||||
app.has.add("tour");
|
||||
const off = app.on((what) => {
|
||||
if (what === "tour.start") show(0);
|
||||
else if (what === "tour.faq") show(STEPS.length - 1);
|
||||
else if (what === "tour.stop") stop();
|
||||
});
|
||||
return () => { off(); app.has.delete("tour"); };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,15 +11,15 @@ export default {
|
||||
const offs = [
|
||||
offer(app, { id: "undo", label: "Undo", icon: "↶", run: () => app.project.undo() }),
|
||||
offer(app, { id: "redo", label: "Redo", icon: "↷", run: () => app.project.redo() }),
|
||||
offer(app, { id: "models", label: "Models & Pipeline", icon: "▤", run: () => app.openSettings && app.openSettings("models") }),
|
||||
offer(app, { id: "engine", label: "Engine settings", icon: "⚙", run: () => app.openSettings && app.openSettings("engine") }),
|
||||
offer(app, { id: "models", label: "Models & Pipeline", icon: "▤", when: () => Boolean(app.openSettings), run: () => app.openSettings("models") }),
|
||||
offer(app, { id: "engine", label: "Engine settings", icon: "⚙", when: () => Boolean(app.openSettings), run: () => app.openSettings("engine") }),
|
||||
];
|
||||
const onDown = (e) => {
|
||||
if (e.button !== 1 || wheel || document.querySelector('[role="dialog"]')) return; // nothing behind a dialog
|
||||
e.preventDefault();
|
||||
const items = (app.actions || []).map((a) => ({ id: a.id, label: a.label, icon: a.icon }));
|
||||
const items = (app.actions || []).filter((a) => !a.when || a.when()).slice(0, 12).map((a) => ({ id: a.id, label: a.label, icon: a.icon }));
|
||||
wheel = c.wheel.picker({ items, x: e.clientX, y: e.clientY, onClose: () => { wheel = null; },
|
||||
onPick: (id) => { const a = (app.actions || []).find((x) => x.id === id); if (a) Promise.resolve().then(() => a.run()).catch((err) => c.toast.warn({ text: err.message })); } });
|
||||
onPick: (item) => { const a = (app.actions || []).find((x) => x.id === item.id); if (a) Promise.resolve().then(() => a.run()).catch((err) => c.toast.warn({ text: err.message })); } });
|
||||
};
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
return () => { document.removeEventListener("mousedown", onDown, true); if (wheel) wheel.close && wheel.close(); offs.forEach((f) => f()); };
|
||||
|
||||
@@ -23,12 +23,16 @@ export default {
|
||||
{ title: "Want the guided tour?", sub: "A walk through the screen, one part at a time. It changes nothing; Help ▸ Restart tour runs it again.", body: () => c.toggle.default({ label: "Show me around when I finish", checked: ctx.tour, onChange: (v) => { ctx.tour = v; } }) },
|
||||
];
|
||||
|
||||
let busy = false;
|
||||
async function finish() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
const pick = presets.find((p) => p.id === ctx.preset);
|
||||
try {
|
||||
await app.project.newProject((ctx.name || "").trim() || "Untitled montage");
|
||||
if (pick) { const r = await app.pipeline.restore({ slots: pick.slots, removed: [], unwired: {} }); if (r.refused.length) c.toast.warn({ text: `The model's pipeline was not loaded: ${r.refused[0]}` }); }
|
||||
} catch (err) { return c.toast.warn({ text: `Could not make the project: ${err.message}` }); }
|
||||
} catch (err) { busy = false; return c.toast.warn({ text: `Could not make the project: ${err.message}` }); }
|
||||
busy = false;
|
||||
close();
|
||||
if (ctx.tour) setTimeout(() => app.say("tour.start"), 300);
|
||||
}
|
||||
@@ -45,9 +49,11 @@ export default {
|
||||
if (win) return;
|
||||
try { presets = (await app.api.pipelinePresets()).presets || []; } catch { presets = []; }
|
||||
step = 0; ctx.name = "Untitled montage"; ctx.preset = ""; ctx.tour = true;
|
||||
win = c.modal.generic({ title: "Project Setup", size: "md", body: body(STEPS[0]), onClose: () => { win = null; } });
|
||||
win = c.modal.generic({ title: "Project Setup", size: "md", closeOnOutside: false, body: body(STEPS[0]), onClose: () => { win = null; } });
|
||||
draw();
|
||||
}
|
||||
return app.on((what) => { if (what === "wizard.open") open(); });
|
||||
app.has.add("wizard");
|
||||
const off = app.on((what) => { if (what === "wizard.open") open(); });
|
||||
return () => { off(); app.has.delete("wizard"); };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export default [
|
||||
"./features/wheel/index.js",
|
||||
"./features/status/index.js",
|
||||
"./features/audio/index.js",
|
||||
"./features/overlays/index.js",
|
||||
"./features/montage/index.js",
|
||||
"./features/generate/warn.js",
|
||||
"./features/composer/index.js",
|
||||
|
||||
@@ -65,8 +65,10 @@ export function syncSeparated(p) {
|
||||
export function moveLane(p, id, deltaSec) {
|
||||
const t = (p.audio_tracks || []).find((x) => x.id === id && x.kind === "separated");
|
||||
if (!t || !deltaSec) return false;
|
||||
t.offset_sec = (t.offset_sec || 0) + deltaSec;
|
||||
return true;
|
||||
const seg = segments(p).find((s) => s.kind === "scene" && s.id === t.scene_id);
|
||||
const was = t.offset_sec || 0;
|
||||
t.offset_sec = Math.max(seg ? -seg.start : -Infinity, was + deltaSec); // never before 0: a slide past the edge must not be owed on the way back
|
||||
return t.offset_sec !== was;
|
||||
}
|
||||
|
||||
/** Trim a separated lane's sound. "in" cuts the head off (positive) or gives it back (negative, never before the sound begins);
|
||||
@@ -76,7 +78,7 @@ export function trimLane(p, id, edge, deltaSec) {
|
||||
if (!t || !deltaSec || t.pinned_dur == null) return false;
|
||||
const full = t.full_dur != null ? t.full_dur : t.pinned_dur;
|
||||
if (edge === "out") { t.user_dur = Math.max(0.1, Math.min(full, t.pinned_dur + deltaSec)); return true; }
|
||||
const d = Math.min(Math.max(deltaSec, -(t.pinned_in_sec || 0)), t.pinned_dur - 0.1);
|
||||
const d = Math.min(Math.max(deltaSec, -(t.pinned_in_sec || 0), -(t.start_sec || 0)), t.pinned_dur - 0.1); // nor earlier than the timeline's start
|
||||
if (!d) return false;
|
||||
t.pinned_in_sec = (t.pinned_in_sec || 0) + d; t.source_in_sec = t.pinned_in_sec;
|
||||
t.full_dur = Math.max(0.1, full - d);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Overlays: text and pictures laid over the cut for a time, on stacked lanes (a higher lane draws on top).
|
||||
// Pure edits of the project; the render reads project.overlay_tracks / overlay_lanes as they are.
|
||||
const newId = () => "o" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||
const MIN = 0.1;
|
||||
|
||||
export const TEXT_DEFAULTS = { font_size: 42, font_family: "arial", color: "#ffffff", opacity: 1, bold: false, italic: false, text_align: "center",
|
||||
line_spacing: 1.2, stroke_width: 0, stroke_color: "#000000", shadow: false, shadow_color: "#000000", bg_enabled: false, bg_color: "#000000", bg_opacity: 0.5 };
|
||||
export const FONTS = ["arial", "helvetica", "georgia", "times", "courier", "verdana", "impact"];
|
||||
|
||||
export const lanesOf = (p) => p.overlay_lanes || [];
|
||||
export const overlaysOf = (p) => p.overlay_tracks || [];
|
||||
export const find = (p, id) => overlaysOf(p).find((o) => o.id === id);
|
||||
|
||||
/** A new lane on top. -> it. */
|
||||
export function addLane(p) {
|
||||
const lane = { id: newId(), label: `Overlay ${lanesOf(p).length + 1}` };
|
||||
p.overlay_lanes = [...lanesOf(p), lane];
|
||||
return lane;
|
||||
}
|
||||
|
||||
/** Drop a lane and everything on it. The last lane stays only while it holds something. */
|
||||
export function removeLane(p, laneId) {
|
||||
if (!lanesOf(p).some((l) => l.id === laneId)) return false;
|
||||
p.overlay_lanes = lanesOf(p).filter((l) => l.id !== laneId);
|
||||
p.overlay_tracks = overlaysOf(p).filter((o) => o.lane_id !== laneId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function put(p, ov, at, laneId) {
|
||||
const lane = lanesOf(p).some((l) => l.id === laneId) ? laneId : (lanesOf(p).length ? lanesOf(p)[lanesOf(p).length - 1].id : addLane(p).id);
|
||||
const made = { id: newId(), lane_id: lane, start_sec: Math.max(0, at || 0), duration_sec: 3, x: 0.5, y: 0.5, flip_h: false, flip_v: false, opacity: 1, ...ov };
|
||||
p.overlay_tracks = [...overlaysOf(p), made];
|
||||
return made;
|
||||
}
|
||||
|
||||
export const addText = (p, at, style = {}, laneId) => put(p, { kind: "text", ...TEXT_DEFAULTS, ...style, text: String(style.text || "").trim() || "Title", label: "Text" }, at, laneId);
|
||||
export const addImage = (p, mediaId, name, at, laneId) => put(p, { kind: "image", media_ref: mediaId, width_px: Math.max(8, Math.round((p.width || 768) * 0.35)), keep_aspect: true, label: name || "Image" }, at, laneId);
|
||||
|
||||
export function update(p, id, patch) {
|
||||
const o = find(p, id);
|
||||
if (!o) return false;
|
||||
Object.assign(o, patch);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function remove(p, id) {
|
||||
if (!find(p, id)) return false;
|
||||
p.overlay_tracks = overlaysOf(p).filter((o) => o.id !== id);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function move(p, id, deltaSec) {
|
||||
const o = find(p, id);
|
||||
if (!o || !deltaSec) return false;
|
||||
o.start_sec = Math.max(0, (o.start_sec || 0) + deltaSec);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** "in" moves the start (the end stays); "out" sets the length. */
|
||||
export function trim(p, id, edge, deltaSec) {
|
||||
const o = find(p, id);
|
||||
if (!o || !deltaSec) return false;
|
||||
const dur = o.duration_sec || 0;
|
||||
if (edge === "out") { o.duration_sec = Math.max(MIN, dur + deltaSec); return true; }
|
||||
const d = Math.min(Math.max(deltaSec, -(o.start_sec || 0)), dur - MIN);
|
||||
if (!d) return false;
|
||||
o.start_sec = (o.start_sec || 0) + d;
|
||||
o.duration_sec = dur - d;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Put an overlay on the lane `by` steps up (+) or down (-); -> false at the edge. */
|
||||
export function restack(p, id, by) {
|
||||
const o = find(p, id), lanes = lanesOf(p);
|
||||
const to = o && lanes.findIndex((l) => l.id === o.lane_id) + by;
|
||||
if (!o || to < 0 || to >= lanes.length || by === 0) return false;
|
||||
o.lane_id = lanes[to].id;
|
||||
return true;
|
||||
}
|
||||
@@ -113,3 +113,16 @@ test("trimming a lane cuts its sound, not its clip; the tail can grow back, neve
|
||||
assert.deepEqual([lane.pinned_in_sec, lane.pinned_dur, lane.start_sec], [1.5, 1.5, 2.5]); // the head is gone and the rest still starts where it sounded
|
||||
assert.equal(trimLane(p, lane.id, "in", 99) && lane.pinned_dur >= 0.1, true);
|
||||
});
|
||||
|
||||
test("sliding past the timeline start is not owed on the way back; trimming in cannot reach before 0", () => {
|
||||
const p = proj(), lane = separate(p, "b");
|
||||
syncSeparated(p);
|
||||
moveLane(p, lane.id, -9);
|
||||
assert.equal(lane.offset_sec, -2);
|
||||
moveLane(p, lane.id, 1);
|
||||
assert.equal(lane.offset_sec, -1);
|
||||
const q = proj(), first = separate(q, "a");
|
||||
const before = first.pinned_in_sec;
|
||||
assert.equal(trimLane(q, first.id, "in", -0.5), false);
|
||||
assert.equal(first.pinned_in_sec, before);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as ov from "../overlays.js";
|
||||
|
||||
test("the first overlay makes its lane; later ones go on the top lane", () => {
|
||||
const p = { width: 1000 };
|
||||
const a = ov.addText(p, 2, { text: " Hi " }), b = ov.addImage(p, "m1", "pic", 1);
|
||||
assert.equal(p.overlay_lanes.length, 1);
|
||||
assert.deepEqual([a.text, a.start_sec, a.lane_id === b.lane_id, b.width_px], ["Hi", 2, true, 350]);
|
||||
const lane = ov.addLane(p);
|
||||
assert.equal(ov.addText(p, 0).lane_id, lane.id);
|
||||
});
|
||||
|
||||
test("trim in keeps the end, never before 0 nor past the end; out sets the length", () => {
|
||||
const p = {}, o = ov.addText(p, 1);
|
||||
ov.trim(p, o.id, "in", 0.5);
|
||||
assert.deepEqual([o.start_sec, o.duration_sec], [1.5, 2.5]);
|
||||
ov.trim(p, o.id, "in", -9);
|
||||
assert.deepEqual([o.start_sec, o.duration_sec], [0, 4]);
|
||||
ov.trim(p, o.id, "in", 99);
|
||||
assert.ok(Math.abs(o.duration_sec - 0.1) < 1e-9);
|
||||
ov.trim(p, o.id, "out", -5);
|
||||
assert.equal(o.duration_sec, 0.1);
|
||||
});
|
||||
|
||||
test("move clamps at 0; restack walks lanes and stops at the edge; removing a lane takes its overlays", () => {
|
||||
const p = {}, o = ov.addText(p, 1), l2 = ov.addLane(p);
|
||||
ov.move(p, o.id, -5);
|
||||
assert.equal(o.start_sec, 0);
|
||||
assert.equal(ov.restack(p, o.id, 1), true);
|
||||
assert.equal(o.lane_id, l2.id);
|
||||
assert.equal(ov.restack(p, o.id, 1), false);
|
||||
ov.removeLane(p, l2.id);
|
||||
assert.equal(ov.find(p, o.id), undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user