feat(editor): Settings > Appearance gets "Show timeline on hover"

Off by default. On, the timeline sits as a strip — its header, so the title,
transport and Composer stay reachable — and opens to full height while the
pointer is on it or something inside it has focus.

It also opens for the whole of a DRAG. During one the pointer is in drag mode
and a strip that stays shut is a drop target you cannot hit, so dragenter holds
it open and drop/dragend closes it. The tracker counts depth rather than
holding a boolean: dragleave for the element being left fires AFTER dragenter
for the one being entered, so a flag closes the strip mid-drag as the pointer
crosses from one clip to the next.

The strip height is MEASURED from the header rather than fixed: .zone-head
wraps when the zone is narrow (34px min-height, 138px on a normal window), so
any constant clips the very controls the strip exists to keep visible. It is
re-measured on resize. flex: 0 0 auto matters — .zone is a shrinkable column
child, and a height with min-height: 0 let the workspace squeeze the strip to
1px. The body collapses rather than being display:none so the lanes are not
torn down and rebuilt on every open.

A browser preference like the colour scheme, not a project one, and registered
in the same Settings section for that reason.

Also in this commit:
- vf: train wherever there is a GPU, not only when the sample sits on one. The
  x0 value function's snapshot loads with map_location="cpu", so keying on the
  sample left it training on the CPU — the run that named it measured
  "x0 vf: train 10.1s" against 46ms for the same net locally.
