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:
co-authored by
Claude Opus 5
parent
a689aaa87d
commit
8cdee7072f
+3
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user