Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8210925
perf(studio): coalesce timeline zoom to one write per frame and zoom …
miguel-heygen Oct 6, 2026
9230c8d
perf(studio): coalesce timeline zoom to one write per frame and zoom …
miguel-heygen Oct 6, 2026
e09471b
feat(studio): zoom-to-range reports done or cancelled and is not a pe…
miguel-heygen Oct 6, 2026
1af85d3
perf(studio): place clips in percent of a one-second layer so zoom re…
miguel-heygen Oct 6, 2026
7b180b3
perf(studio): a zoom step re-renders only clips whose drawing it changes
miguel-heygen Oct 6, 2026
48fb920
refactor(studio): move at-rest waveform and thumbnail work to its own…
miguel-heygen Oct 6, 2026
ba62650
perf(studio): draw waveforms and decode thumbnails when the timeline …
miguel-heygen Oct 6, 2026
d688e40
test(studio): fade handles measure the clip only while they show
miguel-heygen Oct 6, 2026
1efc13c
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
14297e5
test(studio): waveforms draw their visible span and redraw at rest
miguel-heygen Oct 6, 2026
7c485b4
feat(studio): export currentTimelineRange, the inverse of zoomTimelin…
miguel-heygen Oct 6, 2026
debe999
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
94b4917
perf(studio): zoom gestures scale the drawn timeline and lay it out o…
miguel-heygen Oct 6, 2026
c0e78fc
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
560644c
perf(studio): rebase a zoom preview only past 0.5x or 4x
miguel-heygen Oct 6, 2026
44bad1f
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
2ad3661
fix(studio): keep the zoom readout, edges and Fit right during a preview
miguel-heygen Oct 6, 2026
170ab92
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
5cb8785
fix(studio): keep a pending zoom when the timeline re-registers its view
miguel-heygen Oct 6, 2026
4420fcf
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
7cde9fb
refactor(studio): share the zoom geometry helpers the timeline alread…
miguel-heygen Oct 6, 2026
8417897
fix(studio): keep a zoom preview through scrolls and past the content…
miguel-heygen Oct 6, 2026
1e1c086
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
d6f41bb
fix(studio): keep a moved audio clip's waveform drawn where it shows
miguel-heygen Oct 6, 2026
582652e
perf(studio): lay a zoom-out step out once, at its start
miguel-heygen Oct 6, 2026
518881f
fix(studio): keep previewing a zoom at the content's end and over new…
miguel-heygen Oct 6, 2026
f2922e7
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
2593344
fix(studio): redraw a waveform when its clip's width changes or it moves
miguel-heygen Oct 6, 2026
c28f051
perf(studio): skip the layout when a zoom preview ends on its own scale
miguel-heygen Oct 6, 2026
d077635
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
92f96f2
fix(studio): measure filmstrips and waveforms after a zoom preview ends
miguel-heygen Oct 6, 2026
bee6ab5
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
0cb4d30
perf(studio): watch a waveform's undrawn ends only when it has some
miguel-heygen Oct 6, 2026
bec0f3f
fix(studio): measure strips each time a zoom preview is laid out
miguel-heygen Oct 6, 2026
27e104f
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
b91b342
docs(studio): keep the zoom-to-range doc comment within four lines
miguel-heygen Oct 6, 2026
4953158
Merge branch 'perf/studio-timeline-zoom' into perf/studio-timeline-zo…
miguel-heygen Oct 6, 2026
97966f5
docs(studio): keep the zoom files' comment share at or under main's
miguel-heygen Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion packages/studio/src/components/TimelineToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { makeSelection } from "../hooks/domSelectionTestHarness";
Expand All @@ -11,6 +11,7 @@ import { readStudioUiPreferences } from "../utils/studioUiPreferences";
import { dispatchPlainKey, type HotkeyCallbacks } from "../hooks/appHotkeysDispatch";
import { AudioMeterStrip } from "./nle/AudioMeterStrip";
import { TimelineToolbar } from "./TimelineToolbar";
import { registerTimelineZoomViewport } from "../player/components/timelineZoomInput";

