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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-06 06:44:27 +03:00
co-authored by Claude Opus 5.5
parent 3db07afdcb
commit f4e89d4433
12 changed files with 140 additions and 26 deletions
+4 -1
View File
@@ -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); }
+5 -1
View File
@@ -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,
+19
View File
@@ -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; }
});
+33
View File
@@ -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 = `<div class="fp-main"><div class="fp-row"></div><section><header class="cx-panel-head"><button id="gen">Generate</button><span id="status"></span></header><div class="cx-panel-body"><div id="lane"></div></div></section></div>`;
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();
});
+37 -13
View File
@@ -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();
};
},
};
+8 -1
View File
@@ -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) {
+9 -1
View File
@@ -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);
});
+7 -5
View File
@@ -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 */
+8 -1
View File
@@ -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 <video> scrub an imported clip instead of
# re-downloading it whole on every seek.
item = media.get(req.match_info["mid"]) or {}
name = str(item.get("name") or path.name)
if not name.lower().endswith(path.suffix.lower()):
name += path.suffix
from urllib.parse import quote
return web.FileResponse(path, headers={
"Content-Type": media.content_type(req.match_info["mid"])})
"Content-Type": media.content_type(req.match_info["mid"]),
# "Save video as" names the file from this; the route itself ends in /file
"Content-Disposition": f"inline; filename*=UTF-8''{quote(name)}"})
@routes.get(P + "/api/media/{mid}/thumb")
async def _media_thumb(req):
+2 -1
View File
@@ -195,7 +195,7 @@ def _request(port, method, path, body=None, raw=None, content_type="application/
try:
return status, json.loads(rest.decode() or "{}")
except ValueError:
return status, {"raw": rest}
return status, {"raw": rest, "head": header.decode("latin-1")}
def _upload(port, filename, data, field="file"):
@@ -220,6 +220,7 @@ def test_upload_list_file_delete(server):
status, body = _request(server, "GET", f"/funpack/api/media/{mid}/file")
assert status == 200 and body["raw"] == b"fake png bytes"
assert "filename*=UTF-8''ref.png" in body["head"], "Save as names it after the upload, not 'file'"
assert _request(server, "DELETE", f"/funpack/api/media/{mid}")[0] == 200
assert _request(server, "GET", f"/funpack/api/media/{mid}/file")[0] == 404
+7 -2
View File
@@ -288,9 +288,14 @@ async def segment(request, project) -> "web.StreamResponse":
key = (f"{project.id}:{scene_id}:{clip['filename']}:{clip['subfolder']}:{clip['type']}:"
f"{clip['in']}:{clip['dur']}:{'rev' if reverse else ''}:{sig[0]}:{sig[1]}")
async with _lock(key):
# the browser's own "Save video as" names the file from this: the render's unique name, not the route's
stem = os.path.splitext(os.path.basename(str(clip["filename"])))[0] or "clip"
cut = f"_from{float(clip.get('in') or 0):g}s".replace(".", "p") if float(clip.get("in") or 0) else ""
headers = {"Cache-Control": "private, max-age=3600",
"Content-Disposition": f'inline; filename="{projects.safe_part(stem + cut + ("_rev" if reverse else ""))}.mp4"'}
hit = _segments.get(key)
if hit and os.path.isfile(hit[1]):
return web.FileResponse(hit[1], headers={"Cache-Control": "private, max-age=3600"})
return web.FileResponse(hit[1], headers=headers)
try:
out = files.temp_file(f"funpack_preview_{projects.safe_part(project.id)[:8]}_"
f"{projects.safe_part(scene_id)}_{int(time.time() * 1000)}.mp4")
@@ -304,4 +309,4 @@ async def segment(request, project) -> "web.StreamResponse":
except files.ClipError as exc: # ffmpeg itself failed: not "still being written", so not a retry
return web.json_response({"detail": str(exc)}, status=502)
_segments.put(key, (None, out))
return web.FileResponse(out, headers={"Cache-Control": "private, max-age=3600"})
return web.FileResponse(out, headers=headers)
@@ -117,6 +117,7 @@ def test_a_scene_segment_is_exactly_the_window_the_export_would_use(comfy, serve
proj = _project(comfy, source_in=0.5, frames=50, frames_mode="timeline") # 2 s at 25 fps, from 0.5 + 0.5
status, headers, body = get(server, f"{BASE}/projects/{proj.id}/preview-segment/s1")
assert status == 200 and headers["Cache-Control"] == "private, max-age=3600"
assert headers["Content-Disposition"] == 'inline; filename="a_from1s.mp4"', "Save video as: named after its render"
(comfy.root / "seg.mp4").write_bytes(body)
assert files.moov_position(str(comfy.root / "seg.mp4")) == "front"
assert probe(comfy.root / "seg.mp4")[0] == pytest.approx(2.0, abs=0.2)