- The stage line counted nested sub-stages twice and reported 153% of a run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-25 14:32:11 +03:00
co-authored by Claude Opus 5
parent a689aaa87d
commit 8cdee7072f
9 changed files with 325 additions and 4 deletions
+3 -1
View File
@@ -4309,7 +4309,9 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
for n, v in rows]
head = "stages"
if total_seconds:
named = sum(v["seconds"] for _, v in rows)
# Only top-level stages count toward the share. A sub-stage (indented name) is
# measured INSIDE its parent, so adding both reported 153% of a 31.7s run.
named = sum(v["seconds"] for n, v in rows if not n.startswith(" "))
head += f" ({named / max(total_seconds, 1e-6) * 100:.0f}% of {total_seconds:.1f}s)"
return head + ": " + " | ".join(parts)
+35 -1
View File
@@ -45,6 +45,26 @@
return { grid, paint };
}
// Same reasoning as the colour scheme: how much room the timeline should take is a
// property of the screen you are at, not of the project, so it belongs here rather than
// in the settings that travel with one.
function timelineRow() {
const P = window.TimelinePeek;
const row = el("div", "es-row");
const lbl = el("label", "chk es-toggle");
const cb = el("input");
cb.type = "checkbox";
cb.checked = !!(P && P.get());
cb.onchange = () => P && P.set(cb.checked);
lbl.append(cb, el("span", null, "Show timeline on hover"));
row.append(lbl);
row.append(el("div", "es-hint",
"The timeline sits as a strip — title, transport and Composer stay visible — and opens "
+ "to full height while the pointer is on it. It also opens while you drag a file over "
+ "it, so dropping onto a lane works the same as when it is pinned open."));
return row;
}
function mount(body) {
const wrap = el("div", "sw-stack");
wrap.append(el("div", "sw-rows-label", "Colour scheme"));
@@ -53,6 +73,8 @@
wrap.append(el("div", "sw-hint",
"Stored in this browser, not in the project — a rented instance you open the editor on "
+ "starts on Dark until you set it there too."));
wrap.append(el("div", "sw-rows-label", "Timeline"));
wrap.append(timelineRow());
body.append(wrap);
const off = T.onChange(() => paint(T.get()));
return () => off();
@@ -61,12 +83,24 @@
window.SettingsWindow.register({
id: "appearance", group: "", order: 0, title: "Appearance",
subtitle: "Light, dark, or follow the system.",
keywords: "theme colour color scheme light dark auto appearance contrast palette",
keywords: "theme colour color scheme light dark auto appearance contrast palette "
+ "timeline hover peek collapse strip auto-hide autohide",
iconBg: "linear-gradient(180deg,#8fd0ff,#3a7fd5)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13"><path d="M8 1.6a6.4 6.4 0 1 0 0 12.8V1.6z" fill="#fff"/><circle cx="8" cy="8" r="6.4" fill="none" stroke="#fff" stroke-width="1.3"/></svg>',
mount,
});
// Applied at load, before the first paint of the layout, so a timeline that should start
// as a strip is never briefly full height. The drag listeners need the zone to exist.
if (window.TimelinePeek) {
window.TimelinePeek.apply(window.TimelinePeek.get());
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => window.TimelinePeek.install());
} else {
window.TimelinePeek.install();
}
}
// The onboarding wizard's theme step is this same picker.
window.AppearanceSettings = { buildPicker };
})();
+3
View File
@@ -7,6 +7,9 @@
<!-- Before the stylesheet on purpose: data-theme has to be on <html> for the
first paint, or a light-theme user gets a dark flash on every load. -->
<script src="theme.js"></script>
<!-- Beside the theme for the same reason: both are browser preferences that decide
what the first paint looks like, so neither may load after the layout does. -->
<script src="timeline_peek.js"></script>
<script src="ui_mode.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+55 -1
View File
@@ -424,6 +424,60 @@ a.brand:hover .brand-sub { color: var(--accent-hi); }
.inspector { width: var(--properties-w); flex: 0 0 auto; border-left: 1px solid var(--line); }
.timeline { height: var(--timeline-h); min-height: 200px; border-top: 1px solid var(--line); }
/* Timeline peek (Settings > Appearance): the timeline sits as a strip showing only its
header — title, transport, Composer — and opens to its full height when pointed at.
It grows UPWARD because it is the last row of a column, so the strip stays under the
cursor as it opens and the pointer never falls out of what it just opened.
.peek-drag-open is held by timeline_peek.js for the length of a drag: during one the
pointer is in drag mode and a strip that stays shut is a target you cannot hit. */
:root[data-timeline-peek="1"] .zone.timeline {
/* flex: 0 0 auto is the load-bearing part. .zone is a shrinkable column child, so a
height with min-height: 0 let the workspace squeeze the strip to 1px. It must not
shrink; the strip is exactly as tall as its own header.
--timeline-peek-h is measured from that header by timeline_peek.js rather than guessed:
.zone-head WRAPS when the zone is narrow (measured at 138px on a normal window, not the
34px min-height), so any fixed number here clips the transport and Composer it exists
to keep visible. The fallback only covers the moment before the first measurement. */
height: var(--timeline-peek-h, 34px);
min-height: 0;
flex: 0 0 auto;
transition: height .14s ease;
}
:root[data-timeline-peek="1"] .zone.timeline:hover,
:root[data-timeline-peek="1"] .zone.timeline:focus-within,
:root[data-timeline-peek="1"] .zone.timeline.peek-drag-open {
height: var(--timeline-h);
min-height: 200px;
}
/* The body collapses rather than disappearing: display:none would tear down and rebuild the
lanes on every open, and the timeline measures itself to lay them out. */
:root[data-timeline-peek="1"] .zone.timeline .zone-body {
flex: 0 0 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
transition: opacity .1s ease;
}
:root[data-timeline-peek="1"] .zone.timeline:hover .zone-body,
:root[data-timeline-peek="1"] .zone.timeline:focus-within .zone-body,
:root[data-timeline-peek="1"] .zone.timeline.peek-drag-open .zone-body {
flex: 1;
overflow: auto;
opacity: 1;
pointer-events: auto;
}
/* Dragging the splitter would fight the strip for the height, so it goes with it. The
preference IS the height decision while it is on. */
:root[data-timeline-peek="1"] #split-bottom { display: none; }
/* Anything anchored to the timeline's height follows the strip, not the open height. */
:root[data-timeline-peek="1"] { --timeline-anchor: var(--timeline-peek-h, 34px); }
/* Reduced motion: open and close, just without the slide. */
@media (prefers-reduced-motion: reduce) {
:root[data-timeline-peek="1"] .zone.timeline,
:root[data-timeline-peek="1"] .zone.timeline .zone-body { transition: none; }
}
}
/* degrade gracefully as columns are toggled off — preview takes the slack, else properties, else media */
.main-row[data-preview="0"][data-settings="1"] .inspector { flex: 1; width: auto; }
.main-row[data-preview="0"][data-settings="0"] .media,
@@ -1882,7 +1936,7 @@ body.tour-mode { overflow: hidden; }
.tour-banner-label { font-family: var(--font-display); font-weight: 700; color: var(--accent-hi); }
.tour-banner-note { color: var(--muted); font-size: 11px; }
.tour-toast {
position: fixed; bottom: calc(var(--timeline-h) + 20px); left: 50%; transform: translateX(-50%) translateY(8px);
position: fixed; bottom: calc(var(--timeline-anchor, var(--timeline-h)) + 20px); left: 50%; transform: translateX(-50%) translateY(8px);
z-index: 200001; pointer-events: none; opacity: 0; transition: opacity .2s, transform .2s;
padding: 10px 14px; border-radius: 10px; max-width: min(480px, 92vw);
background: var(--accent-dim-3); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); color: var(--text); font-size: 12px;
+134
View File
@@ -0,0 +1,134 @@
// Timeline peek: collapse the timeline to a strip that opens when you point at it.
//
// A browser preference, not a project one — how much of the screen you want the timeline to
// take is a property of the screen you are sitting at, which is why it lives beside the
// colour scheme rather than in settings that travel with a project.
//
// The hover half is CSS (:hover on the zone). The part that needs script is the DRAG: while
// a file is being dragged from the media browser or the desktop, the pointer is in drag mode
// and a strip that stays shut is a target you cannot hit. dragenter opens it and holds it
// open for the whole drag, so dropping onto a lane works exactly as it does when the timeline
// is pinned open.
(function (root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api; // node --test
if (root) root.TimelinePeek = api;
}(typeof window !== "undefined" ? window : null, function () {
const LS_KEY = "funpack_timeline_peek";
const ATTR = "data-timeline-peek";
const DRAG_CLASS = "peek-drag-open";
function stored() {
try {
return localStorage.getItem(LS_KEY) === "1";
} catch (_) {
return false; // private mode / storage denied: the plain layout
}
}
function persist(on) {
try {
if (on) localStorage.setItem(LS_KEY, "1");
else localStorage.removeItem(LS_KEY);
} catch (_) { /* the attribute below still applies for this session */ }
}
// The drag payload decides nothing about whether to open: a drag that turns out to be
// something the timeline cannot take still needs the strip open to say so. Anything with a
// dataTransfer counts — files from the desktop, clips from the media browser, a lane's own
// reorder — which is every drag that could be aimed at a lane.
function isDrag(event) {
return !!(event && event.dataTransfer);
}
function apply(on, doc) {
const d = doc || (typeof document !== "undefined" ? document : null);
if (!d || !d.documentElement) return !!on;
if (on) d.documentElement.setAttribute(ATTR, "1");
else {
d.documentElement.removeAttribute(ATTR);
// Leaving the preference while a drag happened to be open would strand the class on
// the zone, and it only means anything alongside the attribute.
const zone = d.getElementById && d.getElementById("timeline-zone");
if (zone) zone.classList.remove(DRAG_CLASS);
}
return !!on;
}
function get() { return stored(); }
function set(on) {
persist(!!on);
const result = apply(!!on);
if (on) measure(); // turning it on is the first time the strip height matters
return result;
}
// Depth counting, not a boolean: dragging across a lane fires dragleave for the element
// being left AFTER dragenter for the one being entered, so a plain flag closes the strip
// mid-drag as the pointer crosses from one clip to the next.
function makeDragTracker() {
let depth = 0;
return {
enter() { depth += 1; return depth > 0; },
leave() { depth = Math.max(0, depth - 1); return depth > 0; },
end() { depth = 0; return false; },
get depth() { return depth; },
};
}
// The strip is exactly its own header, measured rather than guessed: .zone-head wraps when
// the zone is narrow (34px min-height, 138px in practice on a normal window), and a fixed
// strip height clips the transport and Composer the strip exists to keep visible.
function measure(doc) {
const d = doc || (typeof document !== "undefined" ? document : null);
if (!d || !d.getElementById) return 0;
const zone = d.getElementById("timeline-zone");
const head = zone && zone.querySelector(".zone-head");
if (!head || !head.getBoundingClientRect) return 0;
const h = Math.ceil(head.getBoundingClientRect().height);
if (h > 0) d.documentElement.style.setProperty("--timeline-peek-h", h + "px");
return h;
}
function install(doc) {
const d = doc || document;
const zone = d.getElementById("timeline-zone");
if (!zone) return () => {};
const track = makeDragTracker();
// Measured now and on resize: the header re-wraps as the window changes width, and a
// stale strip height would either clip it or leave a gap under it.
measure(d);
const onResize = () => measure(d);
if (typeof window !== "undefined" && window.addEventListener) {
window.addEventListener("resize", onResize);
}
const paint = (open) => zone.classList.toggle(DRAG_CLASS, !!open);
const onEnter = (e) => { if (isDrag(e)) paint(track.enter()); };
const onLeave = (e) => { if (isDrag(e)) paint(track.leave()); };
const onEnd = () => paint(track.end());
zone.addEventListener("dragenter", onEnter);
zone.addEventListener("dragleave", onLeave);
zone.addEventListener("drop", onEnd);
// A drag abandoned outside the window fires neither drop nor dragleave on the zone, and
// the strip would stay open until the next pointer move. dragend on the document covers
// the drag that started here; the window's own dragleave covers one that came from
// outside it.
d.addEventListener("dragend", onEnd);
d.addEventListener("drop", onEnd);
return () => {
zone.removeEventListener("dragenter", onEnter);
zone.removeEventListener("dragleave", onLeave);
zone.removeEventListener("drop", onEnd);
d.removeEventListener("dragend", onEnd);
d.removeEventListener("drop", onEnd);
if (typeof window !== "undefined" && window.removeEventListener) {
window.removeEventListener("resize", onResize);
}
};
}
return { get, set, apply, install, measure, isDrag, makeDragTracker, LS_KEY, ATTR, DRAG_CLASS };
}));
@@ -0,0 +1,58 @@
// Timeline peek (timeline_peek.js). Run: node --test movie_editor/frontend/timeline_peek.test.js
//
// The hover half is CSS and not testable here. What is testable — and what actually breaks —
// is the drag half: while a file is dragged, the pointer is in drag mode and a strip that
// closes underneath it is a drop target you cannot hit.
const test = require("node:test");
const assert = require("node:assert");
const P = require("./timeline_peek.js");
test("anything carrying a drag payload opens the strip", () => {
assert.equal(P.isDrag({ dataTransfer: {} }), true);
});
test("a plain pointer event does not", () => {
assert.equal(P.isDrag({}), false);
assert.equal(P.isDrag(null), false);
});
test("a drag that turns out to be unusable still opens it", () => {
// The strip has to open before anything can tell you the drop was refused.
assert.equal(P.isDrag({ dataTransfer: { types: ["text/plain"] } }), true);
});
test("crossing from one clip to the next keeps it open", () => {
// dragleave for the element being left fires AFTER dragenter for the one being entered,
// so a plain boolean closes the strip mid-drag. This is why the tracker counts depth.
const t = P.makeDragTracker();
assert.equal(t.enter(), true); // into the zone
assert.equal(t.enter(), true); // into a clip inside it
assert.equal(t.leave(), true); // out of the first clip — still inside the zone
assert.equal(t.leave(), false); // out of the zone entirely
});
test("leaving the window closes it once, not into negative depth", () => {
const t = P.makeDragTracker();
t.enter();
assert.equal(t.leave(), false);
assert.equal(t.leave(), false);
assert.equal(t.depth, 0); // a stray leave must not owe an extra enter
});
test("a dropped or abandoned drag closes it whatever the depth", () => {
const t = P.makeDragTracker();
t.enter(); t.enter(); t.enter();
assert.equal(t.end(), false);
assert.equal(t.depth, 0);
});
test("the preference is off unless it was explicitly turned on", () => {
// No localStorage in node: stored() swallows the failure and reports the plain layout.
assert.equal(P.get(), false);
});
test("apply is safe without a document", () => {
assert.equal(P.apply(true, null), true);
assert.equal(P.apply(false, null), false);
});
+11
View File
@@ -67,3 +67,14 @@ def test_a_raising_stage_is_still_timed(refiner):
with refiner._v2_stage("value function"):
raise ValueError("boom")
assert refiner._v2_stage_times["value function"]["calls"] == 1
def test_a_sub_stage_is_not_counted_twice(refiner):
"""A sub-stage runs INSIDE its parent, so adding both reported 153% of the run."""
for name, secs in (("value function", 10.0), (" vf: train", 9.8)):
with refiner._v2_stage(name):
pass
refiner._v2_stage_times[name]["seconds"] = secs
line = refiner._v2_stage_status(total_seconds=20.0)
assert "50% of 20.0s" in line
assert "vf: train 9.8s" in line # still shown, just not counted again
+16
View File
@@ -74,3 +74,19 @@ def test_the_latent_value_function_inherits_the_same_path():
vf.buffer_r.append(float(i % 2))
vf.train_on(torch.randn(1, 1, 4096), 0.7)
assert vf.n_trained == 1
def test_a_cpu_snapshot_still_goes_to_the_gpu(monkeypatch):
"""The x0 value function's sample is loaded with map_location="cpu". Keying the device on
the sample left it training on the CPU — 10.1s a run against 46ms for the same net."""
import value_function as V
monkeypatch.setattr(V.torch.cuda, "is_available", lambda: True)
home = torch.device("cpu")
assert V.OnlineValueFunction._training_device(torch.zeros(4), home) == torch.device("cuda")
def test_without_a_gpu_it_stays_home(monkeypatch):
import value_function as V
monkeypatch.setattr(V.torch.cuda, "is_available", lambda: False)
home = torch.device("cpu")
assert V.OnlineValueFunction._training_device(torch.zeros(4), home) is home
+10 -1
View File
@@ -139,10 +139,19 @@ class OnlineValueFunction(nn.Module):
@staticmethod
def _training_device(conditioning, home):
"""Where to run the twenty training steps: the sample's GPU if it is on one."""
"""Where to run the twenty training steps: a GPU if there is one at all.
Not "the sample's device". The x0 value function's sample is a snapshot loaded with
map_location="cpu", so keying on the sample left it training on the CPU — measured at
10.1s a run against 46ms for the same net locally. What has to move is the ensemble
and one stacked batch out of the buffer, and both are small; where the sample happens
to have been read from says nothing about where the arithmetic belongs.
"""
try:
if isinstance(conditioning, torch.Tensor) and conditioning.is_cuda:
return conditioning.device
if torch.cuda.is_available():
return torch.device("cuda")
except Exception: # noqa: BLE001
pass
return home