vi.mock("../contexts/StudioContext", () => ({
useStudioShellContextOptional: () => ({
Expand Down Expand Up @@ -239,6 +240,81 @@ describe("TimelineToolbar snap key", () => {
});

describe("TimelineToolbar Fit", () => {
// A zoom lands on the next frame; these tests step frames instead of waiting for them.
beforeEach(() => {
vi.useFakeTimers({
toFake: [
"requestAnimationFrame",
"cancelAnimationFrame",
"setTimeout",
"clearTimeout",
"performance",
],
});
});
afterEach(() => {
vi.useRealTimers();
});

/** A mounted timeline for the zoom to preview on: 1080px wide, 32px of headers. */
function mountTimelineViewport() {
const scroll = document.createElement("div");
Object.defineProperties(scroll, {
clientWidth: { value: 1080 },
// At Fit the content is as wide as the view.
scrollWidth: { value: 1080 },
scrollLeft: { value: 0, writable: true },
});
usePlayerStore.setState({
zoomMode: "fit",
manualZoomPercent: 100,
timelineFitPps: 10,
timelinePps: 10,
});
return registerTimelineZoomViewport({ scroll, contentOrigin: 32 });
}

it("moves the slider and its readout with a zoom while it is previewed", () => {
const unregister = mountTimelineViewport();
const { host, root } = renderToolbar();
const slider = host.querySelector<HTMLInputElement>('input[aria-label="Timeline zoom"]')!;
const before = slider.value;
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(
slider,
String(Number(before) + 5),
);
slider.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() => vi.advanceTimersToNextFrame());
// Not laid out yet: the store still holds Fit, but the toolbar shows the zoom on screen.
expect(usePlayerStore.getState().zoomMode).toBe("fit");
expect(slider.value).not.toBe(before);
expect(host.querySelector('[aria-label="Timeline zoom level"]')?.textContent).not.toBe("Fit");
act(() => root.unmount());
act(() => unregister());
});

it("keeps Fit when it is picked while a zoom waits to be laid out", () => {
const unregister = mountTimelineViewport();
const { host, root } = renderToolbar();
act(() =>
host
.querySelector('button[aria-label="Zoom in"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
);

act(() =>
host
.querySelector('button[aria-label="Fit timeline to width"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
);
act(() => vi.advanceTimersByTime(1000));
expect(usePlayerStore.getState().zoomMode).toBe("fit");
act(() => root.unmount());
act(() => unregister());
});

it("shows Fit as a named icon and says whether fit is on", () => {
const { host, root } = renderToolbar();
const fit = () => host.querySelector('button[aria-label="Fit timeline to width"]');
Expand All @@ -251,6 +327,7 @@ describe("TimelineToolbar Fit", () => {
.querySelector('button[aria-label="Zoom in"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
);
act(() => vi.advanceTimersToNextFrame());
expect(fit()?.getAttribute("aria-pressed")).toBe("false");
act(() => root.unmount());
});
Expand All @@ -263,6 +340,7 @@ describe("TimelineToolbar Fit", () => {
.querySelector('button[aria-label="Zoom in"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
);
act(() => vi.advanceTimersToNextFrame());
expect(usePlayerStore.getState().userZoomCount).toBe(before + 1);
act(() => root.unmount());
});
Expand Down
43 changes: 14 additions & 29 deletions packages/studio/src/components/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import {
import { computeElementPercentage, playsNear } from "../hooks/gsapShared";
import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
import {
getNextTimelineZoomPercent,
getTimelineZoomPercent,
timelineZoomPercentToSlider,
timelineSliderToZoomPercent,
} from "../player/components/timelineZoom";
import { useTimelineZoom } from "../player/components/useTimelineZoom";
import {
useShownTimelineZoomPercent,
zoomTimelineStep,
} from "../player/components/timelineZoomInput";
import { usePlayerStore, type TimelineElement } from "../player";
import { Tooltip } from "./ui";
import { AudioMetersIcon } from "./icons/AudioMetersIcon";
Expand Down Expand Up @@ -170,17 +172,13 @@ export const TimelineToolbar = memo(function TimelineToolbar({
// Subscribe so the add-beat button reacts to playhead movement and analysis load.
const currentTime = usePlayerStore((s) => s.currentTime);
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
// Subscribe (not getState) so the split button enables/disables the moment
// the selection changes, not only on the next playhead tick.
// Subscribed, so the split button follows the selection, not only playhead ticks.
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const elements = usePlayerStore((s) => s.elements);
const timelineFitPps = usePlayerStore((s) => s.timelineFitPps);
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
const displayedTimelineZoomPercent = getTimelineZoomPercent(
zoomMode,
manualZoomPercent,
timelineFitPps,
);
const { zoomMode, setZoomMode, setManualZoomPercent } = useTimelineZoom();
const displayedTimelineZoomPercent = useShownTimelineZoomPercent();
const showsFit = zoomMode === "fit" && displayedTimelineZoomPercent === 100;
const {
state: keyframeState,
isMotionPath: keyframeIsMotionPath,
Expand Down Expand Up @@ -215,8 +213,6 @@ export const TimelineToolbar = memo(function TimelineToolbar({
}, []);

return (
// The "TIMELINE" label is dropped for CapCut-like density — the pane's
// position (tracks right below) makes it self-evident.
<div className="border-b border-neutral-800/60">
<div className="flex items-center justify-between px-2 py-0.5">
<div className="flex items-center gap-0.5">
Expand Down Expand Up @@ -510,12 +506,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
<button
type="button"
aria-label="Zoom out"
onClick={() => {
setZoomMode("manual");
setManualZoomPercent(
getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent, timelineFitPps),
);
}}
onClick={() => zoomTimelineStep("out")}
className={flatIdle}
>
<MagnifyingGlassMinus size={16} aria-hidden="true" />
Expand All @@ -528,12 +519,11 @@ export const TimelineToolbar = memo(function TimelineToolbar({
value={timelineZoomPercentToSlider(displayedTimelineZoomPercent, timelineFitPps)}
title={`${displayedTimelineZoomPercent}%`}
aria-label="Timeline zoom"
onChange={(e) => {
setZoomMode("manual");
onChange={(e) =>
setManualZoomPercent(
timelineSliderToZoomPercent(Number(e.target.value), timelineFitPps),
);
}}
)
}
// h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible
// track stays 2px and the thumb 10px, only the pointer box grows.
className="mx-1 h-6 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-text-0 [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
Expand All @@ -542,12 +532,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
<button
type="button"
aria-label="Zoom in"
onClick={() => {
setZoomMode("manual");
setManualZoomPercent(
getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent, timelineFitPps),
);
}}
onClick={() => zoomTimelineStep("in")}
className={flatIdle}
>
<MagnifyingGlassPlus size={16} aria-hidden="true" />
Expand All @@ -558,7 +543,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
className="ml-1 w-[38px] text-right font-mono text-[11px] tabular-nums text-neutral-500 select-none"
aria-label="Timeline zoom level"
>
{zoomMode === "fit" ? "Fit" : `${displayedTimelineZoomPercent}%`}
{showsFit ? "Fit" : `${displayedTimelineZoomPercent}%`}
</span>
</div>
</div>
Expand Down
65 changes: 65 additions & 0 deletions packages/studio/src/hooks/useThumbnailStripSize.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import { NearScreenIntersectionObserver } from "./intersectionObserverTestUtils"
import { MockResizeObserver, reportResize } from "./resizeObserverTestUtils";
import { useThumbnailStripSize } from "./useThumbnailStripSize";

const zoom = vi.hoisted(() => ({ previewing: false, listeners: new Set<() => void>() }));
vi.mock("../player/components/timelineZoomInput", () => ({
isTimelineZoomPreviewing: () => zoom.previewing,
subscribeTimelineZoomPreview: (listener: () => void) => {
zoom.listeners.add(listener);
return () => zoom.listeners.delete(listener);
},
}));

Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);

it("does not re-render the strip when the observer reports the size it already holds", () => {
Expand Down Expand Up @@ -48,6 +57,62 @@ it("does not re-render the strip when the observer reports the size it already h
}
});

it("measures a strip once a zoom preview ends, not while the preview scales it", () => {
const originalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
const host = document.createElement("div");
document.body.append(host);
let width = 300;
Object.defineProperty(host, "clientWidth", { configurable: true, get: () => width });
Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
const root = createRoot(host);
function Harness() {
const [size, ref] = useThumbnailStripSize();
return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
}
try {
act(() => root.render(<Harness />));
zoom.previewing = true;
width = 600;
act(() => reportResize(600, 40));
expect(host.textContent).toBe("300x40");
zoom.previewing = false;
act(() => zoom.listeners.forEach((listener) => listener()));
expect(host.textContent).toBe("600x40");
} finally {
zoom.previewing = false;
act(() => root.unmount());
host.remove();
globalThis.ResizeObserver = originalResizeObserver;
}
});

it("measures again each time a preview is laid out, though nothing was read during it", () => {
const originalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
const host = document.createElement("div");
document.body.append(host);
let width = 300;
Object.defineProperty(host, "clientWidth", { configurable: true, get: () => width });
Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
const root = createRoot(host);
function Harness() {
const [size, ref] = useThumbnailStripSize();
return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
}
try {
act(() => root.render(<Harness />));
// A zoom-out lays its target out mid-ease; the resize is reported only later.
width = 600;
act(() => zoom.listeners.forEach((listener) => listener()));
expect(host.textContent).toBe("600x40");
} finally {
act(() => root.unmount());
host.remove();
globalThis.ResizeObserver = originalResizeObserver;
}
});

describe("on a scroll", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalIntersectionObserver = globalThis.IntersectionObserver;
Expand Down
24 changes: 23 additions & 1 deletion packages/studio/src/hooks/useThumbnailStripSize.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { useCallback, useState } from "react";
import { flushSync } from "react-dom";
import {
isTimelineZoomPreviewing,
subscribeTimelineZoomPreview,
} from "../player/components/timelineZoomInput";

export interface StripSize {
width: number;
Expand All @@ -24,7 +28,6 @@ const GAP_WARNING: IntersectionObserverInit & { scrollMargin: string } = {
scrollMargin: `0px ${IN_VIEW_CHUNK_PX / 2}px`,
};

// A short strip keeps every tile, as before, so only long clips do work on a scroll.
const SHORT_STRIP_MAX_PX = 8 * IN_VIEW_CHUNK_PX;
const isShort = (width: number) => width <= SHORT_STRIP_MAX_PX;

Expand Down Expand Up @@ -62,6 +65,7 @@ let shared: {
resize: ResizeObserver;
presence: IntersectionObserver | null;
gaps: IntersectionObserver | null;
stopZoomWatch: () => void;
} | null = null;

const offsetOf = (scroller: Element | null) =>
Expand Down Expand Up @@ -97,8 +101,23 @@ const read = (target: Element, strip: Strip) => {
const commit = (updates: (readonly [Apply, Partial<StripSize>])[]) =>
flushSync(() => updates.forEach(([apply, patch]) => apply(patch)));

// A zoom preview scales the strips: reads wait, and each preview's layout re-measures them all.
const remeasureAfterPreview = () => {
if (isTimelineZoomPreviewing()) return;
commit(
[...strips].map(
([target, strip]) =>
[
strip.apply,
{ width: target.clientWidth, height: target.clientHeight, ...read(target, strip) },
] as const,
),
);
};

const refresh = () => {
frame = 0;
if (isTimelineZoomPreviewing()) return;
const offsetsNow = new Map<Element | null, { x: number; y: number }>();
const updates: (readonly [Apply, Partial<StripSize>])[] = [];
for (const [target, strip] of strips) {
Expand All @@ -122,6 +141,7 @@ const scheduleRefresh = () => {
};

const measure = (entries: { target: Element; size?: { width: number; height: number } }[]) =>
isTimelineZoomPreviewing() ||
commit(
entries.flatMap(({ target, size }) => {
const strip = strips.get(target);
Expand Down Expand Up @@ -153,6 +173,7 @@ function acquire() {
gaps: observeIntersections(
(entries) => entries.some((entry) => entry.isIntersecting) && scheduleRefresh(),
),
stopZoomWatch: subscribeTimelineZoomPreview(remeasureAfterPreview),
};
window.addEventListener("scroll", scheduleRefresh, { capture: true, passive: true });
}
Expand All @@ -164,6 +185,7 @@ function release() {
shared?.resize.disconnect();
shared?.presence?.disconnect();
shared?.gaps?.disconnect();
shared?.stopZoomWatch();
shared = null;
window.removeEventListener("scroll", scheduleRefresh, { capture: true });
cancelAnimationFrame(frame);
Expand Down
1 change: 1 addition & 0 deletions packages/studio/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export {
export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
export type { TimelineTheme } from "./player/components/timelineTheme";
export { TRACK_H } from "./player/components/timelineLayout";
export { currentTimelineRange, zoomTimelineToRange } from "./player/components/timelineZoomInput";
export type { TimelineTrackPadding } from "./player/components/timelineLayout";
export { displayTrackOrder } from "./player/components/useTimelineTrackDerivations";
export { fadeHandleBoxes } from "./player/components/timelineClipFadeGeometry";
Expand Down
Loading
Loading