mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
* perf(studio): define timeline viewport budgets and fixtures * test(studio): gate timeline viewport performance in Chromium * refactor(studio): isolate clip drag lifecycle * refactor(studio): extract timeline render contracts * perf(studio): centralize timeline viewport geometry * perf(studio): follow playhead across virtualized rows * perf(studio): add timeline clip-window index primitive * perf(studio): virtualize timeline clip windows * perf(studio): stop timeline scroll work when row virtualization is off The row virtualization stack made the timeline publish a viewport snapshot on every scroll frame and swap `renderClipContent` across every mounted clip at gesture start and settle. Both are windowing concessions, and neither was gated on the flag, so the build users actually run paid for them while mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247. Gate both on the row virtualization flag. The scroll path now stops at the door when the flag is off, so `isScrolling` stays false and resize-driven and programmatic syncs still publish through the immediate path. The flag moves into its own module: the scroll-viewport hook needs to read it, and the virtualization hook already imports the viewport snapshot type back, which would have closed an import cycle. Also release the perf fixture lease from the fixture rather than from the test-hook effect. Loading a fixture writes player state, which changed that effect's dependency identities and tore it down on the next frame, so the lease was revoked moments after it was taken and live iframe discovery overwrote the fixture before the gate could measure it. The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements) next to the existing flag-on one. It refuses the 50,000-element combination, verifies from the mounted DOM that the server under test matches the requested flag, and skips the DOM-size budgets for the unvirtualized build rather than relaxing them, so a skipped budget never reads as a passed one. Verified against a live Studio dev server on the fixture project: flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass flag off, after: interactionP95 33.6ms, longest task 0ms, 5/5 runs pass flag on, after: interactionP95 33.2ms, 4/5 runs pass, exit 0 The flag-on arm's fourth run reproducibly reports a 55-58ms long task against a 50ms budget. That is the residual tail of the window swap itself, tracked separately and not addressed here. * ci(studio): run the timeline viewport gate on studio changes The gate has existed since the row virtualization stack landed but nothing under `.github/` referenced it, so it only ever ran when someone ran it by hand. That is how the flag-off scroll regression reached eight merged-ready PRs without anything noticing. Adds a `studio-timeline-viewport` job that boots two Studio dev servers, one per flag state, and runs both arms of the gate against them. Two servers are needed because row virtualization is read from `import.meta.env` at module load, so one process cannot serve both builds. Scoped to a new `studio` paths filter rather than the broad `code` one: the gate only says anything about `packages/studio`, `packages/core` and `packages/studio-server`. Adds a `ci` tier. It applies the constrained budgets without any emulation, because a hosted runner is already slower and noisier than the machine the strict numbers were recorded on, while the existing `low-resource` tier would throttle it a further 4x and measure the throttle rather than the build. The fixture composition is tracked under `tests/e2e/fixtures` but Studio resolves projects from the gitignored `data/projects`, so the job copies it into place instead of a project directory being committed. Both arms run in about 7 seconds each locally, so the job cost is almost entirely dependency install and the workspace build it shares with `studio-load-smoke`. * fix(ci): preserve both timeline gate evidence arms * ci(studio): report timeline gate arm statuses * ci(studio): require timeline gate evidence artifacts * fix(studio): keep dense keyframes readable * fix(ci): resolve timeline stack audit findings
126 lines
5.4 KiB
TypeScript
126 lines
5.4 KiB
TypeScript
import { useEffect, useRef, type RefObject } from "react";
|
|
import { usePlayerStore, type TimelineElement, type ZoomMode } from "../store/playerStore";
|
|
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
import {
|
|
DRAG_EXTEND_MARGIN_PX,
|
|
getTimelineDisplayContentWidth,
|
|
getTimelineFitPps,
|
|
} from "./timelineLayout";
|
|
import type { DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
|
|
|
|
interface UseTimelineGeometryInput {
|
|
viewportWidth: number;
|
|
effectiveDuration: number;
|
|
zoomMode: ZoomMode;
|
|
manualZoomPercent: number;
|
|
ppsRef: RefObject<number>;
|
|
fitPpsRef: RefObject<number>;
|
|
draggedClip: DraggedClipState | null;
|
|
resizingClip: ResizingClipState | null;
|
|
expandedElements: TimelineElement[];
|
|
isDragging: RefObject<boolean>;
|
|
scrollRef: RefObject<HTMLDivElement | null>;
|
|
lastScrollLeftRef: RefObject<number>;
|
|
contentOrigin: number;
|
|
}
|
|
|
|
// Derive the timeline's horizontal geometry from the viewport, zoom, and any live
|
|
// drag/resize preview: the pixels-per-second scale, the rendered content width
|
|
// (CapCut-style over-extension while dragging/trimming), and the version key that
|
|
// re-triggers dependent effects after an edit re-derives the elements.
|
|
export function useTimelineGeometry({
|
|
viewportWidth,
|
|
effectiveDuration,
|
|
zoomMode,
|
|
manualZoomPercent,
|
|
ppsRef,
|
|
fitPpsRef,
|
|
draggedClip,
|
|
resizingClip,
|
|
expandedElements,
|
|
isDragging,
|
|
scrollRef,
|
|
lastScrollLeftRef,
|
|
contentOrigin,
|
|
}: UseTimelineGeometryInput) {
|
|
// Fit pps maps at least MIN_TIMELINE_EXTENT_S onto the viewport, so short
|
|
// comps show a 60s ruler with usable empty space (see getTimelineFitPps).
|
|
const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration, contentOrigin);
|
|
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
|
|
ppsRef.current = pps;
|
|
const trackContentWidth = Math.max(0, effectiveDuration * pps);
|
|
// Drag-to-extend: while a clip is dragged, keep the rendered extent a margin
|
|
// past the ghost's end. Holding the pointer in the right edge zone then keeps
|
|
// auto-scroll stepping (scrollWidth grows with the ghost), so the timeline
|
|
// extends at auto-scroll pace — placing a clip farther than the timeline
|
|
// currently shows. Growth is bounded per frame by AUTO_SCROLL_MAX_SPEED (no
|
|
// fling); leaving the edge zone stops it; the extra width collapses when the
|
|
// drag ends (the composition itself only grows on commit, content-driven).
|
|
const dragGhostEndPx = draggedClip?.started
|
|
? (draggedClip.previewStart + draggedClip.element.duration) * pps + DRAG_EXTEND_MARGIN_PX
|
|
: 0;
|
|
// Trim-to-extend: same mechanic for a right-edge RESIZE — the rendered extent
|
|
// tracks the trim preview's end so the edge auto-scroll zone always has room
|
|
// to keep stepping while the trim grows past the current timeline width.
|
|
const resizeGhostEndPx = resizingClip?.started
|
|
? (resizingClip.previewStart + resizingClip.previewDuration) * pps + DRAG_EXTEND_MARGIN_PX
|
|
: 0;
|
|
// The timeline canvas always fills at least the viewport width AND the
|
|
// MIN_TIMELINE_EXTENT_S floor: the ruler + empty track lanes keep going into
|
|
// the space instead of leaving dead black — CapCut-style. Only the RENDERED
|
|
// extent grows; clip positions/durations are untouched.
|
|
const displayContentWidth = getTimelineDisplayContentWidth({
|
|
trackContentWidth,
|
|
viewportWidth,
|
|
contentOrigin,
|
|
pps,
|
|
dragGhostEndPx,
|
|
resizeGhostEndPx,
|
|
});
|
|
const displayDuration = pps > 0 ? displayContentWidth / pps : effectiveDuration;
|
|
const zoomModeRef = useRef(zoomMode);
|
|
zoomModeRef.current = zoomMode;
|
|
const manualZoomPercentRef = useRef(manualZoomPercent);
|
|
manualZoomPercentRef.current = manualZoomPercent;
|
|
fitPpsRef.current = fitPps;
|
|
|
|
// Restore the horizontal scroll offset after an edit re-derives the elements
|
|
// (the immutable element snapshot changes) so the reload doesn't jump the view. Only in manual
|
|
// (pinned) mode — fit mode hides the x-scrollbar (scrollLeft is always 0) — and
|
|
// never mid-drag (auto-scroll owns the offset then). rAF waits for the new layout
|
|
// so the clamp reads the post-resync scrollWidth. zoomMode is a legitimate dep:
|
|
// re-running on a mode flip is a no-op thanks to the guard.
|
|
useEffect(() => {
|
|
if (zoomMode !== "manual" || isDragging.current) return;
|
|
const el = scrollRef.current;
|
|
const target = lastScrollLeftRef.current;
|
|
if (!el || target <= 0) return;
|
|
const raf = requestAnimationFrame(() => {
|
|
const max = Math.max(0, el.scrollWidth - el.clientWidth);
|
|
const next = Math.min(target, max);
|
|
if (Math.abs(el.scrollLeft - next) > 0.5) el.scrollLeft = next;
|
|
});
|
|
return () => cancelAnimationFrame(raf);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [expandedElements, zoomMode]);
|
|
// Publish the live scale so edit handlers OUTSIDE <Timeline> (the keyboard-delete
|
|
// path) can pin the zoom via pinTimelineZoomToCurrent without threading geometry.
|
|
// In a useEffect (not the render body) so React-18 concurrent replay — Suspense
|
|
// retry, transitions, StrictMode double-invoke — can't double-publish. The write is
|
|
// idempotent (same pps/fitPps → same fields), so this is behavior-preserving; the
|
|
// effect placement is just the strictly-correct shape.
|
|
useEffect(() => {
|
|
usePlayerStore.getState().setTimelineScale(pps, fitPps);
|
|
}, [pps, fitPps]);
|
|
|
|
return {
|
|
pps,
|
|
fitPps,
|
|
displayContentWidth,
|
|
displayDuration,
|
|
clipStateVersion: expandedElements,
|
|
zoomModeRef,
|
|
manualZoomPercentRef,
|
|
};
|
|
}
|