From dd600040a5e154e7714378bf2a8f8cc389ad8e4e Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:27:52 +0300 Subject: [PATCH] fix: overlay colours accept #rgb/#rrggbbaa, orphan overlays show, dialog keeps project-width default and dead pictures, a failing lane feature cannot stop the timeline Co-Authored-By: Claude Sonnet 5.5 --- app/ui/features/overlays/dialog.js | 7 ++++--- app/ui/features/overlays/index.js | 9 +++++---- app/ui/features/timeline/index.js | 2 +- app/ui/shell/overlays.js | 2 +- modules/output/render/overlays.py | 11 ++++++++++- modules/output/render/tests/test_overlays_color.py | 8 ++++++++ 6 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 modules/output/render/tests/test_overlays_color.py diff --git a/app/ui/features/overlays/dialog.js b/app/ui/features/overlays/dialog.js index 2729d19b..324287f3 100644 --- a/app/ui/features/overlays/dialog.js +++ b/app/ui/features/overlays/dialog.js @@ -27,7 +27,7 @@ function textForm(s) { 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 }))], + field("Picture", c.select.md({ label: "Picture", value: s.media_ref || "", options: [{ value: "", label: "Choose a picture…" }, ...(s.media_ref && !bin.some((m) => m.id === s.media_ref) ? [{ value: s.media_ref, label: "(no longer in the media bin)" }] : []), ...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; } }), @@ -40,13 +40,14 @@ function imageForm(s, bin) { /** 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 || {}) }; +export function openDialog({ kind, existing, bin = [], projectWidth = 768, onSave }) { + const s = kind === "text" ? { ...TEXT_DEFAULTS, text: "Title", ...(existing || {}) } : { width_px: Math.round(projectWidth * 0.35), 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." }); + if (kind === "text") s.text = String(s.text || "").trim() || "Title"; // what the render would draw anyway onSave(s); win.close("done"); }; diff --git a/app/ui/features/overlays/index.js b/app/ui/features/overlays/index.js index edf5b308..581601f6 100644 --- a/app/ui/features/overlays/index.js +++ b/app/ui/features/overlays/index.js @@ -5,7 +5,7 @@ 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); +const short = (o) => String((o.kind === "text" ? o.text : o.label) || "Overlay").split("\n")[0].slice(0, 40); export default { id: "overlays", @@ -19,7 +19,7 @@ export default { 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) => { + openDialog({ kind, existing, bin, projectWidth: p.project ? p.project.width : undefined, 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); @@ -30,9 +30,10 @@ export default { const entry = { owns: (id) => id.startsWith(ID), lanes(pr) { - const lanes = ov.lanesOf(pr); + const known = new Set(ov.lanesOf(pr).map((l) => l.id)); + const lanes = [...(ov.overlaysOf(pr).some((o) => !known.has(o.lane_id)) ? [{ id: "", label: "Unplaced" }] : []), ...ov.lanesOf(pr)]; // a v4 project's overlays without a lane still show, at the bottom as they render 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), + clips: ov.overlaysOf(pr).filter((o) => (lane.id ? o.lane_id === lane.id : !known.has(o.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) }] })) })); diff --git a/app/ui/features/timeline/index.js b/app/ui/features/timeline/index.js index ee043a33..d9049828 100644 --- a/app/ui/features/timeline/index.js +++ b/app/ui/features/timeline/index.js @@ -44,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), ...extra.flatMap((e) => e.lanes(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) => { try { return e.lanes(open); } catch { return []; } })] // a feature's lanes failing must not stop the baseline's.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) => { diff --git a/app/ui/shell/overlays.js b/app/ui/shell/overlays.js index ba456fa8..2139f693 100644 --- a/app/ui/shell/overlays.js +++ b/app/ui/shell/overlays.js @@ -18,7 +18,7 @@ export function addLane(p) { return lane; } -/** Drop a lane and everything on it. The last lane stays only while it holds something. */ +/** Drop a lane and everything on it. */ 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); diff --git a/modules/output/render/overlays.py b/modules/output/render/overlays.py index a68b881f..92fe3020 100644 --- a/modules/output/render/overlays.py +++ b/modules/output/render/overlays.py @@ -59,11 +59,20 @@ def _fontfile(family) -> str | None: def _rgba(color, opacity) -> tuple[int, int, int, int]: c = str(color or "#ffffff").strip().lstrip("#") + if len(c) in (3, 4): # #rgb / #rgba + c = "".join(ch * 2 for ch in c) + alpha = 1.0 + if len(c) == 8: # #rrggbbaa: its alpha scales the overlay's opacity + try: + alpha, c = int(c[6:], 16) / 255, c[:6] + except ValueError: + c = "ffffff" + opacity = _num(opacity, 1.0, 0.0, 1.0) * alpha try: r, g, b = (int(c[i:i + 2], 16) for i in (0, 2, 4)) if len(c) == 6 else (255, 255, 255) except ValueError: r, g, b = 255, 255, 255 - return r, g, b, int(_num(opacity, 1.0, 0.0, 1.0) * 255) + return r, g, b, int(opacity * 255) def _variant(regular: str, bold: bool, italic: bool) -> str | None: diff --git a/modules/output/render/tests/test_overlays_color.py b/modules/output/render/tests/test_overlays_color.py new file mode 100644 index 00000000..0dcde047 --- /dev/null +++ b/modules/output/render/tests/test_overlays_color.py @@ -0,0 +1,8 @@ +from modules.output.render.overlays import _rgba + + +def test_short_and_alpha_hex_colours(): + assert _rgba("#f00", 1) == (255, 0, 0, 255) + assert _rgba("#ff000080", 1) == (255, 0, 0, 128) + assert _rgba("#00ff00", 0.5) == (0, 255, 0, 127) + assert _rgba("nonsense", 1) == (255, 255, 255, 255)