diff --git a/conditioning.py b/conditioning.py
index db626656..468bf4e6 100644
--- a/conditioning.py
+++ b/conditioning.py
@@ -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)
diff --git a/movie_editor/frontend/appearance_settings.js b/movie_editor/frontend/appearance_settings.js
index 7a6b589f..95ef5315 100644
--- a/movie_editor/frontend/appearance_settings.js
+++ b/movie_editor/frontend/appearance_settings.js
@@ -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: '',
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 };
})();
diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html
index 411e4716..1c7755c5 100644
--- a/movie_editor/frontend/index.html
+++ b/movie_editor/frontend/index.html
@@ -7,6 +7,9 @@
+
+
diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css
index bded5a0c..ddffc677 100644
--- a/movie_editor/frontend/styles.css
+++ b/movie_editor/frontend/styles.css
@@ -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;
diff --git a/movie_editor/frontend/timeline_peek.js b/movie_editor/frontend/timeline_peek.js
new file mode 100644
index 00000000..6a2c6e3b
--- /dev/null
+++ b/movie_editor/frontend/timeline_peek.js
@@ -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 };
+}));
diff --git a/movie_editor/frontend/timeline_peek.test.js b/movie_editor/frontend/timeline_peek.test.js
new file mode 100644
index 00000000..3897b147
--- /dev/null
+++ b/movie_editor/frontend/timeline_peek.test.js
@@ -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);
+});
diff --git a/tests/test_stage_timing.py b/tests/test_stage_timing.py
index 3d7e6038..cf660589 100644
--- a/tests/test_stage_timing.py
+++ b/tests/test_stage_timing.py
@@ -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
diff --git a/tests/test_value_function_device.py b/tests/test_value_function_device.py
index 71664dc6..e8a5cd74 100644
--- a/tests/test_value_function_device.py
+++ b/tests/test_value_function_device.py
@@ -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
diff --git a/value_function.py b/value_function.py
index fbd56837..4f18f0d6 100644
--- a/value_function.py
+++ b/value_function.py
@@ -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