From f4e89d4433fa1add9595078a8e3b5f4dd5ea15f5 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:44:27 +0300 Subject: [PATCH] fix: auto-hide opens from its strip, never the title bar; popovers re-place on resize; one-line autocomplete; last frame held at end; saved videos named after their render Co-Authored-By: Claude Opus 5.5 --- app/ui/composer/elements/overlay.css | 5 ++- app/ui/composer/elements/popover.js | 6 ++- app/ui/composer/tests/overlays.test.js | 19 ++++++++ app/ui/features/peek/hub.test.js | 33 ++++++++++++++ app/ui/features/peek/index.js | 50 +++++++++++++++------ app/ui/features/preview/index.js | 9 +++- app/ui/features/preview/player.test.js | 10 ++++- app/ui/frame.css | 12 ++--- core/routes.py | 9 +++- core/tests/test_media.py | 3 +- modules/output/render/serving.py | 9 +++- modules/output/render/tests/test_serving.py | 1 + 12 files changed, 140 insertions(+), 26 deletions(-) create mode 100644 app/ui/features/peek/hub.test.js diff --git a/app/ui/composer/elements/overlay.css b/app/ui/composer/elements/overlay.css index 1974cf7f..bfae2b00 100644 --- a/app/ui/composer/elements/overlay.css +++ b/app/ui/composer/elements/overlay.css @@ -117,7 +117,10 @@ max-width: 260px; } -.cx-autocomplete { min-width: 220px; padding: var(--sp-1); } +.cx-autocomplete { min-width: 220px; inline-size: min(92vw, 420px); padding: var(--sp-1); } +/* a suggestion is one line: the trigger, then as much of its prompt as fits */ +.cx-autocomplete .cx-menu-label { flex: none; max-inline-size: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cx-autocomplete .cx-menu-hint { flex: 1 1 0; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cx-split-btn { display: inline-flex; } .cx-split-btn .cx-btn:first-child { border-radius: var(--ctl-r) 0 0 var(--ctl-r); } diff --git a/app/ui/composer/elements/popover.js b/app/ui/composer/elements/popover.js index 7f9d76a0..95320fbd 100644 --- a/app/ui/composer/elements/popover.js +++ b/app/ui/composer/elements/popover.js @@ -31,6 +31,9 @@ function floatOn(rung, cls, { anchor, side = "bottom", align = "start", gap = 6, window.addEventListener("resize", reposition); // Capture, because the scroller is usually an ancestor panel, not the window. window.addEventListener("scroll", reposition, true); + // Content that arrives after opening changes the size: placed for the old one, it sat wrong until the next scroll. + const resized = typeof ResizeObserver === "function" ? new ResizeObserver(reposition) : null; + if (resized) resized.observe(node); const dismissal = push({ nodes: [node, anchorNode].filter(Boolean), @@ -49,6 +52,7 @@ function floatOn(rung, cls, { anchor, side = "bottom", align = "start", gap = 6, dismissal.release(); window.removeEventListener("resize", reposition); window.removeEventListener("scroll", reposition, true); + if (resized) resized.disconnect(); layer.release(); unmount(node); if (onClose) onClose(reason); @@ -172,7 +176,7 @@ define("autocomplete", "default", ({ input, source, onPick, minChars = 1 } = {}) items.forEach((item, i) => { live.node.append(el("button", { cls: ["cx-menu-item", i === index ? "cx-on" : null], - attrs: { type: "button", role: "option", "aria-selected": String(i === index) }, + attrs: { type: "button", role: "option", "aria-selected": String(i === index), title: item.hint ? `${item.label}: ${item.hint}` : undefined }, children: [ el("span", { cls: "cx-menu-label", text: item.label }), item.hint ? el("span", { cls: "cx-menu-hint", text: item.hint }) : null, diff --git a/app/ui/composer/tests/overlays.test.js b/app/ui/composer/tests/overlays.test.js index 89e0b292..f5da4540 100644 --- a/app/ui/composer/tests/overlays.test.js +++ b/app/ui/composer/tests/overlays.test.js @@ -617,3 +617,22 @@ test("a button inside a drag handle is pressed, not dragged (a window's ✕ in i down(handle); assert.equal(started, 1); }); + +test("a popover whose content grows after opening is placed again for its new size, not at the next scroll", () => { + let observed = null; + globalThis.ResizeObserver = class { constructor(fn) { observed = fn; } observe() {} disconnect() {} }; + try { + const anchor = document.createElement("button"); + anchor.getBoundingClientRect = () => ({ x: 500, y: 400, width: 40, height: 20 }); + document.body.append(anchor); + const body = composer.region.stack({}); + const pop = composer.popover.anchored({ anchor, body, side: "top", align: "end" }); + let width = 100; + pop.node.getBoundingClientRect = () => ({ x: 0, y: 0, width, height: 50 }); + pop.reposition(); + assert.equal(pop.node.style.left, "440px", "end-aligned to the anchor's right edge"); + width = 300; observed(); // the sampler's rows arrive + assert.equal(pop.node.style.left, "240px"); + pop.close(); anchor.remove(); + } finally { delete globalThis.ResizeObserver; } +}); diff --git a/app/ui/features/peek/hub.test.js b/app/ui/features/peek/hub.test.js new file mode 100644 index 00000000..b7ad3851 --- /dev/null +++ b/app/ui/features/peek/hub.test.js @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupDom, teardownDom } from "../../composer/tests/_dom.js"; + +let peek; +test.before(async () => { setupDom(); globalThis.localStorage = window.localStorage; peek = (await import("./index.js")).default; }); +test.after(() => { delete globalThis.localStorage; teardownDom(); }); + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const point = (node) => node.dispatchEvent(new window.Event("pointerover", { bubbles: true })); + +test("auto-hide: the title bar (Generate, Rate) never opens the timeline; resting on the strip does", async () => { + localStorage.setItem("funpack_timeline_peek", "1"); + document.body.innerHTML = `
`; + const root = document.documentElement, isOpen = () => root.classList.contains("fp-peek-open"); + const off = peek.setup({ host: document.getElementById("status") }); + const strip = document.querySelector(".fp-peek-strip"); + assert.ok(strip && strip.nextElementSibling.classList.contains("cx-panel-body"), "the strip sits between title bar and timeline"); + + point(document.getElementById("gen")); await wait(300); + assert.equal(isOpen(), false, "reaching for Generate keeps it shut"); + + point(strip); point(document.querySelector(".fp-row")); await wait(300); + assert.equal(isOpen(), false, "passing over the strip does not flash it open"); + + point(strip); await wait(300); + assert.equal(isOpen(), true, "resting on the strip opens it"); + point(document.getElementById("lane")); await wait(10); + assert.equal(isOpen(), true, "the timeline itself keeps it open"); + point(document.getElementById("gen")); + assert.equal(isOpen(), false, "back on the title bar, it shuts"); + off(); +}); diff --git a/app/ui/features/peek/index.js b/app/ui/features/peek/index.js index bc1c8b7d..6cf1fb0f 100644 --- a/app/ui/features/peek/index.js +++ b/app/ui/features/peek/index.js @@ -1,32 +1,56 @@ -// "Auto-hide": the timeline sits as a strip (its title bar) and opens to full height while the pointer is on it. -// A preference of this screen, kept in this browser. It also opens for the length of a drag, so dropping onto a lane still works. +// "Auto-hide": the timeline sits as its title bar plus a labelled strip, and opens to full height when the pointer rests on +// the strip or the timeline. As v4: the title bar never opens it (reaching for Generate or Rate must not throw it open), a +// pass over the strip on the way somewhere does not flash it (a short beat first), and a drag opens it at once so a lane +// can be hit. A preference of this screen, kept in this browser. import { composer as c } from "../../composer/composer.js"; -const KEY = "funpack_timeline_peek"; +const KEY = "funpack_timeline_peek", BEAT_MS = 220; export default { id: "peek", mount: "timeline.status", setup({ host }) { const root = document.documentElement; - let on = false; + const zone = () => document.querySelector(".fp-main > :nth-child(2)"); + let on = false, timer = null, dragging = false; try { on = localStorage.getItem(KEY) === "1"; } catch { /* the plain layout */ } - const paint = () => root.classList.toggle("fp-peek", on); + const strip = Object.assign(document.createElement("div"), { className: "fp-peek-strip", textContent: "Timeline · hover to show" }); + const place = () => { const z = zone(), body = z && z.querySelector(".cx-panel-body"); if (body && strip.parentNode !== z) z.insertBefore(strip, body); }; + // the closed height (title bar + strip, which may wrap) is what the row above leaves room for + const sizer = typeof ResizeObserver === "function" ? new ResizeObserver(() => { + const z = zone(); + if (z && !root.classList.contains("fp-peek-open")) root.style.setProperty("--peek-h", z.offsetHeight + "px"); + }) : null; + const paint = () => { root.classList.toggle("fp-peek", on); place(); const z = zone(); if (sizer && z) sizer.observe(z); }; paint(); const tab = c.button.sm({ label: "Auto-hide", tone: "neutral", pressed: on, title: "Show the timeline only while the pointer is on it", onClick: () => { on = !on; paint(); tab.setPressed && tab.setPressed(on); try { on ? localStorage.setItem(KEY, "1") : localStorage.removeItem(KEY); } catch { /* lasts until reload */ } } }); host.append(tab.node); - // A drag in progress holds it open. - const open = () => root.classList.add("fp-peek-open"), shut = () => root.classList.remove("fp-peek-open"); - const out = (e) => { if (!e.relatedTarget) shut(); }; - document.addEventListener("dragenter", open); document.addEventListener("dragleave", out); - document.addEventListener("drop", shut); document.addEventListener("dragend", shut); + const open = () => { clearTimeout(timer); timer = null; root.classList.add("fp-peek-open"); }; + const shut = () => { clearTimeout(timer); timer = null; if (!dragging) root.classList.remove("fp-peek-open"); }; + const over = (e) => { + if (!on) return; + const t = e.target, z = zone(); + if (!t || !t.closest || !z) return; + if (z.contains(t) && !t.closest(".cx-panel-head")) { if (!timer && !root.classList.contains("fp-peek-open")) timer = setTimeout(open, BEAT_MS); } + else if (t.closest(".fp-main, .cx-frame-head")) shut(); // elsewhere on the frame; a menu or popover over it leaves it be + }; + const left = (e) => { if (!e.relatedTarget) shut(); }; // out of the window + // a drag opens it at once and holds it open until the drop + const dragIn = () => { dragging = true; open(); }; + const dragOut = (e) => { if (!e.relatedTarget) { dragging = false; shut(); } }; + const dragDone = () => { dragging = false; shut(); }; + document.addEventListener("pointerover", over); document.addEventListener("pointerout", left); + document.addEventListener("dragenter", dragIn); document.addEventListener("dragleave", dragOut); + document.addEventListener("drop", dragDone); document.addEventListener("dragend", dragDone); return () => { - document.removeEventListener("dragenter", open); document.removeEventListener("dragleave", out); - document.removeEventListener("drop", shut); document.removeEventListener("dragend", shut); - root.classList.remove("fp-peek", "fp-peek-open"); tab.node.remove(); + document.removeEventListener("pointerover", over); document.removeEventListener("pointerout", left); + document.removeEventListener("dragenter", dragIn); document.removeEventListener("dragleave", dragOut); + document.removeEventListener("drop", dragDone); document.removeEventListener("dragend", dragDone); + clearTimeout(timer); if (sizer) sizer.disconnect(); + root.classList.remove("fp-peek", "fp-peek-open"); tab.node.remove(); strip.remove(); }; }, }; diff --git a/app/ui/features/preview/index.js b/app/ui/features/preview/index.js index b55bdf95..01929474 100644 --- a/app/ui/features/preview/index.js +++ b/app/ui/features/preview/index.js @@ -18,6 +18,13 @@ export function sourceOf(sc, open, dur) { return { url: `/funpack/api/m/render/projects/${encodeURIComponent(open.id)}/preview-segment/${encodeURIComponent(sc.id)}?${q}`, from: 0 }; } +/** The clip on screen at `t`: a clip owns [start, end), and at the very end of the cut the last one keeps its final frame. */ +export function segmentAt(open, t) { + const all = segments(open).filter((s) => s.kind === "scene"); + return all.find((s) => t >= s.start - 1e-6 && t < s.start + s.dur - 1e-6) + || (t > 0 && t >= totalSeconds(open) - 1e-6 ? all.find((s) => Math.abs(s.start + s.dur - t) < 1e-3) : null) || null; +} + export default { id: "preview", mount: "preview", @@ -48,7 +55,7 @@ export default { let playing = false, shown = null, seg = null, from = 0, quiet = false; const open = () => p.project; - const segAt = (t) => (open() ? segments(open()).find((s) => s.kind === "scene" && t >= s.start - 1e-6 && t < s.start + s.dur - 1e-6) : null); + const segAt = (t) => (open() ? segmentAt(open(), t) : null); const total = () => (open() ? totalSeconds(open()) : 0); function show(next) { diff --git a/app/ui/features/preview/player.test.js b/app/ui/features/preview/player.test.js index b17ec65e..b0fe97ab 100644 --- a/app/ui/features/preview/player.test.js +++ b/app/ui/features/preview/player.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { timecode, sourceOf } from "./index.js"; +import { timecode, sourceOf, segmentAt } from "./index.js"; test("timecode is hours:minutes:seconds:frames at the project rate", () => { assert.equal(timecode(0, 25), "00:00:00:00"); @@ -18,3 +18,11 @@ test("a clip's picture: a generated clip comes from the server's trimmed segment assert.equal(sourceOf({ id: "b" }, open, 3), null); assert.equal(sourceOf({ id: "v", source: { type: "video" } }, open, 3), null); }); + +test("at the very end of the cut the last clip stays on screen instead of the empty monitor", () => { + const open = { id: "p", frame_rate: 25, num_frames_per_scene: 50, scenes: [{ id: "a", text: "x" }, { id: "b", text: "y" }] }; // 2 s each + assert.equal(segmentAt(open, 0).id, "a"); + assert.equal(segmentAt(open, 2).id, "b", "a boundary belongs to the clip that starts there"); + assert.equal(segmentAt(open, 4).id, "b", "playback stopped at the end: the last frame stays"); + assert.equal(segmentAt(open, 5), null); +}); diff --git a/app/ui/frame.css b/app/ui/frame.css index fa6bbdc6..ce3e5434 100644 --- a/app/ui/frame.css +++ b/app/ui/frame.css @@ -36,14 +36,16 @@ .fp-hide-assets.fp-hide-preview .fp-row > :nth-child(3) { flex: 1 1 auto; } .fp-split { border: 0 !important; } -/* Auto-hide timeline: a strip the height of its title bar, full height under the pointer or during a drag. */ -:root.fp-peek .fp-main { position: relative; padding-block-end: 34px; } -:root.fp-peek .fp-main > :nth-child(2) { position: absolute; inset-inline: 0; bottom: 0; block-size: 34px; min-block-size: 0; z-index: 20; overflow: hidden; } +/* Auto-hide timeline: its title bar and a strip; full height once the pointer rests on the strip or the timeline, or during a drag. */ +:root.fp-peek .fp-main { position: relative; padding-block-end: calc(var(--peek-h, 64px) + var(--pgap)); } +:root.fp-peek .fp-main > :nth-child(2) { position: absolute; inset-inline: 0; bottom: 0; block-size: auto; min-block-size: 0; z-index: 20; overflow: hidden; } :root.fp-peek .fp-main > :nth-child(2) .cx-panel-body { display: none; } -:root.fp-peek .fp-main > :nth-child(2):hover, :root.fp-peek.fp-peek-open .fp-main > :nth-child(2) { block-size: var(--timeline-h); overflow: visible; } -:root.fp-peek .fp-main > :nth-child(2):hover .cx-panel-body, :root.fp-peek.fp-peek-open .fp-main > :nth-child(2) .cx-panel-body { display: block; } +.fp-peek-strip { display: none; } +:root.fp-peek .fp-peek-strip { display: flex; align-items: center; justify-content: center; block-size: 26px; flex: none; font-size: var(--fs-6); color: var(--faint); user-select: none; } +:root.fp-peek .fp-peek-strip:hover { color: var(--text); } +:root.fp-peek.fp-peek-open .fp-peek-strip { display: none; } :root.fp-peek .fp-split-bottom { display: none; } /* Welcome: the maintenance actions as a 2x2 of tiles */ diff --git a/core/routes.py b/core/routes.py index 11440ae7..17af0d1c 100644 --- a/core/routes.py +++ b/core/routes.py @@ -863,8 +863,15 @@ def register(routes, prefix=None): # FileResponse, not a hand-read body: it answers Range requests on its # own, which is what lets a