mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 12:00:26 +00:00
fix(studio): keep dense keyframes readable (#2925)
* 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
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { memo, useRef, useState } from "react";
|
||||
import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { CLIP_Y } from "./timelineLayout";
|
||||
import { CLIP_Y, getTimelineBeatEntries } from "./timelineLayout";
|
||||
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
||||
|
||||
export const BEAT_BAND_H = 14; // dark band height at top of track
|
||||
const BEAT_HIT_W = 12; // grab width per beat (px)
|
||||
@@ -24,23 +25,30 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
|
||||
beatStrengths,
|
||||
pps,
|
||||
highlightTime,
|
||||
renderTimeRange,
|
||||
}: {
|
||||
beatTimes: number[] | undefined;
|
||||
beatStrengths: number[] | undefined;
|
||||
pps: number;
|
||||
/** Snap guide time — drawn as a bright line even when it is not a beat. */
|
||||
highlightTime?: number | null;
|
||||
renderTimeRange?: TimelineTimeRange;
|
||||
}) {
|
||||
const visibleBeatTimes = beatTimes && !beatsTooDense(beatTimes, pps) ? beatTimes : null;
|
||||
const highlightIsBeat =
|
||||
highlightTime != null &&
|
||||
visibleBeatTimes?.some((t) => Math.abs(t - highlightTime) < 1e-3) === true;
|
||||
if (!visibleBeatTimes && highlightTime == null) return null;
|
||||
const beatEntries = getTimelineBeatEntries(
|
||||
visibleBeatTimes ?? undefined,
|
||||
beatStrengths,
|
||||
renderTimeRange,
|
||||
);
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
|
||||
{visibleBeatTimes?.map((t, i) => {
|
||||
{beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
|
||||
const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
|
||||
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
|
||||
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
|
||||
const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
|
||||
return (
|
||||
<div
|
||||
@@ -81,10 +89,12 @@ export const BeatStrip = memo(function BeatStrip({
|
||||
beatTimes,
|
||||
beatStrengths,
|
||||
pps,
|
||||
renderTimeRange,
|
||||
}: {
|
||||
beatTimes: number[] | undefined;
|
||||
beatStrengths: number[] | undefined;
|
||||
pps: number;
|
||||
renderTimeRange?: TimelineTimeRange;
|
||||
}) {
|
||||
// Active drag: which beat and how far (px) it's been dragged.
|
||||
const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null);
|
||||
@@ -92,15 +102,21 @@ export const BeatStrip = memo(function BeatStrip({
|
||||
|
||||
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
||||
const cy = BEAT_BAND_H / 2;
|
||||
const beatEntries = getTimelineBeatEntries(
|
||||
beatTimes,
|
||||
beatStrengths,
|
||||
renderTimeRange,
|
||||
drag ? new Set([drag.index]) : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ top: CLIP_Y, height: BEAT_BAND_H, background: "rgba(0,0,0,0.28)", zIndex: 11 }}
|
||||
>
|
||||
{beatTimes.map((t, i) => {
|
||||
{beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
|
||||
// Louder beats → larger, brighter dot. Gamma curve widens the contrast.
|
||||
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
|
||||
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
|
||||
const r = 1.5 + strength * 2.5;
|
||||
const opacity = 0.25 + strength * 0.75;
|
||||
const dxPx = drag?.index === i ? drag.dx : 0;
|
||||
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
getTimelineCanvasHeight,
|
||||
resolveTimelineAssetDrop,
|
||||
getTimelinePlayheadLeft,
|
||||
getTimelinePlaybackFollowScrollLeft,
|
||||
getTimelineScrollLeftForZoomAnchor,
|
||||
getTimelineScrollLeftForZoomTransition,
|
||||
shouldShowTimelineShortcutHint,
|
||||
shouldHandleTimelineDeleteKey,
|
||||
shouldAutoScrollTimeline,
|
||||
getTimelineVisibleTimeRange,
|
||||
getTimelineScrollTopForGeometryChange,
|
||||
} from "./Timeline";
|
||||
import {
|
||||
CLIP_Y,
|
||||
@@ -32,6 +35,7 @@ import {
|
||||
getTimelineDisplayContentWidth,
|
||||
getTimelineFitPps,
|
||||
getTimelineLaneTop,
|
||||
createTimelineRowGeometry,
|
||||
} from "./timelineLayout";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
@@ -44,6 +48,25 @@ afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("timeline viewport geometry", () => {
|
||||
it("derives a clamped visible time range from the raw viewport", () => {
|
||||
expect(
|
||||
getTimelineVisibleTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20),
|
||||
).toEqual({ start: 1, end: 6 });
|
||||
expect(getTimelineVisibleTimeRange({ scrollLeft: 0, clientWidth: 100 }, 100, 200, 20)).toEqual({
|
||||
start: 0,
|
||||
end: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the same row anchored when a row above it expands", () => {
|
||||
const previous = createTimelineRowGeometry([1, 2, 3], [48, 48, 48]);
|
||||
const next = createTimelineRowGeometry([1, 2, 3], [104, 48, 48]);
|
||||
const scrollTop = previous.getRowTop(2) - RULER_H + 6;
|
||||
expect(getTimelineScrollTopForGeometryChange(previous, next, scrollTop)).toBe(scrollTop + 56);
|
||||
});
|
||||
});
|
||||
|
||||
function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
|
||||
const clip = host.querySelector<HTMLElement>(`[data-el-id="${clipId}"]`);
|
||||
if (!clip) throw new Error(`Missing timeline clip ${clipId}`);
|
||||
@@ -252,6 +275,33 @@ describe("Timeline provider boundary", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders the complete track list while row virtualization is gated off", () => {
|
||||
const host = createSizedTimelineHost(640);
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
timelineReady: true,
|
||||
elements: Array.from({ length: 12 }, (_, track) => ({
|
||||
id: `clip-${track}`,
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 2,
|
||||
track,
|
||||
})),
|
||||
});
|
||||
const root = createRoot(host);
|
||||
act(() => root.render(React.createElement(Timeline)));
|
||||
|
||||
const list = host.querySelector<HTMLElement>('[role="list"]');
|
||||
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
|
||||
expect(rows).toHaveLength(12);
|
||||
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
|
||||
expect(rows[0]?.getAttribute("aria-setsize")).toBe("12");
|
||||
expect(rows[11]?.getAttribute("aria-posinset")).toBe("12");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("renders the gutter without legacy icons or hue dots", () => {
|
||||
const { host, root } = renderBasicTimeline();
|
||||
|
||||
@@ -954,6 +1004,56 @@ describe("getTimelinePlayheadLeft", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelinePlaybackFollowScrollLeft", () => {
|
||||
it("holds the viewport still while the playhead remains inside the comfort area", () => {
|
||||
expect(
|
||||
getTimelinePlaybackFollowScrollLeft({
|
||||
playheadX: 700,
|
||||
currentScrollLeft: 100,
|
||||
viewportWidth: 1000,
|
||||
contentOrigin: 264,
|
||||
maxScrollLeft: 2000,
|
||||
}),
|
||||
).toBe(100);
|
||||
});
|
||||
|
||||
it("follows forward playback at the right-side comfort line", () => {
|
||||
expect(
|
||||
getTimelinePlaybackFollowScrollLeft({
|
||||
playheadX: 1200,
|
||||
currentScrollLeft: 100,
|
||||
viewportWidth: 1000,
|
||||
contentOrigin: 264,
|
||||
maxScrollLeft: 2000,
|
||||
}),
|
||||
).toBe(384);
|
||||
});
|
||||
|
||||
it("returns to the matching earlier viewport after a playback loop", () => {
|
||||
expect(
|
||||
getTimelinePlaybackFollowScrollLeft({
|
||||
playheadX: 264,
|
||||
currentScrollLeft: 900,
|
||||
viewportWidth: 1000,
|
||||
contentOrigin: 264,
|
||||
maxScrollLeft: 2000,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps at the end of the scrollable timeline", () => {
|
||||
expect(
|
||||
getTimelinePlaybackFollowScrollLeft({
|
||||
playheadX: 5000,
|
||||
currentScrollLeft: 100,
|
||||
viewportWidth: 1000,
|
||||
contentOrigin: 264,
|
||||
maxScrollLeft: 1500,
|
||||
}),
|
||||
).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineCanvasHeight", () => {
|
||||
it("includes bottom scroll buffer below the last track", () => {
|
||||
expect(getTimelineCanvasHeight([TRACK_H, TRACK_H, TRACK_H])).toBeGreaterThan(
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react";
|
||||
import { useRef, useMemo, useCallback, useState, memo } from "react";
|
||||
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
||||
import { isMusicTrack } from "../../utils/timelineInspector";
|
||||
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
||||
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
||||
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
||||
import { useTimelineZoom } from "./useTimelineZoom";
|
||||
import { useTimelineAssetDrop } from "./timelineDragDrop";
|
||||
import { TimelineEmptyState } from "./TimelineEmptyState";
|
||||
@@ -16,14 +13,12 @@ import { TimelineCanvas } from "./TimelineCanvas";
|
||||
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
import { TimelineOverlays } from "./TimelineOverlays";
|
||||
import { animationContributesLane } from "./TimelinePropertyLanes";
|
||||
import { useTimelineEditPinning } from "./useTimelineEditPinning";
|
||||
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
||||
import { useTimelineGeometry } from "./useTimelineGeometry";
|
||||
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
||||
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD, generateTicks } from "./timelineLayout";
|
||||
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
|
||||
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
||||
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
||||
import type { TimelineProps } from "./TimelineTypes";
|
||||
import {
|
||||
@@ -37,14 +32,25 @@ import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
||||
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
|
||||
import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
||||
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
|
||||
import {
|
||||
getEffectiveTimelineDuration,
|
||||
getTimelinePreviewElement,
|
||||
hasKeyframedTimelineClips,
|
||||
} from "./timelineViewModel";
|
||||
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
|
||||
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
||||
import { useTimelineTicks } from "./useTimelineTicks";
|
||||
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
|
||||
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
|
||||
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
||||
|
||||
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
||||
export {
|
||||
generateTicks,
|
||||
formatTimelineTickLabel,
|
||||
shouldAutoScrollTimeline,
|
||||
getTimelineScrollLeftForZoomTransition,
|
||||
getTimelineScrollLeftForZoomAnchor,
|
||||
getTimelinePlaybackFollowScrollLeft,
|
||||
getTimelinePlayheadLeft,
|
||||
getTimelineCanvasHeight,
|
||||
shouldShowTimelineShortcutHint,
|
||||
@@ -52,6 +58,12 @@ export {
|
||||
shouldHandleTimelineDeleteKey,
|
||||
getDefaultDroppedTrack,
|
||||
} from "./timelineLayout";
|
||||
export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";
|
||||
|
||||
export {
|
||||
getTimelineScrollTopForGeometryChange,
|
||||
getTimelineVisibleTimeRange,
|
||||
} from "./timelineViewportGeometry";
|
||||
|
||||
export const Timeline = memo(function Timeline({
|
||||
onSeek,
|
||||
@@ -71,6 +83,7 @@ export const Timeline = memo(function Timeline({
|
||||
onSplitElement: onSplitElementOverride,
|
||||
onSelectElement,
|
||||
theme: themeOverrides,
|
||||
sessionEpoch = 0,
|
||||
}: TimelineProps = {}) {
|
||||
const {
|
||||
onMoveElement,
|
||||
@@ -102,7 +115,7 @@ export const Timeline = memo(function Timeline({
|
||||
const rawElements = usePlayerStore((s) => s.elements);
|
||||
const expandedElements = useExpandedTimelineElements();
|
||||
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
|
||||
const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null);
|
||||
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
|
||||
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
||||
const adjustedBeatAnalysis = useMemo(
|
||||
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
|
||||
@@ -113,17 +126,11 @@ export const Timeline = memo(function Timeline({
|
||||
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
||||
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
||||
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
|
||||
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
|
||||
const hasKeyframedClips = useMemo(
|
||||
() =>
|
||||
Array.from(gsapAnimations.values()).some((list) =>
|
||||
// Same lane-contribution predicate the layout uses: real keyframes OR a
|
||||
// synthesizable flat tween. Checking animation.keyframes alone left a
|
||||
// flat-tween-only comp without its reserved label column.
|
||||
list.some((animation) => animationContributesLane(animation)),
|
||||
),
|
||||
() => hasKeyframedTimelineClips(gsapAnimations),
|
||||
[gsapAnimations],
|
||||
);
|
||||
const labelMode = hasKeyframedClips;
|
||||
@@ -135,28 +142,13 @@ export const Timeline = memo(function Timeline({
|
||||
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
||||
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const activeTool = usePlayerStore((s) => s.activeTool);
|
||||
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
|
||||
useMountEffect(() => {
|
||||
const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown");
|
||||
const blur = () => setShiftHeld(false);
|
||||
window.addEventListener("keydown", key);
|
||||
window.addEventListener("keyup", key);
|
||||
window.addEventListener("blur", blur);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", key);
|
||||
window.removeEventListener("keyup", key);
|
||||
window.removeEventListener("blur", blur);
|
||||
};
|
||||
});
|
||||
|
||||
const shiftHeld = useTimelineShiftModifier();
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
|
||||
const [clipContextMenu, setClipContextMenu] = useState<{
|
||||
@@ -164,34 +156,37 @@ export const Timeline = memo(function Timeline({
|
||||
y: number;
|
||||
element: TimelineElement;
|
||||
} | null>(null);
|
||||
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
containerRef.current = el;
|
||||
}, []);
|
||||
|
||||
// Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
|
||||
const lastScrollLeftRef = useRef(0);
|
||||
|
||||
const effectiveDuration = useMemo(() => {
|
||||
const safeDur = Number.isFinite(duration) ? duration : 0;
|
||||
if (rawElements.length === 0) return safeDur;
|
||||
const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration));
|
||||
return Number.isFinite(result) ? result : safeDur;
|
||||
}, [rawElements, duration]);
|
||||
|
||||
const effectiveDuration = useMemo(
|
||||
() => getEffectiveTimelineDuration(duration, rawElements),
|
||||
[duration, rawElements],
|
||||
);
|
||||
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
||||
useAutoExpandKeyframedClips(gsapAnimations);
|
||||
const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowHeights, rowHeightsRef } =
|
||||
useTimelineTrackLayout(expandedElements, gsapAnimations, selectedElementId, selectedElementIds);
|
||||
const {
|
||||
tracks,
|
||||
trackStyles,
|
||||
trackOrder,
|
||||
trackOrderRef,
|
||||
laneCounts,
|
||||
rowGeometry,
|
||||
rowGeometryRef,
|
||||
} = useTimelineTrackLayout(
|
||||
expandedElements,
|
||||
gsapAnimations,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
);
|
||||
const expandedElementsRef = useRef(expandedElements);
|
||||
expandedElementsRef.current = expandedElements;
|
||||
|
||||
const ppsRef = useRef(100);
|
||||
const durationRef = useRef(effectiveDuration);
|
||||
durationRef.current = effectiveDuration;
|
||||
// Declared before the fitPps derivation so the edit-pin wrappers can close over it.
|
||||
const fitPpsRef = useRef(100);
|
||||
|
||||
const {
|
||||
pinZoomBeforeEdit,
|
||||
setRangeSelectionRef,
|
||||
@@ -215,11 +210,9 @@ export const Timeline = memo(function Timeline({
|
||||
onBlockDrop,
|
||||
onCompositionDrop,
|
||||
});
|
||||
|
||||
const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
|
||||
expandedElementsRef,
|
||||
});
|
||||
|
||||
const {
|
||||
gapMenuModel,
|
||||
gapHighlight,
|
||||
@@ -249,7 +242,7 @@ export const Timeline = memo(function Timeline({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
rowGeometryRef,
|
||||
onMoveElement: pinnedOnMoveElement,
|
||||
onMoveElements: pinnedOnMoveElements,
|
||||
onResizeElement: pinnedOnResizeElement,
|
||||
@@ -268,25 +261,41 @@ export const Timeline = memo(function Timeline({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
rowGeometryRef,
|
||||
contentOrigin,
|
||||
onFileDrop: pinnedOnFileDrop,
|
||||
onAssetDrop: pinnedOnAssetDrop,
|
||||
onBlockDrop: pinnedOnBlockDrop,
|
||||
onCompositionDrop: pinnedOnCompositionDrop,
|
||||
});
|
||||
|
||||
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights);
|
||||
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
|
||||
const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
|
||||
totalClipCount: expandedElements.length,
|
||||
totalRowCount: displayLayout.displayTrackOrder.length,
|
||||
zoomMode,
|
||||
});
|
||||
const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
|
||||
timelineReady,
|
||||
expandedElements.length,
|
||||
displayLayout.totalH,
|
||||
]);
|
||||
const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
|
||||
useTimelineScrollViewport(scrollRef, [
|
||||
timelineReady,
|
||||
expandedElements.length,
|
||||
displayLayout.totalH,
|
||||
]);
|
||||
const rowWindow = useTimelineRowVirtualization({
|
||||
scrollRef,
|
||||
viewport,
|
||||
rowGeometry: displayLayout.rowGeometry,
|
||||
sessionEpoch,
|
||||
elements: expandedElements,
|
||||
selectedElementId,
|
||||
revealElementId: clipRevealRequest?.elementId ?? null,
|
||||
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
|
||||
resizingRowKey: resizingClip?.element.track,
|
||||
clipContextMenuRowKey: clipContextMenu?.element.track,
|
||||
keyframeContextMenuRowKey: kfContextMenu?.element.track,
|
||||
lastScrollLeftRef,
|
||||
syncScrollViewport,
|
||||
});
|
||||
const { enabled: rowVirtualizationActive, virtualRows, focusedElementId } = rowWindow;
|
||||
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
|
||||
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
|
||||
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
|
||||
@@ -299,39 +308,53 @@ export const Timeline = memo(function Timeline({
|
||||
setKfContextMenu,
|
||||
toggleSelectedKeyframe,
|
||||
});
|
||||
|
||||
const selectedElement = useMemo(
|
||||
() =>
|
||||
expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
||||
[expandedElements, selectedElementId],
|
||||
);
|
||||
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
|
||||
selectedElementRef.current = selectedElement;
|
||||
|
||||
const {
|
||||
pps,
|
||||
fitPps,
|
||||
displayContentWidth,
|
||||
displayDuration,
|
||||
clipStateVersion,
|
||||
zoomModeRef,
|
||||
manualZoomPercentRef,
|
||||
} = useTimelineGeometry({
|
||||
viewportWidth,
|
||||
effectiveDuration,
|
||||
zoomMode,
|
||||
manualZoomPercent,
|
||||
ppsRef,
|
||||
fitPpsRef,
|
||||
draggedClip,
|
||||
resizingClip,
|
||||
expandedElements,
|
||||
isDragging,
|
||||
scrollRef,
|
||||
lastScrollLeftRef,
|
||||
const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
|
||||
useTimelineGeometry({
|
||||
viewportWidth: viewport.clientWidth,
|
||||
effectiveDuration,
|
||||
zoomMode,
|
||||
manualZoomPercent,
|
||||
ppsRef,
|
||||
fitPpsRef,
|
||||
draggedClip,
|
||||
resizingClip,
|
||||
expandedElements,
|
||||
isDragging,
|
||||
scrollRef,
|
||||
lastScrollLeftRef,
|
||||
contentOrigin,
|
||||
});
|
||||
const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
|
||||
tracks,
|
||||
viewport,
|
||||
pixelsPerSecond: pps,
|
||||
contentOrigin,
|
||||
duration: displayDuration,
|
||||
selectedElementId: selectedElementId ?? undefined,
|
||||
draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
|
||||
resizingElementId: resizingClip ? getTimelineElementIdentity(resizingClip.element) : undefined,
|
||||
revealElementId: clipRevealRequest?.elementId,
|
||||
focusedEaseElementId: focusedEaseSegment?.elementId,
|
||||
clipContextMenuElementId: clipContextMenu
|
||||
? getTimelineElementIdentity(clipContextMenu.element)
|
||||
: undefined,
|
||||
keyframeContextMenuElementId: kfContextMenu
|
||||
? getTimelineElementIdentity(kfContextMenu.element)
|
||||
: undefined,
|
||||
focusedElementId,
|
||||
scrollRef,
|
||||
elements: expandedElements,
|
||||
rowGeometry: displayLayout.rowGeometry,
|
||||
allowHorizontalReveal: zoomMode === "manual",
|
||||
rowVirtualizationActive,
|
||||
sessionEpoch,
|
||||
});
|
||||
useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime,
|
||||
clipStateVersion: renderTimeRange,
|
||||
elementStateVersion: expandedElements,
|
||||
});
|
||||
|
||||
const laneGapStrips = useTimelineGapHighlights({
|
||||
gapHighlight,
|
||||
tracks,
|
||||
@@ -364,11 +387,6 @@ export const Timeline = memo(function Timeline({
|
||||
onSeek,
|
||||
contentOrigin,
|
||||
});
|
||||
useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime,
|
||||
clipStateVersion,
|
||||
});
|
||||
const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } =
|
||||
useTimelineRazorInteraction({
|
||||
active: activeTool === "razor",
|
||||
@@ -400,47 +418,25 @@ export const Timeline = memo(function Timeline({
|
||||
setShowPopover,
|
||||
elementsRef: expandedElementsRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
rowGeometryRef,
|
||||
onSelectElement,
|
||||
contentOrigin,
|
||||
});
|
||||
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
|
||||
|
||||
const prevSelectedRef = useRef(selectedElementRef.current);
|
||||
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
const prev = prevSelectedRef.current;
|
||||
const curr = selectedElementRef.current;
|
||||
prevSelectedRef.current = curr;
|
||||
if (prev && !curr) {
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
}
|
||||
});
|
||||
useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () =>
|
||||
setRangeSelection(null),
|
||||
);
|
||||
|
||||
// Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames.
|
||||
const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
|
||||
const { major, minor } = useMemo(
|
||||
() => generateTicks(displayDuration, pps, tickFps),
|
||||
[displayDuration, pps, tickFps],
|
||||
const { major, minor, majorTickInterval } = useTimelineTicks(
|
||||
displayDuration,
|
||||
pps,
|
||||
timeDisplayMode,
|
||||
rowVirtualizationActive ? renderTimeRange : undefined,
|
||||
);
|
||||
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
|
||||
|
||||
const getPreviewElement = useCallback(
|
||||
(element: TimelineElement): TimelineElement => {
|
||||
if (
|
||||
resizingClip &&
|
||||
(resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
|
||||
) {
|
||||
return {
|
||||
...element,
|
||||
start: resizingClip.previewStart,
|
||||
duration: resizingClip.previewDuration,
|
||||
playbackStart: resizingClip.previewPlaybackStart,
|
||||
};
|
||||
}
|
||||
return element;
|
||||
},
|
||||
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
|
||||
[resizingClip],
|
||||
);
|
||||
|
||||
@@ -460,6 +456,7 @@ export const Timeline = memo(function Timeline({
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
aria-label="Timeline"
|
||||
data-timeline-element-count={expandedElements.length}
|
||||
className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
||||
onMouseMove={updateRazorGuide}
|
||||
onMouseLeave={clearRazorGuide}
|
||||
@@ -471,12 +468,16 @@ export const Timeline = memo(function Timeline({
|
||||
>
|
||||
<div
|
||||
ref={setScrollRef}
|
||||
data-timeline-scroll-viewport
|
||||
data-timeline-auto-scroll-left-inset={labelMode ? LABEL_COL_W : 0}
|
||||
tabIndex={-1}
|
||||
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
|
||||
onScroll={(e) => {
|
||||
lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
|
||||
recordTimelineScroll(e.currentTarget);
|
||||
syncScrollViewport(e.currentTarget, true);
|
||||
}}
|
||||
{...rowWindow.timelineFocusProps}
|
||||
onDragOver={handleAssetDragOver}
|
||||
onDragLeave={() => clearDropPreview()}
|
||||
onDrop={handleAssetDrop}
|
||||
@@ -507,6 +508,12 @@ export const Timeline = memo(function Timeline({
|
||||
theme={theme}
|
||||
displayTrackOrder={displayLayout.displayTrackOrder}
|
||||
rowHeights={displayLayout.displayRowHeights}
|
||||
rowGeometry={displayLayout.rowGeometry}
|
||||
virtualRows={virtualRows}
|
||||
rowsVirtualized={rowVirtualizationActive}
|
||||
clipIndex={clipIndex}
|
||||
renderTimeRange={renderTimeRange}
|
||||
pinnedClipIdentities={pinnedClipIdentities}
|
||||
trackOrder={trackOrder}
|
||||
tracks={tracks}
|
||||
trackStyles={trackStyles}
|
||||
@@ -520,7 +527,10 @@ export const Timeline = memo(function Timeline({
|
||||
blockedClipRef={blockedClipRef}
|
||||
suppressClickRef={suppressClickRef}
|
||||
scrollRef={scrollRef}
|
||||
renderClipContent={renderClipContent}
|
||||
// Windowing drops content to mount a row cheaply; unvirtualized it is pure cost.
|
||||
renderClipContent={
|
||||
rowVirtualizationActive && viewport.isScrolling ? undefined : renderClipContent
|
||||
}
|
||||
renderClipOverlay={renderClipOverlay}
|
||||
playheadRef={playheadRef}
|
||||
onDrillDown={onDrillDown}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||
observe(target: Element) {
|
||||
this.callback(
|
||||
[
|
||||
{
|
||||
target,
|
||||
borderBoxSize: [{ inlineSize: target.clientWidth, blockSize: target.clientHeight }],
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
this as unknown as ResizeObserver,
|
||||
);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
|
||||
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
||||
let clientWidth = 900;
|
||||
let clientHeight = 240;
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
|
||||
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
|
||||
configurable: true,
|
||||
get: () => clientWidth,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
|
||||
configurable: true,
|
||||
get: () => clientHeight,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clientWidth = 900;
|
||||
clientHeight = 240;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
if (originalClientWidth)
|
||||
Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
|
||||
if (originalClientHeight)
|
||||
Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
/**
|
||||
* The virtualized list only mounts rows/clips after its ResizeObserver and the
|
||||
* follow-up layout effect have both flushed, which is more than one React tick.
|
||||
* A fixed number of flushes is a coin flip once the rest of the suite is
|
||||
* competing for workers, so wait for the DOM the assertions actually need.
|
||||
*/
|
||||
async function settleUntil(predicate: () => boolean, tries = 60): Promise<void> {
|
||||
for (let attempt = 0; attempt < tries; attempt++) {
|
||||
if (predicate()) return;
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function advanceFrame(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
async function mountTimeline(element: React.ReactElement) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => root.render(element));
|
||||
await act(async () => {});
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
async function dispatchScroll(scroller: HTMLElement): Promise<void> {
|
||||
await act(async () => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
function clipsByTrack(count: number, duration = 1, includeLabels = false) {
|
||||
return Array.from({ length: count }, (_, track) => ({
|
||||
id: `clip-${track}`,
|
||||
...(includeLabels ? { label: `Clip ${track}` } : {}),
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration,
|
||||
track,
|
||||
}));
|
||||
}
|
||||
|
||||
async function scrollTimelineHorizontally(
|
||||
scroller: HTMLElement,
|
||||
scrollLeft: number,
|
||||
): Promise<void> {
|
||||
scroller.scrollLeft = scrollLeft;
|
||||
await dispatchScroll(scroller);
|
||||
}
|
||||
|
||||
// These tests mount 500-10,000 timeline elements and settle the virtualizer,
|
||||
// which lands right on the 5s default once the rest of the suite is competing
|
||||
// for workers. The generous ceiling is a flake guard, not an expected runtime.
|
||||
describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
||||
it("keeps a zero-size first render bounded while the feature flag is enabled", async () => {
|
||||
clientWidth = 0;
|
||||
clientHeight = 0;
|
||||
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
||||
import("./Timeline"),
|
||||
import("../store/playerStore"),
|
||||
]);
|
||||
usePlayerStore.setState({
|
||||
duration: 60,
|
||||
timelineReady: true,
|
||||
elements: clipsByTrack(10_000),
|
||||
});
|
||||
|
||||
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 2 }));
|
||||
|
||||
const rows = host.querySelectorAll('[role="listitem"]');
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.length).toBeLessThanOrEqual(16);
|
||||
|
||||
act(() => root.unmount());
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("defers rich clip content while scrolling without replacing the clip shell", async () => {
|
||||
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
||||
import("./Timeline"),
|
||||
import("../store/playerStore"),
|
||||
]);
|
||||
usePlayerStore.setState({
|
||||
duration: 60,
|
||||
timelineReady: true,
|
||||
selectedElementId: "clip-0",
|
||||
elements: [{ id: "clip-0", label: "Clip 0", tag: "div", start: 0, duration: 10, track: 0 }],
|
||||
});
|
||||
|
||||
const { host, root } = await mountTimeline(
|
||||
React.createElement(Timeline, {
|
||||
renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 110));
|
||||
});
|
||||
|
||||
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
||||
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
|
||||
expect(scroller).not.toBeNull();
|
||||
expect(clip).not.toBeNull();
|
||||
expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
|
||||
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
|
||||
|
||||
if (scroller) await dispatchScroll(scroller);
|
||||
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
||||
expect(host.querySelector("[data-rich-content]")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 110));
|
||||
});
|
||||
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
||||
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
usePlayerStore.getState().reset();
|
||||
}
|
||||
});
|
||||
|
||||
it("mounts a bounded list range over the full geometry height", async () => {
|
||||
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight, TRACK_H }] =
|
||||
await Promise.all([
|
||||
import("./Timeline"),
|
||||
import("../store/playerStore"),
|
||||
import("./timelineLayout"),
|
||||
]);
|
||||
usePlayerStore.setState({
|
||||
duration: 60,
|
||||
timelineReady: true,
|
||||
elements: clipsByTrack(1_000),
|
||||
});
|
||||
|
||||
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 3 }));
|
||||
|
||||
await settleUntil(
|
||||
() =>
|
||||
(host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) >
|
||||
0,
|
||||
);
|
||||
const list = host.querySelector<HTMLElement>('[role="list"]');
|
||||
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.length).toBeLessThanOrEqual(16);
|
||||
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
|
||||
expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
|
||||
expect(list?.parentElement?.style.height).toBe(
|
||||
`${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
|
||||
);
|
||||
|
||||
const firstRow = rows[0] as HTMLElement;
|
||||
const focusedControl = firstRow.querySelector<HTMLButtonElement>("button");
|
||||
expect(focusedControl).not.toBeNull();
|
||||
act(() => focusedControl?.focus());
|
||||
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
||||
expect(scroller).not.toBeNull();
|
||||
if (scroller) {
|
||||
scroller.scrollTop = 500 * 48;
|
||||
await act(async () => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
}
|
||||
expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
|
||||
expect(document.activeElement).toBe(focusedControl);
|
||||
|
||||
act(() => root.unmount());
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("windows clips and ruler cells while retaining an off-window selected clip", async () => {
|
||||
const [{ Timeline }, { usePlayerStore }, { TIMELINE_VIEWPORT_BUDGETS }] = await Promise.all([
|
||||
import("./Timeline"),
|
||||
import("../store/playerStore"),
|
||||
import("../lib/timelineViewportBudgets"),
|
||||
]);
|
||||
usePlayerStore.setState({
|
||||
duration: 1_000,
|
||||
timelineReady: true,
|
||||
zoomMode: "manual",
|
||||
manualZoomPercent: 2_000,
|
||||
selectedElementId: "clip-490",
|
||||
selectedElementIds: new Set(["clip-490"]),
|
||||
// Repeated fixture shape intentionally contrasts row and clip windowing scales.
|
||||
// fallow-ignore-next-line code-duplication
|
||||
elements: Array.from({ length: 500 }, (_, index) => ({
|
||||
id: `clip-${index}`,
|
||||
tag: "div",
|
||||
start: index * 2,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
})),
|
||||
});
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 4 })));
|
||||
await act(async () => {});
|
||||
|
||||
await settleUntil(() => host.querySelectorAll("[data-clip]").length > 1);
|
||||
const initialClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
|
||||
const initialGridCells = host.querySelectorAll("[data-timeline-grid-cell]");
|
||||
expect(initialClips.length).toBeGreaterThan(1);
|
||||
expect(initialClips.length).toBeLessThanOrEqual(
|
||||
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
|
||||
);
|
||||
expect(initialGridCells.length).toBeLessThan(100);
|
||||
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
|
||||
const initialWindowIds = initialClips.map((clip) => clip.dataset.elId);
|
||||
|
||||
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
||||
expect(scroller).not.toBeNull();
|
||||
if (scroller) await scrollTimelineHorizontally(scroller, 8_000);
|
||||
|
||||
const scrolledClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
|
||||
expect(scrolledClips.map((clip) => clip.dataset.elId)).not.toEqual(initialWindowIds);
|
||||
expect(scrolledClips.length).toBeLessThanOrEqual(
|
||||
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
|
||||
);
|
||||
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
|
||||
expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
|
||||
|
||||
await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
|
||||
await advanceFrame();
|
||||
await act(async () => {});
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
const focusedClip = host.querySelector('[data-el-id="clip-300"]');
|
||||
expect(document.activeElement).toBe(focusedClip);
|
||||
await advanceFrame();
|
||||
expect(host.querySelector('[data-el-id="clip-300"]')).toBe(focusedClip);
|
||||
expect(document.activeElement).toBe(focusedClip);
|
||||
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
|
||||
|
||||
if (scroller) await scrollTimelineHorizontally(scroller, 0);
|
||||
expect(host.querySelector('[data-el-id="clip-300"]')).toBe(focusedClip);
|
||||
expect(document.activeElement).toBe(focusedClip);
|
||||
|
||||
act(() => root.unmount());
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The flag-off build is the one users get today. It mounts every clip, so the
|
||||
* scroll-time concessions windowing makes are pure cost there: this block pins
|
||||
* the timeline to doing no per-frame work at all while a gesture runs.
|
||||
*/
|
||||
describe("Timeline without row virtualization", { timeout: 30_000 }, () => {
|
||||
async function renderUnvirtualizedTimeline() {
|
||||
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "0");
|
||||
vi.resetModules();
|
||||
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
||||
import("./Timeline"),
|
||||
import("../store/playerStore"),
|
||||
]);
|
||||
usePlayerStore.setState({
|
||||
duration: 60,
|
||||
timelineReady: true,
|
||||
selectedElementId: "clip-0",
|
||||
elements: clipsByTrack(40, 10, true),
|
||||
});
|
||||
|
||||
const { host, root } = await mountTimeline(
|
||||
React.createElement(Timeline, {
|
||||
renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
host,
|
||||
dispose: () => {
|
||||
act(() => root.unmount());
|
||||
usePlayerStore.getState().reset();
|
||||
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
|
||||
vi.resetModules();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("mounts every clip rather than a window", async () => {
|
||||
const { host, dispose } = await renderUnvirtualizedTimeline();
|
||||
try {
|
||||
expect(host.querySelectorAll("[data-clip]").length).toBe(40);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps clip content mounted across a scroll gesture", async () => {
|
||||
const { host, dispose } = await renderUnvirtualizedTimeline();
|
||||
try {
|
||||
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
||||
expect(scroller).not.toBeNull();
|
||||
const richBefore = host.querySelectorAll("[data-rich-content]").length;
|
||||
expect(richBefore).toBe(40);
|
||||
|
||||
if (scroller) await dispatchScroll(scroller);
|
||||
|
||||
expect(host.querySelectorAll("[data-rich-content]").length).toBe(richBefore);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not swap clip content back in after the gesture settles", async () => {
|
||||
const { host, dispose } = await renderUnvirtualizedTimeline();
|
||||
try {
|
||||
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
||||
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
|
||||
if (scroller) await dispatchScroll(scroller);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
||||
expect(host.querySelectorAll("[data-rich-content]").length).toBe(40);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the scroll position alone, so no snapshot round trip happens", async () => {
|
||||
const { host, dispose } = await renderUnvirtualizedTimeline();
|
||||
try {
|
||||
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
||||
if (!scroller) throw new Error("Expected a timeline scroll viewport");
|
||||
scroller.scrollTop = 400;
|
||||
await dispatchScroll(scroller);
|
||||
|
||||
expect(scroller.scrollTop).toBe(400);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,8 +23,9 @@ import { TimelineClip } from "./TimelineClip";
|
||||
import { TimelineLanes } from "./TimelineLanes";
|
||||
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
||||
import { renderClipChildren } from "./timelineClipChildren";
|
||||
import { useTimelineRevealClip } from "./useTimelineRevealClip";
|
||||
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
|
||||
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
|
||||
interface TimelineCanvasProps extends TimelineLaneBaseProps {
|
||||
major: number[];
|
||||
@@ -62,14 +63,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
onRazorSplitAll,
|
||||
} = useTimelineEditContextOptional();
|
||||
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
||||
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
|
||||
useTimelineRevealClip(scrollRef);
|
||||
const draggedElement = draggedClip?.element ?? null;
|
||||
const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null;
|
||||
const activeDraggedElement =
|
||||
draggedClip?.started === true && draggedElement
|
||||
draggedClip?.started === true && draggedElement && draggedElementIdentity
|
||||
? getRenderedTimelineElement({
|
||||
element: draggedElement,
|
||||
draggedElementId: draggedElement.key ?? draggedElement.id,
|
||||
draggedElementId: draggedElementIdentity,
|
||||
previewStart: draggedClip.previewStart,
|
||||
previewTrack: draggedClip.previewTrack,
|
||||
})
|
||||
@@ -85,10 +85,10 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
// the wall together and never deforms. Matches what the commit will do — see
|
||||
// timelineMultiDragPreview + commit.
|
||||
const multiDragPreview: MultiDragPreviewInput | null =
|
||||
draggedClip?.started === true && draggedElement
|
||||
draggedClip?.started === true && draggedElement && draggedElementIdentity
|
||||
? {
|
||||
dragStarted: true,
|
||||
draggedKey: draggedElement.key ?? draggedElement.id,
|
||||
draggedKey: draggedElementIdentity,
|
||||
draggedOriginStart: draggedElement.start,
|
||||
draggedPreviewStart: draggedClip.previewStart,
|
||||
selectedKeys: selectedElementIds,
|
||||
@@ -126,11 +126,12 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
theme={props.theme}
|
||||
beatAnalysis={props.beatAnalysis}
|
||||
contentOrigin={props.contentOrigin}
|
||||
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
|
||||
/>
|
||||
|
||||
{/* Breathing room between the sticky ruler and the first track lane — the
|
||||
top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
|
||||
<div aria-hidden="true" style={{ height: TRACKS_TOP_PAD }} />
|
||||
<div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_TOP_PAD }} />
|
||||
|
||||
<TimelineLanes
|
||||
{...props}
|
||||
@@ -147,7 +148,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
{/* Breathing room below the last track lane (~1.5 track heights) — a real
|
||||
scrollable surface, so a clip can be dragged into the void to create a
|
||||
new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
|
||||
<div aria-hidden="true" style={{ height: TRACKS_BOTTOM_PAD }} />
|
||||
<div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_BOTTOM_PAD }} />
|
||||
|
||||
{/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
|
||||
menu row would collapse; a quiet tint for every gap on the selected
|
||||
@@ -157,7 +158,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
const rowIndex = displayTrackOrder.indexOf(strip.track);
|
||||
if (rowIndex < 0) return null;
|
||||
const loud = strip.kind === "hover";
|
||||
return strip.intervals.map((gap) => (
|
||||
const visibleIntervals = props.rowsVirtualized
|
||||
? strip.intervals.filter(
|
||||
(gap) =>
|
||||
gap.start < props.renderTimeRange.end && gap.end > props.renderTimeRange.start,
|
||||
)
|
||||
: strip.intervals;
|
||||
return visibleIntervals.map((gap) => (
|
||||
<div
|
||||
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
|
||||
className="pointer-events-none absolute"
|
||||
@@ -249,6 +256,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
}
|
||||
isHovered={false}
|
||||
isDragging={true}
|
||||
isActive={isTimelineClipActive(activeDraggedElement, props.currentTime)}
|
||||
hasCustomContent={!!props.renderClipContent}
|
||||
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
|
||||
theme={props.theme}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface TimelineClipProps {
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
isDragging?: boolean;
|
||||
isActive?: boolean;
|
||||
hasCustomContent: boolean;
|
||||
capabilities: TimelineEditCapabilities;
|
||||
theme?: TimelineTheme;
|
||||
@@ -35,6 +36,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
isSelected,
|
||||
isHovered,
|
||||
isDragging = false,
|
||||
isActive = false,
|
||||
hasCustomContent,
|
||||
capabilities,
|
||||
theme = defaultTimelineTheme,
|
||||
@@ -88,6 +90,8 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
data-clip-start={el.start}
|
||||
data-clip-end={el.start + el.duration}
|
||||
data-clip-hidden={el.hidden ? "true" : undefined}
|
||||
data-active={isActive ? "" : undefined}
|
||||
tabIndex={-1}
|
||||
className={clipClassName}
|
||||
style={style}
|
||||
title={
|
||||
|
||||
@@ -33,6 +33,7 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
clipDuration={10}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
@@ -46,10 +47,85 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
|
||||
}
|
||||
|
||||
describe("TimelineClipDiamonds", () => {
|
||||
// Dense rows narrow the DIAMOND so neighbours stay individually readable, but
|
||||
// the hit box floors at KF_MIN_HIT_W — a gap-sized target gets unusable
|
||||
// (~7px) at the zoom floor.
|
||||
it("narrows dense keyframe visuals while flooring their hit regions", () => {
|
||||
it("marks only the nearest keyframe in a dense lane as under the playhead", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [34.6, 34.8, 35, 35.2, 35.4].map((percentage) => ({
|
||||
percentage,
|
||||
propertyGroup: "position",
|
||||
properties: { x: percentage },
|
||||
})),
|
||||
}}
|
||||
clipWidthPx={4000}
|
||||
clipHeightPx={48}
|
||||
clipDuration={12}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={35.05}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(host.querySelectorAll('[data-keyframe-at-playhead="true"]')).toHaveLength(1);
|
||||
expect(host.querySelector<HTMLButtonElement>('[data-keyframe-at-playhead="true"]')?.title).toBe(
|
||||
"35%",
|
||||
);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("distinguishes a playhead match from an explicitly selected keyframe", () => {
|
||||
const selectedKey = timelineKeyframeSelectionKey("clip-1", {
|
||||
percentage: 60,
|
||||
propertyGroup: "position",
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 40, propertyGroup: "position", properties: { x: 40 } },
|
||||
{ percentage: 60, propertyGroup: "position", properties: { x: 60 } },
|
||||
],
|
||||
}}
|
||||
clipWidthPx={1200}
|
||||
clipHeightPx={48}
|
||||
clipDuration={10}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={40}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set([selectedKey])}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const playheadDiamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
|
||||
const selectedDiamond = host.querySelector<HTMLButtonElement>('button[title="60%"]');
|
||||
expect(playheadDiamond?.dataset.keyframeAtPlayhead).toBe("true");
|
||||
expect(playheadDiamond?.dataset.keyframeSelected).toBe("false");
|
||||
expect(playheadDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#a3a3a3");
|
||||
expect(playheadDiamond?.querySelector('path[stroke="#4ba3d2"]')).not.toBeNull();
|
||||
expect(selectedDiamond?.dataset.keyframeAtPlayhead).toBe("false");
|
||||
expect(selectedDiamond?.dataset.keyframeSelected).toBe("true");
|
||||
expect(selectedDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps dense keyframe visuals full-size while bounding their hit regions", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
@@ -80,7 +156,7 @@ describe("TimelineClipDiamonds", () => {
|
||||
expect(diamonds).toHaveLength(3);
|
||||
for (const diamond of diamonds) {
|
||||
expect(Number.parseFloat(diamond.style.width)).toBeCloseTo(12);
|
||||
expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBeCloseTo(8.8);
|
||||
expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBe(22);
|
||||
}
|
||||
act(() => root.unmount());
|
||||
});
|
||||
@@ -123,6 +199,7 @@ describe("TimelineClipDiamonds", () => {
|
||||
keyframesData={{ format: "percentage", keyframes: [groupedKeyframe] }}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
clipDuration={10}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={-10}
|
||||
@@ -271,6 +348,7 @@ describe("TimelineClipDiamonds", () => {
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
clipDuration={10}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
@@ -356,6 +434,7 @@ describe("TimelineClipDiamonds", () => {
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
clipDuration={10}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
@@ -620,6 +699,7 @@ describe("TimelineClipDiamonds", () => {
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
clipDuration={10}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
|
||||
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
||||
import { LANE_H } from "./timelineLayout";
|
||||
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
||||
import {
|
||||
DIAMOND_RATIO,
|
||||
@@ -22,11 +23,10 @@ import {
|
||||
|
||||
export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
|
||||
|
||||
// Floor for a diamond's clickable width. The visual size still narrows to the
|
||||
// neighbour gap so packed diamonds stay individually readable, but the hit box
|
||||
// stops there: at the zoom floor the gap alone left a ~7px target, which is
|
||||
// neither hittable nor selectable with any accuracy. Boxes may overlap slightly
|
||||
// below this width; each diamond still owns the half-gap around its own centre.
|
||||
// Floor for a diamond's clickable width. The visible diamond stays full-size
|
||||
// even when neighbours overlap; shrinking recorded-gesture keyframes into dots
|
||||
// makes the timeline unreadable. The hit box still follows the neighbour gap
|
||||
// and stops at this floor so nearby timestamps remain separately targetable.
|
||||
//
|
||||
// Deliberately below the 24px WCAG 2.2 (2.5.8) minimum, under that criterion's
|
||||
// own spacing exception: at normal keyframe density the neighbour gap is under
|
||||
@@ -40,10 +40,36 @@ function roundPct(percentage: number): number {
|
||||
return Math.round(percentage * 1000) / 1000;
|
||||
}
|
||||
|
||||
/** Find the closest authored keyframe without scanning the full lane on every playhead tick. */
|
||||
function nearestKeyframeWithin(
|
||||
sorted: TimelineDiamondKeyframe[],
|
||||
percentage: number,
|
||||
tolerance: number,
|
||||
): TimelineDiamondKeyframe | null {
|
||||
if (!Number.isFinite(percentage) || tolerance <= 0 || sorted.length === 0) return null;
|
||||
let low = 0;
|
||||
let high = sorted.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (sorted[mid]!.percentage < percentage) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
const before = sorted[low - 1];
|
||||
const after = sorted[low];
|
||||
const nearest =
|
||||
before && after
|
||||
? percentage - before.percentage <= after.percentage - percentage
|
||||
? before
|
||||
: after
|
||||
: (before ?? after);
|
||||
return nearest && Math.abs(nearest.percentage - percentage) <= tolerance ? nearest : null;
|
||||
}
|
||||
|
||||
export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
keyframesData,
|
||||
clipWidthPx,
|
||||
clipHeightPx,
|
||||
clipDuration,
|
||||
beatsActive,
|
||||
accentColor,
|
||||
isSelected,
|
||||
@@ -197,14 +223,24 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
const previousGap = previous ? centerX - centerXOf(previous.percentage) : Infinity;
|
||||
const nextGap = next ? centerXOf(next.percentage) - centerX : Infinity;
|
||||
const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
|
||||
const gapWidth = Math.min(diamondSize, nearestGap);
|
||||
const hitWidth = Math.min(diamondSize, nearestGap);
|
||||
return {
|
||||
keyframe,
|
||||
centerX,
|
||||
hitWidth: Math.max(KF_MIN_HIT_W, gapWidth),
|
||||
visualSize: gapWidth === diamondSize ? diamondSize : Math.max(2, gapWidth - 2),
|
||||
hitWidth: Math.max(KF_MIN_HIT_W, hitWidth),
|
||||
visualSize: diamondSize,
|
||||
};
|
||||
});
|
||||
// The playhead is a cursor, not a selection. Resolve one nearest keyframe per
|
||||
// rendered property lane and only while it is within half an output frame.
|
||||
// A fixed clip-% tolerance grows with duration and lit whole clusters of
|
||||
// gesture-recorded keyframes at once on long clips.
|
||||
const halfFramePct =
|
||||
clipDuration && clipDuration > 0 ? 50 / STUDIO_PREVIEW_FPS / clipDuration : 0;
|
||||
const playheadKeyframe =
|
||||
isSelected && halfFramePct > 0
|
||||
? nearestKeyframeWithin(sorted, currentPercentage, halfFramePct)
|
||||
: null;
|
||||
const baseColor = isSelected ? accentColor : "#a3a3a3";
|
||||
const baseOpacity = isSelected ? 0.4 : 0.25;
|
||||
const canDrag = isSelected && !!onMoveKeyframe;
|
||||
@@ -251,9 +287,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
// fully visible instead of being clipped by the sticky label column.
|
||||
const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2;
|
||||
const isKfSelected = selectedKeyframes.has(kfKey);
|
||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
||||
const atPlayhead = kf === playheadKeyframe;
|
||||
const isHighlighted = isKfSelected || atPlayhead;
|
||||
const color = isHighlighted ? accentColor : "#a3a3a3";
|
||||
const color = isKfSelected ? accentColor : "#a3a3a3";
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -417,6 +453,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
data-keyframe-percentage={
|
||||
groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
|
||||
}
|
||||
data-keyframe-at-playhead={String(atPlayhead)}
|
||||
data-keyframe-selected={String(isKfSelected)}
|
||||
aria-current={atPlayhead ? "time" : undefined}
|
||||
aria-pressed={isKfSelected}
|
||||
style={{
|
||||
left: leftPx,
|
||||
top: centerY,
|
||||
@@ -461,19 +501,19 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
viewBox="0 0 10 10"
|
||||
style={{ flexShrink: 0, pointerEvents: "none" }}
|
||||
>
|
||||
{isKfSelected && (
|
||||
{(isKfSelected || atPlayhead) && (
|
||||
<path
|
||||
d="M5 0L10 5L5 10L0 5Z"
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth="0.8"
|
||||
opacity={0.5}
|
||||
opacity={isKfSelected ? 0.5 : 1}
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d="M5 1L9 5L5 9L1 5Z"
|
||||
fill={color}
|
||||
opacity={isKfSelected || atPlayhead ? 1 : 0.55}
|
||||
opacity={isKfSelected ? 1 : atPlayhead ? 0.9 : 0.55}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelineLanes } from "./TimelineLanes";
|
||||
import { getTrackStyle } from "./timelineIcons";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { TRACK_H } from "./timelineLayout";
|
||||
import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
|
||||
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
@@ -78,6 +79,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
||||
const laneCounts = new Map(
|
||||
elements.map((el) => [el.id, (gsapAnimations.get(el.id) ?? []).length]),
|
||||
);
|
||||
const rowHeights = displayTrackOrder.map(() => TRACK_H);
|
||||
act(() => {
|
||||
usePlayerStore.setState({ expandedClipIds: new Set(next.expandedClipIds ?? []) });
|
||||
root.render(
|
||||
@@ -88,7 +90,13 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
||||
trackContentWidth={800}
|
||||
theme={defaultTimelineTheme}
|
||||
displayTrackOrder={displayTrackOrder}
|
||||
rowHeights={displayTrackOrder.map(() => TRACK_H)}
|
||||
rowHeights={rowHeights}
|
||||
rowGeometry={getTimelineRowGeometry(rowHeights)}
|
||||
virtualRows={displayTrackOrder.map((_, index) => ({ index, rowKey: index }))}
|
||||
rowsVirtualized={false}
|
||||
clipIndex={createTimelineClipIndex(tracks)}
|
||||
renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
|
||||
pinnedClipIdentities={new Set()}
|
||||
trackOrder={displayTrackOrder}
|
||||
tracks={tracks}
|
||||
trackStyles={new Map()}
|
||||
@@ -144,6 +152,7 @@ describe("TimelineLanes track numbering", () => {
|
||||
});
|
||||
|
||||
expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
|
||||
expect(view.host.querySelectorAll("[data-timeline-row]")).toHaveLength(2);
|
||||
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
@@ -171,8 +180,10 @@ describe("TimelineLanes track numbering", () => {
|
||||
onContextMenuLane,
|
||||
});
|
||||
|
||||
// Row children: [sticky header column, time-mapped track content].
|
||||
const rows = Array.from(view.host.children);
|
||||
// Row children: [sticky header column, time-mapped track content]. The rows
|
||||
// sit inside the lanes list, which is what carries the virtualization
|
||||
// positioning context.
|
||||
const rows = Array.from(view.host.querySelectorAll('[role="listitem"]'));
|
||||
const secondTrackContent = rows[1]?.children.item(1);
|
||||
act(() => {
|
||||
secondTrackContent?.dispatchEvent(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { Fragment, useId } from "react";
|
||||
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
||||
@@ -8,12 +8,14 @@ import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
||||
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
||||
import { clipTimingStart } from "../../hooks/gsapShared";
|
||||
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
|
||||
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
|
||||
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H } from "./timelineLayout";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import {
|
||||
isMultiDragActive,
|
||||
isMultiDragPassenger,
|
||||
multiDragPassengerOffsetPx,
|
||||
multiDragDeltaSeconds,
|
||||
type MultiDragPreviewInput,
|
||||
multiDragPassengerOffsetPx,
|
||||
} from "./timelineMultiDragPreview";
|
||||
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
@@ -21,6 +23,10 @@ import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
||||
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
||||
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
||||
import { renderClipChildren } from "./timelineClipChildren";
|
||||
import { TimelineTrackRow } from "./TimelineTrackRow";
|
||||
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
||||
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
|
||||
interface TimelineLanesProps extends TimelineLaneBaseProps {
|
||||
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
|
||||
@@ -41,7 +47,12 @@ export function TimelineLanes({
|
||||
trackContentWidth,
|
||||
theme,
|
||||
displayTrackOrder,
|
||||
rowHeights,
|
||||
rowGeometry,
|
||||
virtualRows,
|
||||
rowsVirtualized,
|
||||
clipIndex,
|
||||
renderTimeRange,
|
||||
pinnedClipIdentities,
|
||||
trackOrder,
|
||||
tracks,
|
||||
trackStyles,
|
||||
@@ -102,19 +113,45 @@ export function TimelineLanes({
|
||||
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
||||
toggleClipExpanded(key);
|
||||
};
|
||||
const multiDragDelta =
|
||||
multiDragPreview && isMultiDragActive(multiDragPreview)
|
||||
? multiDragDeltaSeconds(multiDragPreview)
|
||||
: 0;
|
||||
const actorWindows =
|
||||
rowsVirtualized && multiDragPreview && multiDragDelta !== 0
|
||||
? [
|
||||
{
|
||||
range: {
|
||||
start: renderTimeRange.start - multiDragDelta,
|
||||
end: renderTimeRange.end - multiDragDelta,
|
||||
},
|
||||
identities: multiDragPreview.selectedKeys,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="list"
|
||||
aria-label="Timeline tracks"
|
||||
className={rowsVirtualized ? "absolute inset-0" : undefined}
|
||||
>
|
||||
{
|
||||
// NOTE (deliberate no-virtualization): lanes and their clips render via a
|
||||
// plain `.map()` inside the scroll container rather than a windowing/virtualized
|
||||
// list. NLE clip counts are small (dozens to low hundreds), so the DOM cost is
|
||||
// bounded and virtualization's complexity isn't worth it. TODO: revisit and swap
|
||||
// in a virtualizer if editorial workflows ever push very high clip counts.
|
||||
// fallow-ignore-next-line complexity
|
||||
displayTrackOrder.map((trackNum, row) => {
|
||||
virtualRows.map(({ index: row, rowKey }) => {
|
||||
const trackNum = displayTrackOrder[row];
|
||||
if (trackNum === undefined) return null;
|
||||
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
|
||||
const rowHeight = getTimelineRowHeight(row, rowHeights);
|
||||
const rowHeight = rowGeometry.getRowHeight(row);
|
||||
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
||||
const renderElements = rowsVirtualized
|
||||
? queryTimelineClipIndex(
|
||||
clipIndex,
|
||||
trackNum,
|
||||
renderTimeRange,
|
||||
pinnedClipIdentities,
|
||||
actorWindows,
|
||||
)
|
||||
: els;
|
||||
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
||||
const isPendingTrack =
|
||||
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
||||
@@ -148,14 +185,16 @@ export function TimelineLanes({
|
||||
// fractional sort key and would mint ids like `...-0.16666666666666666`.
|
||||
const lanesId = `${lanesIdPrefix}-track-${row}`;
|
||||
return (
|
||||
<div
|
||||
key={trackNum}
|
||||
className="relative flex"
|
||||
style={{
|
||||
height: rowHeight,
|
||||
background: rowBackground,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
}}
|
||||
<TimelineTrackRow
|
||||
key={rowKey}
|
||||
index={row}
|
||||
rowKey={rowKey}
|
||||
rowCount={displayTrackOrder.length}
|
||||
top={rowGeometry.getRowTop(row)}
|
||||
height={rowHeight}
|
||||
virtualized={rowsVirtualized}
|
||||
background={rowBackground}
|
||||
borderColor={theme.rowBorder}
|
||||
>
|
||||
<TimelineTrackHeader
|
||||
trackNumber={trackNum}
|
||||
@@ -218,6 +257,7 @@ export function TimelineLanes({
|
||||
? draggedClip.snapTime
|
||||
: null
|
||||
}
|
||||
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
||||
/>
|
||||
{/* Beat dots on the active track (the one holding the selection),
|
||||
falling back to the music track when nothing is selected. */}
|
||||
@@ -226,6 +266,7 @@ export function TimelineLanes({
|
||||
beatTimes={beatAnalysis?.beatTimes}
|
||||
beatStrengths={beatAnalysis?.beatStrengths}
|
||||
pps={pps}
|
||||
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
||||
/>
|
||||
)}
|
||||
{isPendingTrack && (
|
||||
@@ -245,9 +286,9 @@ export function TimelineLanes({
|
||||
)}
|
||||
{
|
||||
// fallow-ignore-next-line complexity
|
||||
els.map((el) => {
|
||||
renderElements.map((el) => {
|
||||
const clipStyle = getTrackStyle(el.tag);
|
||||
const elementKey = el.key ?? el.id;
|
||||
const elementKey = getTimelineElementIdentity(el);
|
||||
// Only the track's active keyframe clip shows expanded lanes;
|
||||
// other clips (incl. siblings on a shared track) show compact
|
||||
// diamonds on their own bar instead.
|
||||
@@ -263,7 +304,8 @@ export function TimelineLanes({
|
||||
const clipKey = elementKey;
|
||||
const isDraggingClip =
|
||||
draggedClip?.started === true &&
|
||||
(draggedElement?.key ?? draggedElement?.id) === elementKey;
|
||||
draggedElement != null &&
|
||||
getTimelineElementIdentity(draggedElement) === elementKey;
|
||||
if (isDraggingClip) return null;
|
||||
const previewElement = getPreviewElement(el);
|
||||
// Passenger of a live multi-drag: slide by the SAME formation
|
||||
@@ -290,6 +332,7 @@ export function TimelineLanes({
|
||||
isSelected={isSelected}
|
||||
isHovered={hoveredClip === clipKey}
|
||||
isDragging={false}
|
||||
isActive={isTimelineClipActive(previewElement, currentTime)}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={capabilities}
|
||||
theme={theme}
|
||||
@@ -434,6 +477,7 @@ export function TimelineLanes({
|
||||
keyframesData={keyframeCache.get(elementKey)!}
|
||||
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
||||
clipHeightPx={rowHeight - 2 * CLIP_Y}
|
||||
clipDuration={previewElement.duration}
|
||||
beatsActive={beatStripOnTrack}
|
||||
accentColor={clipStyle.accent}
|
||||
isSelected={isSelected}
|
||||
@@ -499,7 +543,18 @@ export function TimelineLanes({
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
);
|
||||
if (!isPassenger) return [clip, propertyLanes];
|
||||
// Keep one keyed top-level child per element. Returning an
|
||||
// array here makes React reconcile the outer array by
|
||||
// position, so a window shift remounts otherwise stable
|
||||
// clip keys and can tear down focus mid-reveal.
|
||||
if (!isPassenger) {
|
||||
return (
|
||||
<Fragment key={clipKey}>
|
||||
{clip}
|
||||
{propertyLanes}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={clipKey}
|
||||
@@ -518,10 +573,10 @@ export function TimelineLanes({
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</TimelineTrackRow>
|
||||
);
|
||||
})
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,6 +126,19 @@ const POSITION_SEGMENT_ANIMATION = animation("position-tween", "position", [
|
||||
{ percentage: 50, properties: { x: 50 } },
|
||||
]);
|
||||
|
||||
function boundaryKeyframeAnimations(): GsapAnimation[] {
|
||||
return [
|
||||
animation("position-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
]),
|
||||
animation("visual-tween", "visual", [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/** A tween the parser leaves unclassified because it spans several groups. */
|
||||
function ungroupedAnimation(
|
||||
id: string,
|
||||
@@ -306,16 +319,7 @@ describe("TimelinePropertyLanes", () => {
|
||||
});
|
||||
|
||||
it("keeps both groups' diamonds when their source keyframes share 0% and 100%", () => {
|
||||
const animations = [
|
||||
animation("position-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
]),
|
||||
animation("visual-tween", "visual", [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
]),
|
||||
];
|
||||
const animations = boundaryKeyframeAnimations();
|
||||
|
||||
const { host, root } = renderPropertyLanes({ animations });
|
||||
|
||||
@@ -512,16 +516,7 @@ describe("TimelinePropertyLanes", () => {
|
||||
// COLUMN whose children are all absolutely positioned: it computed to 0x0 and
|
||||
// held no diamonds. The real lanes had no wrapper at all to point at.
|
||||
it("wraps the lanes in the identified element so aria-controls resolves to the diamonds", () => {
|
||||
const animations = [
|
||||
animation("position-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
]),
|
||||
animation("visual-tween", "visual", [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
]),
|
||||
];
|
||||
const animations = boundaryKeyframeAnimations();
|
||||
const { host, root } = renderPropertyLanes({ id: "timeline-lanes-track-0", animations });
|
||||
const wrapper = host.querySelector("#timeline-lanes-track-0");
|
||||
|
||||
@@ -594,6 +589,7 @@ describe("TimelinePropertyLanes", () => {
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
clipDuration={10}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={-10}
|
||||
|
||||
@@ -240,6 +240,7 @@ export function TimelinePropertyLanes({
|
||||
keyframesData={keyframesData}
|
||||
clipWidthPx={clipWidthPx}
|
||||
clipHeightPx={LANE_H}
|
||||
clipDuration={clipDuration}
|
||||
accentColor={accentColor}
|
||||
isSelected={isSelected}
|
||||
currentPercentage={currentPercentage}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { memo } from "react";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import { RULER_H, formatTimelineTickLabel } from "./timelineLayout";
|
||||
import { RULER_H, getTimelineBeatEntries } from "./timelineLayout";
|
||||
import { formatTimelineTickLabel } from "./timelineRulerGeometry";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { secondsToFrame } from "../lib/time";
|
||||
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
||||
|
||||
interface TimelineRulerProps {
|
||||
major: number[];
|
||||
@@ -16,6 +18,7 @@ interface TimelineRulerProps {
|
||||
theme: TimelineTheme;
|
||||
beatAnalysis?: MusicBeatAnalysis | null;
|
||||
contentOrigin: number;
|
||||
renderTimeRange?: TimelineTimeRange;
|
||||
}
|
||||
|
||||
export const TimelineRuler = memo(function TimelineRuler({
|
||||
@@ -29,10 +32,12 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
theme,
|
||||
beatAnalysis,
|
||||
contentOrigin,
|
||||
renderTimeRange,
|
||||
}: TimelineRulerProps) {
|
||||
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
||||
const beatTimes = beatAnalysis?.beatTimes ?? [];
|
||||
const beatStrengths = beatAnalysis?.beatStrengths ?? [];
|
||||
const beatEntries = getTimelineBeatEntries(beatTimes, beatStrengths, renderTimeRange);
|
||||
|
||||
// Only draw beat lines when they'd be at least 5px apart
|
||||
const avgBeatInterval =
|
||||
@@ -51,13 +56,14 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
height={totalH}
|
||||
>
|
||||
{showBeats &&
|
||||
beatTimes.map((t, i) => {
|
||||
beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
|
||||
const x = t * pps;
|
||||
// Louder beats → brighter line. Gamma curve widens the contrast.
|
||||
const strength = Math.pow(Math.min(1, beatStrengths[i] ?? 0.5), 2.2);
|
||||
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
|
||||
const opacity = 0.08 + strength * 0.62;
|
||||
return (
|
||||
<line
|
||||
data-timeline-grid-cell="beat"
|
||||
key={`b-${t}-${i}`}
|
||||
x1={x}
|
||||
y1={0}
|
||||
@@ -103,13 +109,23 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
contentOrigin + t * pps (see getTimelinePlayheadLeft). Without the shift
|
||||
a tick spans [x, x+1) and its center is half a pixel right. */}
|
||||
{minor.map((t) => (
|
||||
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
|
||||
<div
|
||||
key={`m-${t}`}
|
||||
data-timeline-grid-cell="minor"
|
||||
className="absolute bottom-0"
|
||||
style={{ left: t * pps - 0.5 }}
|
||||
>
|
||||
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{major.map((t) => (
|
||||
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps - 0.5 }}>
|
||||
<div
|
||||
key={`M-${t}`}
|
||||
data-timeline-grid-cell="major"
|
||||
className="absolute top-0"
|
||||
style={{ left: t * pps - 0.5 }}
|
||||
>
|
||||
<span
|
||||
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface TimelineTrackRowProps {
|
||||
index: number;
|
||||
rowKey: number;
|
||||
rowCount: number;
|
||||
top: number;
|
||||
height: number;
|
||||
virtualized: boolean;
|
||||
background: string;
|
||||
borderColor: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Accessible row shell; edit geometry owns its exact top and height. */
|
||||
export function TimelineTrackRow({
|
||||
index,
|
||||
rowKey,
|
||||
rowCount,
|
||||
top,
|
||||
height,
|
||||
virtualized,
|
||||
background,
|
||||
borderColor,
|
||||
children,
|
||||
}: TimelineTrackRowProps) {
|
||||
return (
|
||||
<div
|
||||
role="listitem"
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={rowCount}
|
||||
data-index={index}
|
||||
data-timeline-row={index}
|
||||
data-timeline-row-key={rowKey}
|
||||
className={`${virtualized ? "absolute left-0 right-0" : "relative"} flex`}
|
||||
style={{
|
||||
top: virtualized ? top : undefined,
|
||||
height,
|
||||
background,
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type { TimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
|
||||
|
||||
export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
|
||||
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
|
||||
sessionEpoch?: number;
|
||||
onSeek?: (time: number) => void;
|
||||
onDrillDown?: (element: TimelineElement) => void;
|
||||
renderClipContent?: (
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { SetStateAction } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle";
|
||||
import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("timeline clip drag gesture lifecycle", () => {
|
||||
it("finishes a drag after virtualization unmounts its source row", () => {
|
||||
const element: TimelineElement = {
|
||||
id: "clip-1",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 2,
|
||||
track: 0,
|
||||
};
|
||||
const drag: DraggedClipState = {
|
||||
element,
|
||||
originClientX: 0,
|
||||
originClientY: 0,
|
||||
originScrollLeft: 0,
|
||||
originScrollTop: 0,
|
||||
pointerClientX: 0,
|
||||
pointerClientY: 0,
|
||||
pointerOffsetX: 0,
|
||||
pointerOffsetY: 0,
|
||||
previewStart: 1,
|
||||
previewTrack: 0,
|
||||
desiredTrack: 0,
|
||||
insertRow: null,
|
||||
snapTime: null,
|
||||
snapType: null,
|
||||
started: true,
|
||||
};
|
||||
const draggedClipRef = { current: drag as DraggedClipState | null };
|
||||
const resizingClipRef = { current: null as ResizingClipState | null };
|
||||
const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
|
||||
draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
|
||||
};
|
||||
const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
|
||||
...previous,
|
||||
previewStart: 3,
|
||||
}));
|
||||
const onMoveElement = vi.fn();
|
||||
const stopAutoScroll = vi.fn();
|
||||
const dispose = mountTimelineClipDragGestureLifecycle({
|
||||
draggedClipRef,
|
||||
resizingClipRef,
|
||||
blockedClipRef: { current: null },
|
||||
groupResizeRef: { current: null },
|
||||
suppressClickRef: { current: false },
|
||||
elementsRef: { current: [element] },
|
||||
trackOrderRef: { current: [0] },
|
||||
setDraggedClip,
|
||||
setResizingClip: () => {},
|
||||
setShowPopover: () => {},
|
||||
setRangeSelectionRef: { current: null },
|
||||
applyResizePointerRef: { current: () => {} },
|
||||
syncClipDragAutoScrollRef: { current: () => {} },
|
||||
stopClipDragAutoScrollRef: { current: stopAutoScroll },
|
||||
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
|
||||
restoreGroupResizeMembers: () => {},
|
||||
updateElement: vi.fn(),
|
||||
onMoveElementRef: { current: onMoveElement },
|
||||
onMoveElementsRef: { current: undefined },
|
||||
onResizeElementRef: { current: undefined },
|
||||
onResizeElementsRef: { current: undefined },
|
||||
onBlockedEditAttemptRef: { current: undefined },
|
||||
readZIndexRef: { current: undefined },
|
||||
onStackingPatchesRef: { current: undefined },
|
||||
refreshAfterLaneMoveRef: { current: undefined },
|
||||
});
|
||||
|
||||
const sourceRow = document.createElement("div");
|
||||
sourceRow.dataset.timelineRow = "";
|
||||
sourceRow.append(document.createElement("div"));
|
||||
document.body.append(sourceRow);
|
||||
sourceRow.remove();
|
||||
|
||||
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
|
||||
expect(updateDraggedClipPreview).toHaveBeenCalledTimes(1);
|
||||
expect(draggedClipRef.current?.previewStart).toBe(3);
|
||||
|
||||
window.dispatchEvent(new MouseEvent("pointerup"));
|
||||
expect(onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
|
||||
expect(draggedClipRef.current).toBeNull();
|
||||
expect(stopAutoScroll).toHaveBeenCalledTimes(1);
|
||||
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import { resolveTimelineDragEscape } from "./timelineEditing";
|
||||
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
||||
import type {
|
||||
BlockedClipState,
|
||||
DraggedClipState,
|
||||
ResizingClipState,
|
||||
} from "./timelineClipDragTypes";
|
||||
import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
|
||||
import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
|
||||
import {
|
||||
beginTimelineOptimisticGesture,
|
||||
rollbackLatestTimelineOptimisticGesture,
|
||||
} from "./timelineOptimisticRevision";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import type { StackingPatch } from "./timelineStackingSync";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
|
||||
type UpdateElement = ReturnType<typeof usePlayerStore.getState>["updateElement"];
|
||||
|
||||
interface TimelineClipDragGestureLifecycleInput {
|
||||
draggedClipRef: RefObject<DraggedClipState | null>;
|
||||
resizingClipRef: RefObject<ResizingClipState | null>;
|
||||
blockedClipRef: RefObject<BlockedClipState | null>;
|
||||
groupResizeRef: RefObject<TimelineGroupResizeSession | null>;
|
||||
suppressClickRef: RefObject<boolean>;
|
||||
elementsRef: RefObject<TimelineElement[]>;
|
||||
trackOrderRef: RefObject<number[]>;
|
||||
setDraggedClip: Dispatch<SetStateAction<DraggedClipState | null>>;
|
||||
setResizingClip: Dispatch<SetStateAction<ResizingClipState | null>>;
|
||||
setShowPopover: (show: boolean) => void;
|
||||
setRangeSelectionRef: RefObject<((selection: null) => void) | null>;
|
||||
applyResizePointerRef: RefObject<(resize: ResizingClipState, clientX: number) => void>;
|
||||
syncClipDragAutoScrollRef: RefObject<(clientX: number, clientY: number) => void>;
|
||||
stopClipDragAutoScrollRef: RefObject<() => void>;
|
||||
updateDraggedClipPreviewRef: RefObject<
|
||||
(drag: DraggedClipState, clientX: number, clientY: number) => DraggedClipState
|
||||
>;
|
||||
restoreGroupResizeMembers: (session: TimelineGroupResizeSession, all?: boolean) => void;
|
||||
updateElement: UpdateElement;
|
||||
onMoveElementRef: RefObject<TimelineEditCallbacks["onMoveElement"]>;
|
||||
onMoveElementsRef: RefObject<TimelineEditCallbacks["onMoveElements"]>;
|
||||
onResizeElementRef: RefObject<TimelineEditCallbacks["onResizeElement"]>;
|
||||
onResizeElementsRef: RefObject<TimelineEditCallbacks["onResizeElements"]>;
|
||||
onBlockedEditAttemptRef: RefObject<
|
||||
((element: TimelineElement, intent: BlockedClipState["intent"]) => void) | undefined
|
||||
>;
|
||||
readZIndexRef: RefObject<((element: TimelineElement) => number) | undefined>;
|
||||
onStackingPatchesRef: RefObject<
|
||||
((patches: StackingPatch[]) => Promise<unknown> | void) | undefined
|
||||
>;
|
||||
refreshAfterLaneMoveRef: RefObject<(() => void) | undefined>;
|
||||
}
|
||||
|
||||
export function mountTimelineClipDragGestureLifecycle({
|
||||
// The explicit destructuring mirrors the single call site's dependency object by design.
|
||||
// fallow-ignore-next-line code-duplication
|
||||
draggedClipRef,
|
||||
resizingClipRef,
|
||||
blockedClipRef,
|
||||
groupResizeRef,
|
||||
suppressClickRef,
|
||||
elementsRef,
|
||||
trackOrderRef,
|
||||
setDraggedClip,
|
||||
setResizingClip,
|
||||
setShowPopover,
|
||||
setRangeSelectionRef,
|
||||
applyResizePointerRef,
|
||||
syncClipDragAutoScrollRef,
|
||||
stopClipDragAutoScrollRef,
|
||||
updateDraggedClipPreviewRef,
|
||||
restoreGroupResizeMembers,
|
||||
updateElement,
|
||||
onMoveElementRef,
|
||||
onMoveElementsRef,
|
||||
onResizeElementRef,
|
||||
onResizeElementsRef,
|
||||
onBlockedEditAttemptRef,
|
||||
readZIndexRef,
|
||||
onStackingPatchesRef,
|
||||
refreshAfterLaneMoveRef,
|
||||
}: TimelineClipDragGestureLifecycleInput): () => void {
|
||||
const clearSuppressedClick = () => {
|
||||
requestAnimationFrame(() => {
|
||||
suppressClickRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
const handleResizePointerMove = (event: PointerEvent, resize: ResizingClipState) => {
|
||||
const distance = Math.abs(event.clientX - resize.originClientX);
|
||||
if (!resize.started && distance < 2) return;
|
||||
setShowPopover(false);
|
||||
setRangeSelectionRef.current?.(null);
|
||||
applyResizePointerRef.current(resize, event.clientX);
|
||||
syncClipDragAutoScrollRef.current(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handleBlockedPointerMove = (event: PointerEvent, blocked: BlockedClipState) => {
|
||||
const distance = Math.hypot(
|
||||
event.clientX - blocked.originClientX,
|
||||
event.clientY - blocked.originClientY,
|
||||
);
|
||||
const threshold = blocked.intent === "move" ? 4 : 2;
|
||||
if (!blocked.started && distance < threshold) return;
|
||||
if (!blocked.started) {
|
||||
blocked.started = true;
|
||||
blockedClipRef.current = blocked;
|
||||
suppressClickRef.current = true;
|
||||
setShowPopover(false);
|
||||
setRangeSelectionRef.current?.(null);
|
||||
onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragPointerMove = (event: PointerEvent, drag: DraggedClipState) => {
|
||||
const distance = Math.hypot(
|
||||
event.clientX - drag.originClientX,
|
||||
event.clientY - drag.originClientY,
|
||||
);
|
||||
if (!drag.started && distance < 4) return;
|
||||
setShowPopover(false);
|
||||
setRangeSelectionRef.current?.(null);
|
||||
setDraggedClip((previous) =>
|
||||
previous
|
||||
? updateDraggedClipPreviewRef.current(previous, event.clientX, event.clientY)
|
||||
: previous,
|
||||
);
|
||||
syncClipDragAutoScrollRef.current(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handleWindowPointerMove = (event: PointerEvent) => {
|
||||
const resize = resizingClipRef.current;
|
||||
if (resize) return handleResizePointerMove(event, resize);
|
||||
const blocked = blockedClipRef.current;
|
||||
if (blocked) return handleBlockedPointerMove(event, blocked);
|
||||
const drag = draggedClipRef.current;
|
||||
if (drag) handleDragPointerMove(event, drag);
|
||||
};
|
||||
|
||||
const commitResizePointerUp = (resize: ResizingClipState) => {
|
||||
resizingClipRef.current = null;
|
||||
setResizingClip(null);
|
||||
const groupSession = groupResizeRef.current;
|
||||
groupResizeRef.current = null;
|
||||
if (!resize.started) {
|
||||
if (groupSession) restoreGroupResizeMembers(groupSession);
|
||||
return;
|
||||
}
|
||||
suppressClickRef.current = true;
|
||||
clearSuppressedClick();
|
||||
if (groupSession) {
|
||||
commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current);
|
||||
return;
|
||||
}
|
||||
const hasChanged =
|
||||
resize.previewStart !== resize.element.start ||
|
||||
resize.previewDuration !== resize.element.duration ||
|
||||
resize.previewPlaybackStart !== resize.element.playbackStart;
|
||||
if (!hasChanged) return;
|
||||
|
||||
const resizeKey = resize.element.key ?? resize.element.id;
|
||||
const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]);
|
||||
updateElement(resizeKey, {
|
||||
start: resize.previewStart,
|
||||
duration: resize.previewDuration,
|
||||
playbackStart: resize.previewPlaybackStart,
|
||||
});
|
||||
Promise.resolve(
|
||||
onResizeElementRef.current?.(resize.element, {
|
||||
start: resize.previewStart,
|
||||
duration: resize.previewDuration,
|
||||
playbackStart: resize.previewPlaybackStart,
|
||||
}),
|
||||
).catch((error) => {
|
||||
rollbackLatestTimelineOptimisticGesture(updateElement, revision, [
|
||||
{
|
||||
key: resizeKey,
|
||||
updates: {
|
||||
start: resize.element.start,
|
||||
duration: resize.element.duration,
|
||||
playbackStart: resize.element.playbackStart,
|
||||
},
|
||||
},
|
||||
]);
|
||||
console.error("[Timeline] Failed to persist clip resize", error);
|
||||
});
|
||||
};
|
||||
|
||||
const finishBlockedPointerUp = (blocked: BlockedClipState) => {
|
||||
blockedClipRef.current = null;
|
||||
if (blocked.started) clearSuppressedClick();
|
||||
};
|
||||
|
||||
const commitDragPointerUp = (drag: DraggedClipState) => {
|
||||
draggedClipRef.current = null;
|
||||
setDraggedClip(null);
|
||||
if (!drag.started) return;
|
||||
suppressClickRef.current = true;
|
||||
clearSuppressedClick();
|
||||
commitDraggedClipMove(drag, {
|
||||
elements: elementsRef.current,
|
||||
trackOrder: trackOrderRef.current,
|
||||
updateElement,
|
||||
onMoveElement: onMoveElementRef.current,
|
||||
onMoveElements: onMoveElementsRef.current,
|
||||
selectedKeys: usePlayerStore.getState().selectedElementIds,
|
||||
readZIndex: readZIndexRef.current,
|
||||
onStackingPatches: onStackingPatchesRef.current,
|
||||
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
const handleWindowPointerUp = () => {
|
||||
stopClipDragAutoScrollRef.current();
|
||||
const resize = resizingClipRef.current;
|
||||
if (resize) return commitResizePointerUp(resize);
|
||||
const blocked = blockedClipRef.current;
|
||||
if (blocked) return finishBlockedPointerUp(blocked);
|
||||
const drag = draggedClipRef.current;
|
||||
if (!drag) {
|
||||
if (suppressClickRef.current) clearSuppressedClick();
|
||||
return;
|
||||
}
|
||||
commitDragPointerUp(drag);
|
||||
};
|
||||
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
const decision = resolveTimelineDragEscape({
|
||||
key: event.key,
|
||||
drag: draggedClipRef.current,
|
||||
resize: resizingClipRef.current,
|
||||
blocked: blockedClipRef.current,
|
||||
});
|
||||
if (!decision.cancel) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
stopClipDragAutoScrollRef.current();
|
||||
draggedClipRef.current = null;
|
||||
setDraggedClip(null);
|
||||
resizingClipRef.current = null;
|
||||
setResizingClip(null);
|
||||
const groupSession = groupResizeRef.current;
|
||||
groupResizeRef.current = null;
|
||||
if (groupSession) restoreGroupResizeMembers(groupSession);
|
||||
blockedClipRef.current = null;
|
||||
if (decision.suppressClick) suppressClickRef.current = true;
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleWindowPointerMove);
|
||||
window.addEventListener("pointerup", handleWindowPointerUp);
|
||||
window.addEventListener("pointercancel", handleWindowPointerUp);
|
||||
window.addEventListener("keydown", handleWindowKeyDown, true);
|
||||
return () => {
|
||||
stopClipDragAutoScrollRef.current();
|
||||
window.removeEventListener("pointermove", handleWindowPointerMove);
|
||||
window.removeEventListener("pointerup", handleWindowPointerUp);
|
||||
window.removeEventListener("pointercancel", handleWindowPointerUp);
|
||||
window.removeEventListener("keydown", handleWindowKeyDown, true);
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export interface TimelineClipDiamondsProps {
|
||||
keyframesData: KeyframeCacheEntry;
|
||||
clipWidthPx: number;
|
||||
clipHeightPx: number;
|
||||
/** Needed to compare the playhead to keyframes in output-frame time. */
|
||||
clipDuration: number;
|
||||
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
|
||||
* the bottom half so they clear the strip at the top. */
|
||||
beatsActive?: boolean;
|
||||
@@ -70,7 +72,10 @@ export interface TimelineDiamondLaneProps extends Omit<
|
||||
| "onContextMenuKeyframe"
|
||||
| "onMoveKeyframe"
|
||||
| "onSelectSegment"
|
||||
| "clipDuration"
|
||||
> {
|
||||
/** Isolated lanes may omit timing; without it no playhead diamond is marked. */
|
||||
clipDuration?: number;
|
||||
groupAware?: boolean;
|
||||
globalEase?: string;
|
||||
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
TIMELINE_COMPOSITION_MIME,
|
||||
} from "../../utils/timelineCompositionDrop";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { resolveTimelineAssetDrop } from "./timelineLayout";
|
||||
import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
|
||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||
|
||||
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||
@@ -13,7 +13,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||
ppsRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
trackOrderRef: RefObject<number[]>;
|
||||
rowHeightsRef: RefObject<readonly number[]>;
|
||||
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
||||
contentOrigin: number;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function useTimelineAssetDrop({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
rowGeometryRef,
|
||||
contentOrigin,
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
@@ -93,7 +93,7 @@ export function useTimelineAssetDrop({
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
duration: durationRef.current,
|
||||
clampStartToDuration: !usePointerStart,
|
||||
rowHeights: rowHeightsRef.current,
|
||||
rowHeights: rowGeometryRef.current.rowHeights,
|
||||
trackOrder: trackOrderRef.current,
|
||||
},
|
||||
clientX,
|
||||
@@ -104,7 +104,7 @@ export function useTimelineAssetDrop({
|
||||
track: pointer.track,
|
||||
};
|
||||
},
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, contentOrigin],
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin],
|
||||
);
|
||||
|
||||
const handleAssetDrop = useCallback(
|
||||
|
||||
@@ -669,6 +669,38 @@ describe("resolveTimelineAutoScroll", () => {
|
||||
),
|
||||
).toEqual({ x: 9, y: 6 });
|
||||
});
|
||||
|
||||
it("uses the time-grid edge instead of the viewport edge when labels are sticky", () => {
|
||||
expect(
|
||||
resolveTimelineAutoScroll(
|
||||
{
|
||||
left: 100,
|
||||
top: 100,
|
||||
right: 700,
|
||||
bottom: 400,
|
||||
},
|
||||
340,
|
||||
250,
|
||||
232,
|
||||
),
|
||||
).toEqual({ x: -10, y: 0 });
|
||||
});
|
||||
|
||||
it("caps auto-scroll speed when the pointer moves inside the sticky labels", () => {
|
||||
expect(
|
||||
resolveTimelineAutoScroll(
|
||||
{
|
||||
left: 100,
|
||||
top: 100,
|
||||
right: 700,
|
||||
bottom: 400,
|
||||
},
|
||||
120,
|
||||
250,
|
||||
232,
|
||||
),
|
||||
).toEqual({ x: -12, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTimelineAgentPrompt", () => {
|
||||
|
||||
@@ -84,21 +84,24 @@ export function resolveTimelineAutoScroll(
|
||||
bounds: TimelineAutoScrollBounds,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
leftInset = 0,
|
||||
): { x: number; y: number } {
|
||||
const getAxisDelta = (start: number, end: number, pointer: number) => {
|
||||
if (pointer < start + AUTO_SCROLL_EDGE_ZONE) {
|
||||
const proximity = Math.max(0, 1 - (pointer - start) / AUTO_SCROLL_EDGE_ZONE);
|
||||
const proximity = Math.min(1, Math.max(0, 1 - (pointer - start) / AUTO_SCROLL_EDGE_ZONE));
|
||||
return -Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
|
||||
}
|
||||
if (pointer > end - AUTO_SCROLL_EDGE_ZONE) {
|
||||
const proximity = Math.max(0, 1 - (end - pointer) / AUTO_SCROLL_EDGE_ZONE);
|
||||
const proximity = Math.min(1, Math.max(0, 1 - (end - pointer) / AUTO_SCROLL_EDGE_ZONE));
|
||||
return Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const horizontalStart = Math.min(bounds.right, bounds.left + Math.max(0, leftInset));
|
||||
|
||||
return {
|
||||
x: getAxisDelta(bounds.left, bounds.right, clientX),
|
||||
x: getAxisDelta(horizontalStart, bounds.right, clientX),
|
||||
y: getAxisDelta(bounds.top, bounds.bottom, clientY),
|
||||
};
|
||||
}
|
||||
@@ -485,7 +488,41 @@ export function applyTimelineAutoScrollStep(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): boolean {
|
||||
const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
|
||||
return applyTimelineAutoScrollDelta(
|
||||
scroll,
|
||||
resolveTimelineAutoScroll(
|
||||
scroll.getBoundingClientRect(),
|
||||
clientX,
|
||||
clientY,
|
||||
getTimelineAutoScrollLeftInset(scroll),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply one horizontal edge-scroll step while scrubbing the ruler/playhead. */
|
||||
export function applyTimelineHorizontalAutoScrollStep(
|
||||
scroll: HTMLElement,
|
||||
clientX: number,
|
||||
): boolean {
|
||||
const bounds = scroll.getBoundingClientRect();
|
||||
const delta = resolveTimelineAutoScroll(
|
||||
bounds,
|
||||
clientX,
|
||||
bounds.top + (bounds.bottom - bounds.top) / 2,
|
||||
getTimelineAutoScrollLeftInset(scroll),
|
||||
);
|
||||
return applyTimelineAutoScrollDelta(scroll, { x: delta.x, y: 0 });
|
||||
}
|
||||
|
||||
function getTimelineAutoScrollLeftInset(scroll: HTMLElement): number {
|
||||
const inset = Number(scroll.dataset.timelineAutoScrollLeftInset);
|
||||
return Number.isFinite(inset) ? Math.max(0, inset) : 0;
|
||||
}
|
||||
|
||||
function applyTimelineAutoScrollDelta(
|
||||
scroll: HTMLElement,
|
||||
delta: { x: number; y: number },
|
||||
): boolean {
|
||||
if (delta.x === 0 && delta.y === 0) return false;
|
||||
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
||||
const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
|
||||
@@ -510,7 +547,12 @@ export function resolveTimelineAutoScrollLoopAction(
|
||||
rafActive: boolean,
|
||||
): "start" | "stop" | "none" {
|
||||
if (!scroll) return "none";
|
||||
const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
|
||||
const delta = resolveTimelineAutoScroll(
|
||||
scroll.getBoundingClientRect(),
|
||||
clientX,
|
||||
clientY,
|
||||
getTimelineAutoScrollLeftInset(scroll),
|
||||
);
|
||||
if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none";
|
||||
return rafActive ? "none" : "start";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { resolveTimelineFocusIdentity } from "./timelineFocusIdentity";
|
||||
|
||||
describe("timeline focus identity", () => {
|
||||
const elements: TimelineElement[] = [
|
||||
{ id: "clip/a", key: "stable:key", tag: "div", start: 0, duration: 1, track: 7.5 },
|
||||
];
|
||||
|
||||
it("resolves a stable element id and fractional display row from model identity", () => {
|
||||
expect(resolveTimelineFocusIdentity(elements, "stable:key")).toEqual({
|
||||
elementId: "stable:key",
|
||||
rowKey: 7.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invent focus for a missing or cleared identity", () => {
|
||||
expect(resolveTimelineFocusIdentity(elements, "missing")).toBeNull();
|
||||
expect(resolveTimelineFocusIdentity(elements, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
||||
|
||||
export interface TimelineFocusIdentity {
|
||||
readonly elementId: string;
|
||||
readonly rowKey: number;
|
||||
}
|
||||
|
||||
/** Resolve logical focus from model identity; mount state is deliberately irrelevant. */
|
||||
export function resolveTimelineFocusIdentity(
|
||||
elements: readonly TimelineElement[],
|
||||
elementId: string | null,
|
||||
): TimelineFocusIdentity | null {
|
||||
if (!elementId) return null;
|
||||
const element = getTimelineElementIndexes(elements).byKey.get(elementId);
|
||||
if (!element) return null;
|
||||
return {
|
||||
elementId,
|
||||
rowKey: element.track,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import type { TrackVisualStyle } from "./timelineIcons";
|
||||
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
||||
import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex";
|
||||
import type { TimelineRowGeometry } from "./timelineLayout";
|
||||
import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
|
||||
|
||||
/**
|
||||
* Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
|
||||
@@ -22,6 +25,12 @@ export interface TimelineLaneBaseProps {
|
||||
theme: TimelineTheme;
|
||||
displayTrackOrder: number[];
|
||||
rowHeights: readonly number[];
|
||||
rowGeometry: TimelineRowGeometry;
|
||||
virtualRows: readonly TimelineVirtualRow[];
|
||||
rowsVirtualized: boolean;
|
||||
clipIndex: TimelineClipIndex;
|
||||
renderTimeRange: TimelineTimeRange;
|
||||
pinnedClipIdentities: ReadonlySet<string>;
|
||||
trackOrder: number[];
|
||||
tracks: [number, TimelineElement[]][];
|
||||
trackStyles: Map<number, TrackVisualStyle>;
|
||||
|
||||
@@ -15,9 +15,50 @@ import {
|
||||
getTimelineRowFromY,
|
||||
getTimelineRowOffsets,
|
||||
getTimelineCanvasHeight,
|
||||
createTimelineRowGeometry,
|
||||
getTimelineRowGeometry,
|
||||
trackHeights,
|
||||
resolveTimelineAssetDrop,
|
||||
getTimelineBeatEntries,
|
||||
} from "./timelineLayout";
|
||||
import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
|
||||
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
|
||||
|
||||
describe("horizontal timeline window", () => {
|
||||
it("adds the shared quarter-viewport overscan on each side and clamps to duration", () => {
|
||||
expect(getTimelineRenderTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20)).toEqual(
|
||||
{ start: 0, end: 7.25 },
|
||||
);
|
||||
expect(
|
||||
getTimelineRenderTimeRange({ scrollLeft: 1_900, clientWidth: 500 }, 100, 200, 20),
|
||||
).toEqual({ start: 15.75, end: 20 });
|
||||
});
|
||||
|
||||
it("generates globally aligned ticks directly inside the bounded window", () => {
|
||||
const ticks = generateTicks(10_000, 100, undefined, { start: 500.2, end: 501.8 });
|
||||
const interval = getTimelineMajorTickInterval(10_000, 100);
|
||||
expect(ticks.major.every((time) => time >= 500.2 && time <= 501.8)).toBe(true);
|
||||
expect(
|
||||
ticks.major.every((time) => Math.abs(time / interval - Math.round(time / interval)) < 1e-6),
|
||||
).toBe(true);
|
||||
expect(ticks.major.length + ticks.minor.length).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("slices beat records with original strength indexes and unions a pinned beat", () => {
|
||||
expect(
|
||||
getTimelineBeatEntries(
|
||||
[0, 1, 2, 3],
|
||||
[0.1, 0.2, 0.3, 0.4],
|
||||
{ start: 1, end: 3 },
|
||||
new Set([3]),
|
||||
),
|
||||
).toEqual([
|
||||
{ index: 1, time: 1, strength: 0.2 },
|
||||
{ index: 2, time: 2, strength: 0.3 },
|
||||
{ index: 3, time: 3, strength: 0.4 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/** N collapsed rows, the shape every caller passes when nothing is expanded. */
|
||||
const baseRows = (count: number) => Array.from({ length: count }, () => TRACK_H);
|
||||
@@ -64,6 +105,24 @@ describe("variable timeline row geometry", () => {
|
||||
RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + 2 * LANE_H + TRACKS_BOTTOM_PAD,
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses one immutable geometry snapshot for one height array", () => {
|
||||
const heights = trackHeights(tracks, new Set(["b"]));
|
||||
const first = getTimelineRowGeometry(heights);
|
||||
expect(getTimelineRowGeometry(heights)).toBe(first);
|
||||
expect(Object.isFrozen(first)).toBe(true);
|
||||
expect(Object.isFrozen(first.rowOffsets)).toBe(true);
|
||||
});
|
||||
|
||||
it("looks up row boundaries through the precomputed geometry", () => {
|
||||
const geometry = createTimelineRowGeometry([4, 8, 12], [48, 104, 76]);
|
||||
expect(getTimelineRowGeometry(geometry.rowHeights)).toBe(geometry);
|
||||
expect(geometry.getRowIndex(8)).toBe(1);
|
||||
expect(geometry.getRowFromY(geometry.getRowTop(1))).toBe(1);
|
||||
expect(geometry.getRowFromY(geometry.getRowTop(2) - 0.001)).toBeLessThan(2);
|
||||
expect(geometry.getRowFromY(geometry.getRowTop(2))).toBe(2);
|
||||
expect(geometry.canvasHeight).toBe(RULER_H + TRACKS_TOP_PAD + 228 + TRACKS_BOTTOM_PAD);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapsed timeline row geometry characterization", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatTime } from "../lib/time";
|
||||
import type { ZoomMode } from "../store/playerStore";
|
||||
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
||||
|
||||
/* ── Layout constants ──────────────────────────────────────────────── */
|
||||
export const GUTTER = 32;
|
||||
@@ -10,6 +10,47 @@ export const RULER_H = 24;
|
||||
export const CLIP_Y = 3;
|
||||
export const CLIP_HANDLE_W = 18;
|
||||
|
||||
export interface TimelineBeatEntry {
|
||||
readonly index: number;
|
||||
readonly time: number;
|
||||
readonly strength: number | undefined;
|
||||
}
|
||||
|
||||
function findFirstTimeAtOrAfter(times: readonly number[], target: number): number {
|
||||
let low = 0;
|
||||
let high = times.length;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if ((times[mid] ?? Number.POSITIVE_INFINITY) < target) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
/** Slice sorted beat data without allocating entries outside the render window. */
|
||||
export function getTimelineBeatEntries(
|
||||
beatTimes: readonly number[] | undefined,
|
||||
beatStrengths: readonly number[] | undefined,
|
||||
range: TimelineTimeRange | undefined,
|
||||
pinnedIndexes: ReadonlySet<number> = new Set(),
|
||||
): readonly TimelineBeatEntry[] {
|
||||
if (!beatTimes?.length) return [];
|
||||
const start = range?.start ?? Number.NEGATIVE_INFINITY;
|
||||
const end = range?.end ?? Number.POSITIVE_INFINITY;
|
||||
const selected = new Set<number>();
|
||||
for (let index = findFirstTimeAtOrAfter(beatTimes, start); index < beatTimes.length; index++) {
|
||||
const time = beatTimes[index];
|
||||
if (time === undefined || time >= end) break;
|
||||
selected.add(index);
|
||||
}
|
||||
for (const index of pinnedIndexes) {
|
||||
if (index >= 0 && index < beatTimes.length) selected.add(index);
|
||||
}
|
||||
return [...selected]
|
||||
.sort((left, right) => left - right)
|
||||
.map((index) => ({ index, time: beatTimes[index]!, strength: beatStrengths?.[index] }));
|
||||
}
|
||||
|
||||
export function getTimelineLaneTop(laneIndex: number): number {
|
||||
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
|
||||
}
|
||||
@@ -74,55 +115,134 @@ function validRowHeight(height: number | undefined): number {
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized by the rowHeights array identity: a marquee/drag pointer tick calls
|
||||
* getTimelineRowTop once per clip, and each call would otherwise rebuild the
|
||||
* whole cumulative array (O(clips x rows) allocations per tick). rowHeights is
|
||||
* itself memoized upstream (useTimelineTrackLayout), so the identity is stable
|
||||
* for the life of a gesture. Callers must treat the result as read-only.
|
||||
*/
|
||||
const rowOffsetsCache = new WeakMap<readonly number[], number[]>();
|
||||
export interface TimelineRowGeometry {
|
||||
readonly rowKeys: readonly number[];
|
||||
readonly rowHeights: readonly number[];
|
||||
/** Cumulative row boundaries, including the final bottom boundary. */
|
||||
readonly rowOffsets: readonly number[];
|
||||
readonly rowsHeight: number;
|
||||
readonly canvasHeight: number;
|
||||
getRowIndex(rowKey: number): number;
|
||||
getRowHeight(row: number): number;
|
||||
getRowTop(row: number): number;
|
||||
getRowFromY(contentY: number): number;
|
||||
getRowPositionFromY(contentY: number): {
|
||||
rowFloat: number;
|
||||
row: number;
|
||||
fraction: number;
|
||||
rowHeight: number;
|
||||
};
|
||||
}
|
||||
|
||||
const rowGeometryCache = new WeakMap<readonly number[], TimelineRowGeometry>();
|
||||
const EMPTY_ROW_HEIGHTS: readonly number[] = Object.freeze([]);
|
||||
|
||||
/** Build the immutable row snapshot shared by rendering and hit testing. */
|
||||
export function createTimelineRowGeometry(
|
||||
rowKeys: readonly number[],
|
||||
rowHeights: readonly number[],
|
||||
): TimelineRowGeometry {
|
||||
const heights = Object.freeze(rowHeights.map(validRowHeight));
|
||||
const keys = Object.freeze(
|
||||
heights.map((_, row) => {
|
||||
const key = rowKeys[row];
|
||||
return key !== undefined && Number.isFinite(key) ? key : row;
|
||||
}),
|
||||
);
|
||||
const offsets = [0];
|
||||
for (const height of heights) offsets.push((offsets.at(-1) ?? 0) + height);
|
||||
Object.freeze(offsets);
|
||||
const rowIndexByKey = new Map(keys.map((key, row) => [key, row]));
|
||||
|
||||
const getRowHeight = (row: number) => validRowHeight(heights[row]);
|
||||
const getRowOffset = (row: number) => {
|
||||
if (heights.length === 0) return row * TRACK_H;
|
||||
if (row <= 0) return row * getRowHeight(0);
|
||||
if (row >= heights.length) {
|
||||
return (offsets[heights.length] ?? 0) + (row - heights.length) * TRACK_H;
|
||||
}
|
||||
const wholeRow = Math.floor(row);
|
||||
return (offsets[wholeRow] ?? 0) + (row - wholeRow) * getRowHeight(wholeRow);
|
||||
};
|
||||
const getRowFromY = (contentY: number) => {
|
||||
const y = contentY - RULER_H - TRACKS_TOP_PAD;
|
||||
if (heights.length === 0) return y / TRACK_H;
|
||||
if (y < 0) return y / getRowHeight(0);
|
||||
const rowsHeight = offsets[heights.length] ?? 0;
|
||||
if (y >= rowsHeight) return heights.length + (y - rowsHeight) / TRACK_H;
|
||||
|
||||
// First boundary strictly greater than y. Unlike the old linear scan this
|
||||
// stays logarithmic for large timelines and uses the precomputed offsets.
|
||||
let low = 1;
|
||||
let high = heights.length;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if ((offsets[mid] ?? 0) > y) high = mid;
|
||||
else low = mid + 1;
|
||||
}
|
||||
const row = low - 1;
|
||||
return row + (y - (offsets[row] ?? 0)) / getRowHeight(row);
|
||||
};
|
||||
const geometry: TimelineRowGeometry = {
|
||||
rowKeys: keys,
|
||||
rowHeights: heights,
|
||||
rowOffsets: offsets,
|
||||
rowsHeight: offsets.at(-1) ?? 0,
|
||||
canvasHeight: RULER_H + TRACKS_TOP_PAD + (offsets.at(-1) ?? 0) + TRACKS_BOTTOM_PAD,
|
||||
getRowIndex: (rowKey) => rowIndexByKey.get(rowKey) ?? -1,
|
||||
getRowHeight,
|
||||
getRowTop: (row) => RULER_H + TRACKS_TOP_PAD + getRowOffset(row),
|
||||
getRowFromY,
|
||||
getRowPositionFromY: (contentY) => {
|
||||
const rowFloat = getRowFromY(contentY);
|
||||
const row = Math.floor(rowFloat);
|
||||
return { rowFloat, row, fraction: rowFloat - row, rowHeight: getRowHeight(row) };
|
||||
},
|
||||
};
|
||||
const frozenGeometry = Object.freeze(geometry);
|
||||
rowGeometryCache.set(heights, frozenGeometry);
|
||||
return frozenGeometry;
|
||||
}
|
||||
|
||||
/** Compatibility accessor; repeated calls for one height-array reuse one snapshot. */
|
||||
export function getTimelineRowGeometry(rowHeights: readonly number[]): TimelineRowGeometry {
|
||||
const cached = rowGeometryCache.get(rowHeights);
|
||||
if (cached) return cached;
|
||||
const geometry = createTimelineRowGeometry(
|
||||
rowHeights.map((_, row) => row),
|
||||
rowHeights,
|
||||
);
|
||||
rowGeometryCache.set(rowHeights, geometry);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** Cumulative top offsets, including the final bottom boundary. */
|
||||
export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] {
|
||||
const cached = rowOffsetsCache.get(rowHeights);
|
||||
if (cached) return cached;
|
||||
const offsets = [0];
|
||||
for (const height of rowHeights) {
|
||||
offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height));
|
||||
}
|
||||
rowOffsetsCache.set(rowHeights, offsets);
|
||||
return offsets;
|
||||
return [...getTimelineRowGeometry(rowHeights).rowOffsets];
|
||||
}
|
||||
|
||||
export function getTimelineRowHeight(row: number, rowHeights: readonly number[] = []): number {
|
||||
export function getTimelineRowHeight(
|
||||
row: number,
|
||||
rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
|
||||
): number {
|
||||
return validRowHeight(rowHeights[row]);
|
||||
}
|
||||
|
||||
function getTimelineRowOffset(row: number, rowHeights: readonly number[]): number {
|
||||
if (rowHeights.length === 0) return row * TRACK_H;
|
||||
const offsets = getTimelineRowOffsets(rowHeights);
|
||||
if (row <= 0) return row * getTimelineRowHeight(0, rowHeights);
|
||||
if (row >= rowHeights.length) {
|
||||
// Deliberately TRACK_H, not the last row's height: rows past the end do not
|
||||
// exist yet, and a row created by dropping there starts unexpanded. The
|
||||
// pre-first-row branch above uses row 0's concrete height instead because
|
||||
// that row DOES exist — the pointer is in the top pad above a real lane.
|
||||
return (offsets[rowHeights.length] ?? 0) + (row - rowHeights.length) * TRACK_H;
|
||||
}
|
||||
const wholeRow = Math.floor(row);
|
||||
const fraction = row - wholeRow;
|
||||
return (offsets[wholeRow] ?? 0) + fraction * getTimelineRowHeight(wholeRow, rowHeights);
|
||||
return getTimelineRowGeometry(rowHeights).getRowTop(row) - RULER_H - TRACKS_TOP_PAD;
|
||||
}
|
||||
|
||||
/**
|
||||
* The y (content-space) of the top edge of track ROW index `row` (0 = first
|
||||
* displayed lane). The single source of truth for row→y — the ruler height plus
|
||||
* displayed lane). The single source of truth for row->y: the ruler height plus
|
||||
* the top breathing pad plus whole track lanes above it. Every clip/ghost/
|
||||
* placeholder/insertion top and every pointer-y→row inversion goes through this
|
||||
* placeholder/insertion top and every pointer-y->row inversion goes through this
|
||||
* (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
|
||||
*/
|
||||
export function getTimelineRowTop(row: number, rowHeights: readonly number[] = []): number {
|
||||
export function getTimelineRowTop(
|
||||
row: number,
|
||||
rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
|
||||
): number {
|
||||
return RULER_H + TRACKS_TOP_PAD + getTimelineRowOffset(row, rowHeights);
|
||||
}
|
||||
|
||||
@@ -131,34 +251,18 @@ export function getTimelineRowTop(row: number, rowHeights: readonly number[] = [
|
||||
* space y (used for insert-row / drop-lane decisions). Locates the concrete row
|
||||
* from cumulative offsets, then returns its local fractional position.
|
||||
*/
|
||||
export function getTimelineRowFromY(contentY: number, rowHeights: readonly number[] = []): number {
|
||||
const y = contentY - RULER_H - TRACKS_TOP_PAD;
|
||||
if (rowHeights.length === 0) return y / TRACK_H;
|
||||
if (y < 0) return y / getTimelineRowHeight(0, rowHeights);
|
||||
|
||||
const offsets = getTimelineRowOffsets(rowHeights);
|
||||
for (let row = 0; row < rowHeights.length; row += 1) {
|
||||
const bottom = offsets[row + 1] ?? 0;
|
||||
if (y < bottom) {
|
||||
const top = offsets[row] ?? 0;
|
||||
return row + (y - top) / getTimelineRowHeight(row, rowHeights);
|
||||
}
|
||||
}
|
||||
return rowHeights.length + (y - (offsets[rowHeights.length] ?? 0)) / TRACK_H;
|
||||
export function getTimelineRowFromY(
|
||||
contentY: number,
|
||||
rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
|
||||
): number {
|
||||
return getTimelineRowGeometry(rowHeights).getRowFromY(contentY);
|
||||
}
|
||||
|
||||
export function getTimelineRowPositionFromY(
|
||||
contentY: number,
|
||||
rowHeights: readonly number[] = [],
|
||||
rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
|
||||
): { rowFloat: number; row: number; fraction: number; rowHeight: number } {
|
||||
const rowFloat = getTimelineRowFromY(contentY, rowHeights);
|
||||
const row = Math.floor(rowFloat);
|
||||
return {
|
||||
rowFloat,
|
||||
row,
|
||||
fraction: rowFloat - row,
|
||||
rowHeight: getTimelineRowHeight(row, rowHeights),
|
||||
};
|
||||
return getTimelineRowGeometry(rowHeights).getRowPositionFromY(contentY);
|
||||
}
|
||||
|
||||
/** Fractional insert band for the concrete row under a pointer. */
|
||||
@@ -193,123 +297,6 @@ export const MIN_TIMELINE_EXTENT_S = 60;
|
||||
export const FIT_ZOOM_HEADROOM = 1.2;
|
||||
|
||||
/* ── Tick generation ──────────────────────────────────────────────── */
|
||||
// fallow-ignore-next-line complexity
|
||||
function getMajorTickInterval(
|
||||
duration: number,
|
||||
pixelsPerSecond?: number,
|
||||
frameRate?: number,
|
||||
): number {
|
||||
// "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
|
||||
// minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
|
||||
// readable (non-colliding) labels instead of the old 10m fallback everywhere.
|
||||
const zoomIntervals = [
|
||||
0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
|
||||
];
|
||||
let interval: number;
|
||||
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
||||
const targetMajorPx = 88;
|
||||
interval =
|
||||
zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
|
||||
3600;
|
||||
} else {
|
||||
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
||||
const target = duration / 6;
|
||||
interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
|
||||
}
|
||||
// Frame display mode: labels are frame numbers, so a major step must be a
|
||||
// WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
|
||||
// (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
|
||||
// the label spacing never drops below the readability target.
|
||||
if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
|
||||
const fps = frameRate ?? 0;
|
||||
return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
|
||||
}
|
||||
return interval;
|
||||
}
|
||||
|
||||
// How many equal parts to split each major interval into for minor ticks. Prefer
|
||||
// quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
|
||||
// none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
|
||||
// the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
|
||||
// sub-frame time is not a seekable position), so only divisors of the major
|
||||
// step's frame count qualify — quarters, then fifths (15/30-frame majors),
|
||||
// thirds, halves.
|
||||
// fallow-ignore-next-line complexity
|
||||
function getMinorSubdivisions(
|
||||
majorInterval: number,
|
||||
pixelsPerSecond?: number,
|
||||
frameRate?: number,
|
||||
): number {
|
||||
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
|
||||
if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
|
||||
const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
|
||||
const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
|
||||
const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
|
||||
for (const parts of candidates) {
|
||||
if (fps > 0 && majorFrames % parts !== 0) continue;
|
||||
if ((majorInterval / parts) * pps >= 8) return parts;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Ticks are exact multiples of the interval (multiplied per index, never
|
||||
// accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
|
||||
// keep values/keys clean without disturbing frame-exact positions like 2/30s.
|
||||
function roundTickValue(t: number): number {
|
||||
return Math.round(t * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
export function generateTicks(
|
||||
duration: number,
|
||||
pixelsPerSecond?: number,
|
||||
frameRate?: number,
|
||||
): { major: number[]; minor: number[] } {
|
||||
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
|
||||
return { major: [], minor: [] };
|
||||
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
|
||||
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
|
||||
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
|
||||
const major: number[] = [];
|
||||
const minor: number[] = [];
|
||||
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
||||
for (let i = 0; major.length < maxTicks; i++) {
|
||||
const t = i * majorInterval;
|
||||
if (t > duration + 0.001) break;
|
||||
major.push(roundTickValue(t));
|
||||
// Emit the (subdivisions - 1) minor ticks between this major and the next.
|
||||
for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
|
||||
const m = t + k * minorInterval;
|
||||
if (m <= duration + 0.001) minor.push(roundTickValue(m));
|
||||
}
|
||||
}
|
||||
return { major, minor };
|
||||
}
|
||||
|
||||
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
||||
if (!Number.isFinite(time)) return "00:00";
|
||||
const safeTime = Math.max(0, time);
|
||||
if (majorInterval < 0.1) {
|
||||
const totalHundredths = Math.round(safeTime * 100);
|
||||
const wholeSeconds = Math.floor(totalHundredths / 100);
|
||||
const hundredth = totalHundredths % 100;
|
||||
return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
|
||||
}
|
||||
if (majorInterval < 1) {
|
||||
const totalTenths = Math.round(safeTime * 10);
|
||||
const wholeSeconds = Math.floor(totalTenths / 10);
|
||||
const tenth = totalTenths % 10;
|
||||
return `${formatTime(wholeSeconds)}.${tenth}`;
|
||||
}
|
||||
if (duration >= 3600 || safeTime >= 3600) {
|
||||
const totalSeconds = Math.floor(safeTime);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return formatTime(safeTime);
|
||||
}
|
||||
|
||||
/* ── Width / duration derivation ──────────────────────────────────── */
|
||||
/**
|
||||
* Fit-mode pixels-per-second: fill the viewport with the composition plus
|
||||
@@ -473,13 +460,52 @@ export function getTimelineScrubTime(input: {
|
||||
});
|
||||
return Math.max(0, Math.min(duration, x / pixelsPerSecond));
|
||||
}
|
||||
const PLAYBACK_FOLLOW_POSITION = 0.75;
|
||||
|
||||
/**
|
||||
* Keep a playing timeline calm until the playhead crosses the right-side
|
||||
* comfort line, then advance the viewport just enough to hold it there. A
|
||||
* loop/reverse jump that leaves the playhead behind the sticky labels restores
|
||||
* the matching earlier viewport instead.
|
||||
*/
|
||||
export function getTimelinePlaybackFollowScrollLeft(input: {
|
||||
playheadX: number;
|
||||
currentScrollLeft: number;
|
||||
viewportWidth: number;
|
||||
contentOrigin: number;
|
||||
maxScrollLeft: number;
|
||||
}): number {
|
||||
const current = Number.isFinite(input.currentScrollLeft)
|
||||
? Math.max(0, input.currentScrollLeft)
|
||||
: 0;
|
||||
const max = Number.isFinite(input.maxScrollLeft) ? Math.max(0, input.maxScrollLeft) : 0;
|
||||
const visibleTimelineWidth = input.viewportWidth - input.contentOrigin;
|
||||
if (
|
||||
!Number.isFinite(input.playheadX) ||
|
||||
!Number.isFinite(input.contentOrigin) ||
|
||||
!Number.isFinite(visibleTimelineWidth) ||
|
||||
visibleTimelineWidth <= 0 ||
|
||||
max <= 0
|
||||
) {
|
||||
return Math.min(max, current);
|
||||
}
|
||||
|
||||
const visibleStart = current + input.contentOrigin;
|
||||
const followLine = visibleStart + visibleTimelineWidth * PLAYBACK_FOLLOW_POSITION;
|
||||
let next = current;
|
||||
if (input.playheadX > followLine) {
|
||||
next = input.playheadX - input.contentOrigin - visibleTimelineWidth * PLAYBACK_FOLLOW_POSITION;
|
||||
} else if (input.playheadX < visibleStart) {
|
||||
next = input.playheadX - input.contentOrigin;
|
||||
}
|
||||
return Math.max(0, Math.min(max, next));
|
||||
}
|
||||
|
||||
export function getTimelineCanvasHeight(rowHeights: readonly number[]): number {
|
||||
// RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
|
||||
// subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
|
||||
// below the last lane is real scrollable surface, not a hidden buffer.
|
||||
const rowsHeight = getTimelineRowOffsets(rowHeights).at(-1) ?? 0;
|
||||
return RULER_H + TRACKS_TOP_PAD + rowsHeight + TRACKS_BOTTOM_PAD;
|
||||
return getTimelineRowGeometry(rowHeights).canvasHeight;
|
||||
}
|
||||
|
||||
/* ── UI helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Row virtualization opt-in. Disabled until horizontal windowing and stable
|
||||
* gesture lifetime land.
|
||||
*
|
||||
* It lives in its own module so the scroll-viewport hook can read it without
|
||||
* importing the virtualization hook that already imports the viewport snapshot
|
||||
* type back, which would close an import cycle.
|
||||
*/
|
||||
export const STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED =
|
||||
import.meta.env.DEV === true &&
|
||||
import.meta.env.VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED === "1";
|
||||
@@ -0,0 +1,142 @@
|
||||
import { formatTime } from "../lib/time";
|
||||
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function getTimelineMajorTickInterval(
|
||||
duration: number,
|
||||
pixelsPerSecond?: number,
|
||||
frameRate?: number,
|
||||
): number {
|
||||
// "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
|
||||
// minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
|
||||
// readable (non-colliding) labels instead of the old 10m fallback everywhere.
|
||||
const zoomIntervals = [
|
||||
0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
|
||||
];
|
||||
let interval: number;
|
||||
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
||||
const targetMajorPx = 88;
|
||||
interval =
|
||||
zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
|
||||
3600;
|
||||
} else {
|
||||
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
||||
const target = duration / 6;
|
||||
interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
|
||||
}
|
||||
// Frame display mode: labels are frame numbers, so a major step must be a
|
||||
// WHOLE number of frames. Snap UP so label spacing stays readable.
|
||||
if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
|
||||
const fps = frameRate ?? 0;
|
||||
return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
|
||||
}
|
||||
return interval;
|
||||
}
|
||||
|
||||
// Prefer quarter subdivisions so the midpoint remains visible; fall back to
|
||||
// smaller whole-frame-compatible sets as ticks become too dense to read.
|
||||
// fallow-ignore-next-line complexity
|
||||
function getMinorSubdivisions(
|
||||
majorInterval: number,
|
||||
pixelsPerSecond?: number,
|
||||
frameRate?: number,
|
||||
): number {
|
||||
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
|
||||
if (pps <= 0) return 4;
|
||||
const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
|
||||
const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
|
||||
const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
|
||||
for (const parts of candidates) {
|
||||
if (fps > 0 && majorFrames % parts !== 0) continue;
|
||||
if ((majorInterval / parts) * pps >= 8) return parts;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Multiply from the index rather than accumulating, then round to 1µs so long
|
||||
// rulers do not drift and frame-exact positions keep clean values and keys.
|
||||
function roundTickValue(time: number): number {
|
||||
return Math.round(time * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
function isSupportedTickDuration(duration: number): boolean {
|
||||
return duration > 0 && Number.isFinite(duration) && duration <= 14400;
|
||||
}
|
||||
|
||||
function getTickRange(duration: number, range?: TimelineTimeRange): TimelineTimeRange {
|
||||
return {
|
||||
start: Math.max(0, range?.start ?? 0),
|
||||
end: Math.min(duration, range?.end ?? duration),
|
||||
};
|
||||
}
|
||||
|
||||
function appendMinorTicks(
|
||||
minor: number[],
|
||||
majorTime: number,
|
||||
minorInterval: number,
|
||||
subdivisions: number,
|
||||
range: TimelineTimeRange,
|
||||
maxTicks: number,
|
||||
majorCount: number,
|
||||
): void {
|
||||
for (let part = 1; part < subdivisions && majorCount + minor.length < maxTicks; part++) {
|
||||
const time = majorTime + part * minorInterval;
|
||||
if (time >= range.start - 0.001 && time <= range.end + 0.001) {
|
||||
minor.push(roundTickValue(time));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generateTicks(
|
||||
duration: number,
|
||||
pixelsPerSecond?: number,
|
||||
frameRate?: number,
|
||||
range?: TimelineTimeRange,
|
||||
): { major: number[]; minor: number[] } {
|
||||
if (!isSupportedTickDuration(duration)) return { major: [], minor: [] };
|
||||
const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate);
|
||||
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
|
||||
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
|
||||
const major: number[] = [];
|
||||
const minor: number[] = [];
|
||||
// Safety cap prevents malformed inputs from creating an unbounded ruler.
|
||||
const maxTicks = 2000;
|
||||
const tickRange = getTickRange(duration, range);
|
||||
const firstMajorIndex = Math.max(0, Math.floor(tickRange.start / majorInterval));
|
||||
for (let index = firstMajorIndex; major.length < maxTicks; index++) {
|
||||
const time = index * majorInterval;
|
||||
if (time > tickRange.end + 0.001) break;
|
||||
if (time >= tickRange.start - 0.001) major.push(roundTickValue(time));
|
||||
appendMinorTicks(minor, time, minorInterval, subdivisions, tickRange, maxTicks, major.length);
|
||||
}
|
||||
return { major, minor };
|
||||
}
|
||||
|
||||
export function formatTimelineTickLabel(
|
||||
time: number,
|
||||
duration: number,
|
||||
majorInterval: number,
|
||||
): string {
|
||||
if (!Number.isFinite(time)) return "00:00";
|
||||
const safeTime = Math.max(0, time);
|
||||
if (majorInterval < 0.1) {
|
||||
const totalHundredths = Math.round(safeTime * 100);
|
||||
const wholeSeconds = Math.floor(totalHundredths / 100);
|
||||
const hundredth = totalHundredths % 100;
|
||||
return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
|
||||
}
|
||||
if (majorInterval < 1) {
|
||||
const totalTenths = Math.round(safeTime * 10);
|
||||
const wholeSeconds = Math.floor(totalTenths / 10);
|
||||
const tenth = totalTenths % 10;
|
||||
return `${formatTime(wholeSeconds)}.${tenth}`;
|
||||
}
|
||||
if (duration >= 3600 || safeTime >= 3600) {
|
||||
const totalSeconds = Math.floor(safeTime);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return formatTime(safeTime);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import type { ResizingClipState } from "./timelineClipDragTypes";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { animationContributesLane } from "./TimelinePropertyLanes";
|
||||
|
||||
export function hasKeyframedTimelineClips(
|
||||
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
|
||||
): boolean {
|
||||
return Array.from(animationsByElement.values()).some((animations) =>
|
||||
animations.some(animationContributesLane),
|
||||
);
|
||||
}
|
||||
|
||||
export function getEffectiveTimelineDuration(
|
||||
duration: number,
|
||||
elements: readonly TimelineElement[],
|
||||
): number {
|
||||
const safeDuration = Number.isFinite(duration) ? duration : 0;
|
||||
if (elements.length === 0) return safeDuration;
|
||||
const result = Math.max(
|
||||
safeDuration,
|
||||
...elements.map((element) => element.start + element.duration),
|
||||
);
|
||||
return Number.isFinite(result) ? result : safeDuration;
|
||||
}
|
||||
|
||||
export function getTimelinePreviewElement(
|
||||
element: TimelineElement,
|
||||
resizingClip: ResizingClipState | null,
|
||||
): TimelineElement {
|
||||
if (
|
||||
resizingClip &&
|
||||
getTimelineElementIdentity(resizingClip.element) === getTimelineElementIdentity(element)
|
||||
) {
|
||||
return {
|
||||
...element,
|
||||
start: resizingClip.previewStart,
|
||||
duration: resizingClip.previewDuration,
|
||||
playbackStart: resizingClip.previewPlaybackStart,
|
||||
};
|
||||
}
|
||||
return element;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { RULER_H, type TimelineRowGeometry } from "./timelineLayout";
|
||||
import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
|
||||
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
||||
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
||||
|
||||
export function getTimelineRenderTimeRange(
|
||||
viewport: Pick<TimelineScrollViewportSnapshot, "scrollLeft" | "clientWidth">,
|
||||
pixelsPerSecond: number,
|
||||
contentOrigin: number,
|
||||
duration: number,
|
||||
): TimelineTimeRange {
|
||||
if (!(pixelsPerSecond > 0) || !(duration > 0) || !(viewport.clientWidth > 0)) {
|
||||
return { start: 0, end: 0 };
|
||||
}
|
||||
const overscanPx = viewport.clientWidth * TIMELINE_VIEWPORT_BUDGETS.timeOverscanViewportRatio;
|
||||
const startPx = viewport.scrollLeft - contentOrigin - overscanPx;
|
||||
const endPx = viewport.scrollLeft + viewport.clientWidth - contentOrigin + overscanPx;
|
||||
return {
|
||||
start: Math.min(duration, Math.max(0, startPx / pixelsPerSecond)),
|
||||
end: Math.min(duration, Math.max(0, endPx / pixelsPerSecond)),
|
||||
};
|
||||
}
|
||||
|
||||
export function getTimelineVisibleTimeRange(
|
||||
viewport: Pick<TimelineScrollViewportSnapshot, "scrollLeft" | "clientWidth">,
|
||||
pixelsPerSecond: number,
|
||||
contentOrigin: number,
|
||||
duration: number,
|
||||
): { start: number; end: number } {
|
||||
if (!(pixelsPerSecond > 0) || !(duration > 0)) return { start: 0, end: 0 };
|
||||
const start = Math.max(0, (viewport.scrollLeft - contentOrigin) / pixelsPerSecond);
|
||||
const end = Math.min(
|
||||
duration,
|
||||
Math.max(start, (viewport.scrollLeft + viewport.clientWidth - contentOrigin) / pixelsPerSecond),
|
||||
);
|
||||
return { start: Math.min(start, duration), end };
|
||||
}
|
||||
|
||||
export function getTimelineScrollTopForGeometryChange(
|
||||
previous: TimelineRowGeometry,
|
||||
next: TimelineRowGeometry,
|
||||
scrollTop: number,
|
||||
): number {
|
||||
const anchor = previous.getRowPositionFromY(scrollTop + RULER_H);
|
||||
if (anchor.row < 0 || anchor.row >= previous.rowKeys.length) return scrollTop;
|
||||
const anchorKey = previous.rowKeys[anchor.row];
|
||||
if (anchorKey === undefined) return scrollTop;
|
||||
const nextRow = next.getRowIndex(anchorKey);
|
||||
if (nextRow < 0) return scrollTop;
|
||||
return Math.max(0, scrollTop + next.getRowTop(nextRow) - previous.getRowTop(anchor.row));
|
||||
}
|
||||
@@ -2,34 +2,45 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampTimelineZoomPercent,
|
||||
computePinnedZoomPercent,
|
||||
getMaxTimelineZoomPercent,
|
||||
getNextTimelineZoomPercent,
|
||||
getPinchTimelineZoomPercent,
|
||||
getTimelinePixelsPerSecond,
|
||||
getTimelineZoomPercent,
|
||||
MAX_TIMELINE_ZOOM_PERCENT,
|
||||
MIN_TIMELINE_ZOOM_PERCENT,
|
||||
timelineZoomPercentToSlider,
|
||||
timelineSliderToZoomPercent,
|
||||
} from "./timelineZoom";
|
||||
|
||||
const FIT_PPS = 12;
|
||||
const MAX_ZOOM_PERCENT = 12_000;
|
||||
|
||||
describe("clampTimelineZoomPercent", () => {
|
||||
it("defaults invalid values to 100", () => {
|
||||
expect(clampTimelineZoomPercent(Number.NaN)).toBe(100);
|
||||
expect(clampTimelineZoomPercent(Number.NaN, FIT_PPS)).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps to the supported percent bounds", () => {
|
||||
expect(clampTimelineZoomPercent(1)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
||||
expect(clampTimelineZoomPercent(5000)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
||||
expect(clampTimelineZoomPercent(1, FIT_PPS)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
||||
expect(clampTimelineZoomPercent(100_000, FIT_PPS)).toBe(MAX_ZOOM_PERCENT);
|
||||
});
|
||||
|
||||
it("zooms to a readable width for each 30fps frame instead of stopping at 2000%", () => {
|
||||
const fitPixelsPerSecond = 12;
|
||||
const percent = clampTimelineZoomPercent(100_000, fitPixelsPerSecond);
|
||||
|
||||
expect(percent).toBe(12_000);
|
||||
expect(getTimelinePixelsPerSecond(fitPixelsPerSecond, "manual", percent)).toBe(1_440);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineZoomPercent", () => {
|
||||
it("treats fit mode as 100 percent", () => {
|
||||
expect(getTimelineZoomPercent("fit", 375)).toBe(100);
|
||||
expect(getTimelineZoomPercent("fit", 375, FIT_PPS)).toBe(100);
|
||||
});
|
||||
|
||||
it("returns the clamped manual zoom percent", () => {
|
||||
expect(getTimelineZoomPercent("manual", 125.2)).toBe(125);
|
||||
expect(getTimelineZoomPercent("manual", 125.2, FIT_PPS)).toBe(125);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,68 +56,71 @@ describe("getTimelinePixelsPerSecond", () => {
|
||||
|
||||
describe("getNextTimelineZoomPercent", () => {
|
||||
it("zooms out from fit relative to 100 percent", () => {
|
||||
expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(50);
|
||||
expect(getNextTimelineZoomPercent("out", "fit", 375, FIT_PPS)).toBe(50);
|
||||
});
|
||||
|
||||
it("zooms in from fit relative to 100 percent", () => {
|
||||
expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(200);
|
||||
expect(getNextTimelineZoomPercent("in", "fit", 375, FIT_PPS)).toBe(200);
|
||||
});
|
||||
|
||||
it("clamps the lower bound", () => {
|
||||
expect(getNextTimelineZoomPercent("out", "manual", MIN_TIMELINE_ZOOM_PERCENT)).toBe(
|
||||
expect(getNextTimelineZoomPercent("out", "manual", MIN_TIMELINE_ZOOM_PERCENT, FIT_PPS)).toBe(
|
||||
MIN_TIMELINE_ZOOM_PERCENT,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps the upper bound", () => {
|
||||
expect(getNextTimelineZoomPercent("in", "manual", MAX_TIMELINE_ZOOM_PERCENT)).toBe(
|
||||
MAX_TIMELINE_ZOOM_PERCENT,
|
||||
expect(getNextTimelineZoomPercent("in", "manual", MAX_ZOOM_PERCENT, FIT_PPS)).toBe(
|
||||
MAX_ZOOM_PERCENT,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPinchTimelineZoomPercent", () => {
|
||||
it("zooms in for upward pinch wheel deltas", () => {
|
||||
expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100);
|
||||
expect(getPinchTimelineZoomPercent(-80, "fit", 100, FIT_PPS)).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("zooms out for downward pinch wheel deltas", () => {
|
||||
expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200);
|
||||
expect(getPinchTimelineZoomPercent(80, "manual", 200, FIT_PPS)).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it("keeps the current zoom for zero or invalid deltas", () => {
|
||||
expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180);
|
||||
expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180);
|
||||
expect(getPinchTimelineZoomPercent(0, "manual", 180, FIT_PPS)).toBe(180);
|
||||
expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180, FIT_PPS)).toBe(180);
|
||||
});
|
||||
|
||||
it("clamps pinch zoom to the supported range", () => {
|
||||
expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
||||
expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
||||
expect(getPinchTimelineZoomPercent(10000, "manual", 100, FIT_PPS)).toBe(
|
||||
MIN_TIMELINE_ZOOM_PERCENT,
|
||||
);
|
||||
expect(getPinchTimelineZoomPercent(-10000, "manual", 100, FIT_PPS)).toBe(MAX_ZOOM_PERCENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timelineZoomPercentToSlider", () => {
|
||||
it("maps min zoom to slider position 0", () => {
|
||||
expect(timelineZoomPercentToSlider(MIN_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(0, 5);
|
||||
expect(timelineZoomPercentToSlider(MIN_TIMELINE_ZOOM_PERCENT, FIT_PPS)).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it("maps max zoom to slider position 100", () => {
|
||||
expect(timelineZoomPercentToSlider(MAX_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(100, 5);
|
||||
expect(timelineZoomPercentToSlider(MAX_ZOOM_PERCENT, FIT_PPS)).toBeCloseTo(100, 5);
|
||||
});
|
||||
|
||||
it("maps 100% to the log midpoint between 10 and 2000", () => {
|
||||
const expected = ((Math.log(100) - Math.log(10)) / (Math.log(2000) - Math.log(10))) * 100;
|
||||
expect(timelineZoomPercentToSlider(100)).toBeCloseTo(expected, 3);
|
||||
it("maps 100% onto the dynamic log range", () => {
|
||||
const expected =
|
||||
((Math.log(100) - Math.log(10)) / (Math.log(MAX_ZOOM_PERCENT) - Math.log(10))) * 100;
|
||||
expect(timelineZoomPercentToSlider(100, FIT_PPS)).toBeCloseTo(expected, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timelineSliderToZoomPercent", () => {
|
||||
it("maps slider 0 to min zoom", () => {
|
||||
expect(timelineSliderToZoomPercent(0)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
||||
expect(timelineSliderToZoomPercent(0, FIT_PPS)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
||||
});
|
||||
|
||||
it("maps slider 100 to max zoom", () => {
|
||||
expect(timelineSliderToZoomPercent(100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
||||
expect(timelineSliderToZoomPercent(100, FIT_PPS)).toBe(MAX_ZOOM_PERCENT);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +140,7 @@ describe("computePinnedZoomPercent", () => {
|
||||
|
||||
it("clamps a pin that would exceed the manual-zoom bounds", () => {
|
||||
// currentPps 10000 / fitPps 1 = 1_000_000% → clamped to MAX.
|
||||
expect(computePinnedZoomPercent(10000, 1)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
||||
expect(computePinnedZoomPercent(10000, 1)).toBe(getMaxTimelineZoomPercent(1));
|
||||
// Tiny ratio → clamped up to MIN.
|
||||
expect(computePinnedZoomPercent(0.001, 1000)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
||||
});
|
||||
@@ -140,10 +154,10 @@ describe("computePinnedZoomPercent", () => {
|
||||
});
|
||||
|
||||
describe("timelineZoomPercentToSlider / timelineSliderToZoomPercent round-trip", () => {
|
||||
for (const percent of [10, 100, 500, 2000]) {
|
||||
for (const percent of [10, 100, 500, 2000, MAX_ZOOM_PERCENT]) {
|
||||
it(`round-trips ${percent}% within ±1%`, () => {
|
||||
const slider = timelineZoomPercentToSlider(percent);
|
||||
const back = timelineSliderToZoomPercent(slider);
|
||||
const slider = timelineZoomPercentToSlider(percent, FIT_PPS);
|
||||
const back = timelineSliderToZoomPercent(slider, FIT_PPS);
|
||||
expect(Math.abs(back - percent) / percent).toBeLessThan(0.01);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ZoomMode } from "../store/playerStore";
|
||||
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||
|
||||
export const MIN_TIMELINE_ZOOM_PERCENT = 10;
|
||||
export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
|
||||
const MAX_TIMELINE_FRAME_WIDTH_PX = 48;
|
||||
// CapCut-strength steps: one button press / pinch gesture moves the zoom
|
||||
// meaningfully (user feedback, twice-doubled: 1.25×/0.8× + 0.0035 felt like
|
||||
// "zooming several times to get anywhere", then 1.5× + 0.007 still too soft).
|
||||
@@ -10,16 +11,24 @@ const ZOOM_OUT_FACTOR = 0.5;
|
||||
const ZOOM_IN_FACTOR = 2;
|
||||
const PINCH_ZOOM_SENSITIVITY = 0.014;
|
||||
|
||||
export function clampTimelineZoomPercent(percent: number): number {
|
||||
if (!Number.isFinite(percent)) return 100;
|
||||
return Math.max(
|
||||
MIN_TIMELINE_ZOOM_PERCENT,
|
||||
Math.min(MAX_TIMELINE_ZOOM_PERCENT, Math.round(percent)),
|
||||
);
|
||||
export function getMaxTimelineZoomPercent(fitPixelsPerSecond: number): number {
|
||||
if (!Number.isFinite(fitPixelsPerSecond) || fitPixelsPerSecond <= 0) return 100;
|
||||
const frameLevelPixelsPerSecond = STUDIO_PREVIEW_FPS * MAX_TIMELINE_FRAME_WIDTH_PX;
|
||||
return Math.max(100, Math.round((frameLevelPixelsPerSecond / fitPixelsPerSecond) * 100));
|
||||
}
|
||||
|
||||
export function getTimelineZoomPercent(zoomMode: ZoomMode, manualZoomPercent: number): number {
|
||||
return zoomMode === "fit" ? 100 : clampTimelineZoomPercent(manualZoomPercent);
|
||||
export function clampTimelineZoomPercent(percent: number, fitPixelsPerSecond: number): number {
|
||||
if (!Number.isFinite(percent)) return 100;
|
||||
const maxZoomPercent = getMaxTimelineZoomPercent(fitPixelsPerSecond);
|
||||
return Math.max(MIN_TIMELINE_ZOOM_PERCENT, Math.min(maxZoomPercent, Math.round(percent)));
|
||||
}
|
||||
|
||||
export function getTimelineZoomPercent(
|
||||
zoomMode: ZoomMode,
|
||||
manualZoomPercent: number,
|
||||
fitPixelsPerSecond: number,
|
||||
): number {
|
||||
return zoomMode === "fit" ? 100 : clampTimelineZoomPercent(manualZoomPercent, fitPixelsPerSecond);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +56,10 @@ export function computePinnedZoomPercent(
|
||||
) {
|
||||
return 100;
|
||||
}
|
||||
return clampTimelineZoomPercent((currentPixelsPerSecond / fitPixelsPerSecond) * 100);
|
||||
return clampTimelineZoomPercent(
|
||||
(currentPixelsPerSecond / fitPixelsPerSecond) * 100,
|
||||
fitPixelsPerSecond,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTimelinePixelsPerSecond(
|
||||
@@ -56,7 +68,7 @@ export function getTimelinePixelsPerSecond(
|
||||
manualZoomPercent: number,
|
||||
): number {
|
||||
if (!Number.isFinite(fitPixelsPerSecond) || fitPixelsPerSecond <= 0) return 100;
|
||||
const zoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
||||
const zoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent, fitPixelsPerSecond);
|
||||
return zoomMode === "fit" ? fitPixelsPerSecond : fitPixelsPerSecond * (zoomPercent / 100);
|
||||
}
|
||||
|
||||
@@ -64,41 +76,46 @@ export function getNextTimelineZoomPercent(
|
||||
direction: "in" | "out",
|
||||
zoomMode: ZoomMode,
|
||||
manualZoomPercent: number,
|
||||
fitPixelsPerSecond: number,
|
||||
): number {
|
||||
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
||||
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent, fitPixelsPerSecond);
|
||||
const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
|
||||
return clampTimelineZoomPercent(next);
|
||||
return clampTimelineZoomPercent(next, fitPixelsPerSecond);
|
||||
}
|
||||
|
||||
export function getPinchTimelineZoomPercent(
|
||||
deltaY: number,
|
||||
zoomMode: ZoomMode,
|
||||
manualZoomPercent: number,
|
||||
fitPixelsPerSecond: number,
|
||||
): number {
|
||||
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
||||
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent, fitPixelsPerSecond);
|
||||
if (!Number.isFinite(deltaY) || deltaY === 0) return current;
|
||||
return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
|
||||
return clampTimelineZoomPercent(
|
||||
current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY),
|
||||
fitPixelsPerSecond,
|
||||
);
|
||||
}
|
||||
|
||||
const LOG_MIN = Math.log(MIN_TIMELINE_ZOOM_PERCENT);
|
||||
const LOG_MAX = Math.log(MAX_TIMELINE_ZOOM_PERCENT);
|
||||
|
||||
/**
|
||||
* Maps a zoom percent (10–2000) to a slider position (0–100) using a log scale.
|
||||
* Log scale is used because the range spans 200×; linear would compress the
|
||||
* low end (10–100%) into a tiny sliver of the slider.
|
||||
* Maps the frame-level zoom range to a slider position (0–100) using a log scale.
|
||||
* Linear would compress the useful low end into a tiny sliver of the slider.
|
||||
*/
|
||||
export function timelineZoomPercentToSlider(percent: number): number {
|
||||
const clamped = clampTimelineZoomPercent(percent);
|
||||
return ((Math.log(clamped) - LOG_MIN) / (LOG_MAX - LOG_MIN)) * 100;
|
||||
export function timelineZoomPercentToSlider(percent: number, fitPixelsPerSecond: number): number {
|
||||
const clamped = clampTimelineZoomPercent(percent, fitPixelsPerSecond);
|
||||
const logMax = Math.log(getMaxTimelineZoomPercent(fitPixelsPerSecond));
|
||||
return ((Math.log(clamped) - LOG_MIN) / (logMax - LOG_MIN)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a slider position (0–100) to a zoom percent (10–2000) using a log scale.
|
||||
* Maps a slider position (0–100) to the frame-level zoom range using a log scale.
|
||||
* Inverse of `timelineZoomPercentToSlider`.
|
||||
*/
|
||||
export function timelineSliderToZoomPercent(slider: number): number {
|
||||
export function timelineSliderToZoomPercent(slider: number, fitPixelsPerSecond: number): number {
|
||||
const clampedSlider = Math.max(0, Math.min(100, slider));
|
||||
const logValue = LOG_MIN + (clampedSlider / 100) * (LOG_MAX - LOG_MIN);
|
||||
return clampTimelineZoomPercent(Math.exp(logValue));
|
||||
const logMax = Math.log(getMaxTimelineZoomPercent(fitPixelsPerSecond));
|
||||
const logValue = LOG_MIN + (clampedSlider / 100) * (logMax - LOG_MIN);
|
||||
return clampTimelineZoomPercent(Math.exp(logValue), fitPixelsPerSecond);
|
||||
}
|
||||
|
||||
@@ -1,113 +1,116 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { updateTimelineActiveClipClasses } from "./useTimelineActiveClips";
|
||||
import { liveTime, type TimelineElement } from "../store/playerStore";
|
||||
import {
|
||||
isTimelineClipActive,
|
||||
updateTimelineActiveClipClasses,
|
||||
useTimelineActiveClips,
|
||||
} from "./useTimelineActiveClips";
|
||||
|
||||
function appendClip(container: HTMLElement, id: string, start: string, end: string): HTMLElement {
|
||||
const clip = document.createElement("div");
|
||||
clip.dataset.clip = "true";
|
||||
clip.dataset.elId = id;
|
||||
clip.dataset.clipStart = start;
|
||||
clip.dataset.clipEnd = end;
|
||||
container.append(clip);
|
||||
return clip;
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
function clip(id: string, start: number, duration: number, hidden = false): TimelineElement {
|
||||
return { id, tag: "div", start, duration, track: 1, hidden };
|
||||
}
|
||||
|
||||
describe("updateTimelineActiveClipClasses", () => {
|
||||
it("toggles data-active only for clips containing the current time", () => {
|
||||
function appendClip(container: HTMLElement, id: string, start: string, end: string): HTMLElement {
|
||||
const element = document.createElement("div");
|
||||
element.dataset.clip = "true";
|
||||
element.dataset.elId = id;
|
||||
element.dataset.clipStart = start;
|
||||
element.dataset.clipEnd = end;
|
||||
container.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function Harness({ version, heroStart = 2 }: { version: number; heroStart?: number }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime: 0,
|
||||
clipStateVersion: 0,
|
||||
elementStateVersion: version,
|
||||
});
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ ref: scrollRef },
|
||||
React.createElement("div", {
|
||||
"data-clip": "true",
|
||||
"data-el-id": "intro",
|
||||
"data-clip-start": "0",
|
||||
"data-clip-end": "1",
|
||||
}),
|
||||
React.createElement("div", {
|
||||
"data-clip": "true",
|
||||
"data-el-id": "hero",
|
||||
"data-clip-start": String(heroStart),
|
||||
"data-clip-end": String(heroStart + 1),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("timeline active clips", () => {
|
||||
it("uses model timing and keeps the end boundary inclusive", () => {
|
||||
const element = clip("hero", 2, 3);
|
||||
expect(isTimelineClipActive(element, 2)).toBe(true);
|
||||
expect(isTimelineClipActive(element, 5)).toBe(true);
|
||||
expect(isTimelineClipActive(element, 5.001)).toBe(false);
|
||||
});
|
||||
|
||||
it("never activates hidden or invalid clips", () => {
|
||||
expect(isTimelineClipActive(clip("hidden", 0, 5, true), 2)).toBe(false);
|
||||
expect(isTimelineClipActive(clip("invalid", Number.NaN, 5), 2)).toBe(false);
|
||||
});
|
||||
|
||||
it("synchronizes mounted clip attributes", () => {
|
||||
const container = document.createElement("div");
|
||||
const intro = appendClip(container, "intro", "0", "2");
|
||||
const hero = appendClip(container, "hero", "2", "5");
|
||||
const outro = appendClip(container, "outro", "5", "8");
|
||||
const previous = new Set<string>();
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2.25);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(false);
|
||||
expect(hero.hasAttribute("data-active")).toBe(true);
|
||||
expect(outro.hasAttribute("data-active")).toBe(false);
|
||||
expect(previous).toEqual(new Set(["hero"]));
|
||||
});
|
||||
|
||||
it("never marks hidden clips active inside their time window", () => {
|
||||
const container = document.createElement("div");
|
||||
const hidden = appendClip(container, "hidden", "0", "5");
|
||||
const visible = appendClip(container, "visible", "0", "5");
|
||||
hidden.dataset.clipHidden = "true";
|
||||
const previous = new Set<string>();
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
|
||||
expect(hidden.hasAttribute("data-active")).toBe(false);
|
||||
expect(visible.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["visible"]));
|
||||
});
|
||||
|
||||
it("diffs against the previous active set", () => {
|
||||
const container = document.createElement("div");
|
||||
const intro = appendClip(container, "intro", "0", "2");
|
||||
const hero = appendClip(container, "hero", "2", "5");
|
||||
const previous = new Set(["intro"]);
|
||||
intro.toggleAttribute("data-active", true);
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(true);
|
||||
expect(hero.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["intro", "hero"]));
|
||||
});
|
||||
|
||||
it("keeps a clip active through its inclusive end boundary", () => {
|
||||
const container = document.createElement("div");
|
||||
const intro = appendClip(container, "intro", "0", "2");
|
||||
const previous = new Set<string>();
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 0);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["intro"]));
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["intro"]));
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2.001);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(false);
|
||||
expect(previous).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("re-applies data-active to a fresh DOM node that stayed active across a re-render", () => {
|
||||
// A clip that moves lanes on a reorder remounts as a new element. It stays
|
||||
// in the previous active set, so the plain diff would skip it and leave the
|
||||
// new node without data-active. syncAll must force the attribute on.
|
||||
it("re-applies active state when a clip remounts inside the current window", () => {
|
||||
const container = document.createElement("div");
|
||||
appendClip(container, "hero", "0", "5");
|
||||
const previous = new Set<string>();
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
expect(previous).toEqual(new Set(["hero"]));
|
||||
|
||||
// Simulate a remount: replace the hero clip's DOM node (no data-active).
|
||||
container.replaceChildren();
|
||||
const heroReborn = appendClip(container, "hero", "0", "5");
|
||||
expect(heroReborn.hasAttribute("data-active")).toBe(false);
|
||||
|
||||
// Diff-only would skip it (still active → unchanged); syncAll re-applies.
|
||||
const remounted = appendClip(container, "hero", "0", "5");
|
||||
updateTimelineActiveClipClasses(container, previous, 2, true);
|
||||
expect(heroReborn.hasAttribute("data-active")).toBe(true);
|
||||
|
||||
expect(remounted.hasAttribute("data-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores clips with invalid timing data", () => {
|
||||
const container = document.createElement("div");
|
||||
const missingId = appendClip(container, "", "0", "2");
|
||||
const missingTiming = appendClip(container, "bad", "", "2");
|
||||
const previous = new Set<string>();
|
||||
it("moves active state with live playback without changing store time", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => root.render(React.createElement(Harness, { version: 0 })));
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 1);
|
||||
const intro = host.querySelector<HTMLElement>('[data-el-id="intro"]');
|
||||
const hero = host.querySelector<HTMLElement>('[data-el-id="hero"]');
|
||||
expect(intro?.hasAttribute("data-active")).toBe(true);
|
||||
expect(hero?.hasAttribute("data-active")).toBe(false);
|
||||
|
||||
expect(missingId.hasAttribute("data-active")).toBe(false);
|
||||
expect(missingTiming.hasAttribute("data-active")).toBe(false);
|
||||
expect(previous).toEqual(new Set());
|
||||
act(() => liveTime.notify(2.5));
|
||||
expect(intro?.hasAttribute("data-active")).toBe(false);
|
||||
expect(hero?.hasAttribute("data-active")).toBe(true);
|
||||
|
||||
await act(async () => root.render(React.createElement(Harness, { version: 1, heroStart: 4 })));
|
||||
act(() => liveTime.notify(2.5));
|
||||
expect(host.querySelector('[data-el-id="hero"]')?.hasAttribute("data-active")).toBe(false);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
import { liveTime } from "../store/playerStore";
|
||||
import { liveTime, type TimelineElement } from "../store/playerStore";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
interface ActiveClipRecord {
|
||||
@@ -13,7 +13,34 @@ interface ActiveClipRecord {
|
||||
interface UseTimelineActiveClipsInput {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
currentTime: number;
|
||||
clipStateVersion: string;
|
||||
clipStateVersion: unknown;
|
||||
elementStateVersion: unknown;
|
||||
}
|
||||
|
||||
function isTimelineIntervalActive(
|
||||
interval: Pick<ActiveClipRecord, "start" | "end" | "hidden">,
|
||||
time: number,
|
||||
): boolean {
|
||||
return (
|
||||
Number.isFinite(time) &&
|
||||
!interval.hidden &&
|
||||
Number.isFinite(interval.start) &&
|
||||
Number.isFinite(interval.end) &&
|
||||
time >= interval.start &&
|
||||
time <= interval.end
|
||||
);
|
||||
}
|
||||
|
||||
/** Model-first active state. Rendered nodes receive this on their first mount. */
|
||||
export function isTimelineClipActive(element: TimelineElement, time: number): boolean {
|
||||
return isTimelineIntervalActive(
|
||||
{
|
||||
start: element.start,
|
||||
end: element.start + Math.max(0, element.duration),
|
||||
hidden: element.hidden === true,
|
||||
},
|
||||
time,
|
||||
);
|
||||
}
|
||||
|
||||
function readFiniteNumber(value: string | undefined): number | null {
|
||||
@@ -27,66 +54,37 @@ function readClipRecord(element: Element): ActiveClipRecord | null {
|
||||
const id = element.dataset.elId;
|
||||
const start = readFiniteNumber(element.dataset.clipStart);
|
||||
const end = readFiniteNumber(element.dataset.clipEnd);
|
||||
const hidden = element.dataset.clipHidden === "true";
|
||||
if (!id || start === null || end === null) return null;
|
||||
return { id, start, end, hidden, element };
|
||||
return { id, start, end, hidden: element.dataset.clipHidden === "true", element };
|
||||
}
|
||||
|
||||
function collectTimelineClipRecords(container: HTMLElement): ActiveClipRecord[] {
|
||||
const records: ActiveClipRecord[] = [];
|
||||
for (const element of container.querySelectorAll('[data-clip="true"]')) {
|
||||
const record = readClipRecord(element);
|
||||
if (record) records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function indexClipRecordsById(records: ActiveClipRecord[]): Map<string, ActiveClipRecord> {
|
||||
const recordsById = new Map<string, ActiveClipRecord>();
|
||||
for (const record of records) recordsById.set(record.id, record);
|
||||
return recordsById;
|
||||
return [...container.querySelectorAll('[data-clip="true"]')]
|
||||
.map(readClipRecord)
|
||||
.filter((record): record is ActiveClipRecord => record !== null);
|
||||
}
|
||||
|
||||
function getActiveClipIds(records: ActiveClipRecord[], time: number): Set<string> {
|
||||
const next = new Set<string>();
|
||||
if (!Number.isFinite(time)) return next;
|
||||
for (const record of records) {
|
||||
if (record.hidden) continue;
|
||||
if (time >= record.start && time <= record.end) next.add(record.id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function setsMatch(left: Set<string>, right: Set<string>): boolean {
|
||||
if (left.size !== right.size) return false;
|
||||
for (const value of left) {
|
||||
if (!right.has(value)) return false;
|
||||
}
|
||||
return true;
|
||||
return new Set(
|
||||
records.filter((record) => isTimelineIntervalActive(record, time)).map((record) => record.id),
|
||||
);
|
||||
}
|
||||
|
||||
function applyActiveClipDiff(
|
||||
records: ActiveClipRecord[],
|
||||
previous: Set<string>,
|
||||
time: number,
|
||||
// Force every record's attribute to match its active state instead of only
|
||||
// touching clips whose active-state changed. Required whenever `records` were
|
||||
// freshly re-queried after a render: a clip that stayed active but got a new
|
||||
// DOM node (e.g. moved lanes on a reorder) would otherwise be skipped by the
|
||||
// diff and render without `data-active` despite still being at the playhead.
|
||||
syncAll = false,
|
||||
) {
|
||||
): void {
|
||||
const next = getActiveClipIds(records, time);
|
||||
const changed = !setsMatch(previous, next);
|
||||
for (const record of records) {
|
||||
const wasActive = previous.has(record.id);
|
||||
const isActive = next.has(record.id);
|
||||
if (!syncAll && wasActive === isActive) continue;
|
||||
record.element.toggleAttribute("data-active", isActive);
|
||||
if (syncAll || previous.has(record.id) !== isActive) {
|
||||
record.element.toggleAttribute("data-active", isActive);
|
||||
}
|
||||
}
|
||||
previous.clear();
|
||||
for (const id of next) previous.add(id);
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function updateTimelineActiveClipClasses(
|
||||
@@ -94,30 +92,28 @@ export function updateTimelineActiveClipClasses(
|
||||
previous: Set<string>,
|
||||
time: number,
|
||||
syncAll = false,
|
||||
) {
|
||||
): void {
|
||||
applyActiveClipDiff(collectTimelineClipRecords(container), previous, time, syncAll);
|
||||
}
|
||||
|
||||
/** Keeps the currently mounted clip window synchronized with the RAF playback clock. */
|
||||
export function useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime,
|
||||
clipStateVersion,
|
||||
}: UseTimelineActiveClipsInput) {
|
||||
elementStateVersion,
|
||||
}: UseTimelineActiveClipsInput): void {
|
||||
const recordsRef = useRef<ActiveClipRecord[]>([]);
|
||||
const recordsByIdRef = useRef(new Map<string, ActiveClipRecord>());
|
||||
const previousActiveIdsRef = useRef(new Set<string>());
|
||||
|
||||
const refreshRecords = useCallback(
|
||||
(time: number) => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) {
|
||||
recordsRef.current = [];
|
||||
recordsByIdRef.current.clear();
|
||||
previousActiveIdsRef.current.clear();
|
||||
return;
|
||||
}
|
||||
recordsRef.current = collectTimelineClipRecords(scroll);
|
||||
recordsByIdRef.current = indexClipRecordsById(recordsRef.current);
|
||||
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time, true);
|
||||
},
|
||||
[scrollRef],
|
||||
@@ -125,12 +121,10 @@ export function useTimelineActiveClips({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
refreshRecords(currentTime);
|
||||
}, [currentTime, clipStateVersion, refreshRecords]);
|
||||
|
||||
useMountEffect(() => {
|
||||
const unsub = liveTime.subscribe((time) => {
|
||||
}, [clipStateVersion, currentTime, elementStateVersion, refreshRecords]);
|
||||
useMountEffect(() =>
|
||||
liveTime.subscribe((time) => {
|
||||
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,15 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import {
|
||||
applyTimelineAutoScrollStep,
|
||||
resolveTimelineAutoScrollLoopAction,
|
||||
resolveTimelineDragEscape,
|
||||
} from "./timelineEditing";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
|
||||
import { mergeUserBeats } from "../../utils/beatEditing";
|
||||
import {
|
||||
buildTimelineGroupResizeMembers,
|
||||
type TimelineGroupResizeSession,
|
||||
} from "./timelineGroupEditing";
|
||||
import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineSnapping";
|
||||
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
||||
import type { StackingPatch } from "./timelineStackingSync";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import {
|
||||
@@ -28,11 +25,9 @@ import type {
|
||||
ResizingClipState,
|
||||
BlockedClipState,
|
||||
} from "./timelineClipDragTypes";
|
||||
import {
|
||||
beginTimelineOptimisticGesture,
|
||||
rollbackLatestTimelineOptimisticGesture,
|
||||
} from "./timelineOptimisticRevision";
|
||||
import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
|
||||
import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle";
|
||||
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
||||
import type { TimelineRowGeometry } from "./timelineLayout";
|
||||
|
||||
export type {
|
||||
DraggedClipState,
|
||||
@@ -48,7 +43,7 @@ interface UseTimelineClipDragInput {
|
||||
ppsRef: React.RefObject<number>;
|
||||
durationRef: React.RefObject<number>;
|
||||
trackOrderRef: React.RefObject<number[]>;
|
||||
rowHeightsRef?: React.RefObject<readonly number[]>;
|
||||
rowGeometryRef?: React.RefObject<TimelineRowGeometry>;
|
||||
onMoveElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "track">,
|
||||
@@ -86,7 +81,7 @@ export function useTimelineClipDrag({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
rowGeometryRef,
|
||||
onMoveElement,
|
||||
onMoveElements,
|
||||
onResizeElement,
|
||||
@@ -102,12 +97,11 @@ export function useTimelineClipDrag({
|
||||
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
|
||||
const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
|
||||
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
||||
const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0);
|
||||
const musicPlaybackStart = usePlayerStore(
|
||||
(s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0,
|
||||
);
|
||||
const musicDuration = usePlayerStore((s) => s.elements.find(isMusicTrack)?.duration ?? 0);
|
||||
const musicSrc = usePlayerStore((s) => s.elements.find(isMusicTrack)?.src ?? null);
|
||||
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
|
||||
const musicStart = musicElement?.start ?? 0;
|
||||
const musicPlaybackStart = musicElement?.playbackStart ?? 0;
|
||||
const musicDuration = musicElement?.duration ?? 0;
|
||||
const musicSrc = musicElement?.src ?? null;
|
||||
|
||||
const adjustedBeatTimes = useMemo(() => {
|
||||
if (rawBeatTimes === EMPTY_BEAT_TIMES || musicDuration === 0) return EMPTY_BEAT_TIMES;
|
||||
@@ -234,23 +228,21 @@ export function useTimelineClipDrag({
|
||||
// Build the audio-track set once per gesture (see snapTargetsCacheRef): it
|
||||
// only feeds zone-aware drop placement and is frozen while dragging.
|
||||
if (!dragAudioTracksRef.current) {
|
||||
dragAudioTracksRef.current = new Set(
|
||||
elementsRef.current.filter(isAudioTimelineElement).map((e) => e.track),
|
||||
);
|
||||
dragAudioTracksRef.current = getTimelineElementIndexes(elementsRef.current).audioTracks;
|
||||
}
|
||||
return computeDragPreview(drag, clientX, clientY, {
|
||||
scroll: scrollRef.current,
|
||||
pps: ppsRef.current,
|
||||
duration: durationRef.current,
|
||||
trackOrder: trackOrderRef.current,
|
||||
rowHeights: rowHeightsRef?.current,
|
||||
rowHeights: rowGeometryRef?.current.rowHeights,
|
||||
elements: elementsRef.current,
|
||||
selectedKeys: usePlayerStore.getState().selectedElementIds,
|
||||
buildSnapTargets,
|
||||
audioTracks: dragAudioTracksRef.current,
|
||||
});
|
||||
},
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, buildSnapTargets],
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, buildSnapTargets],
|
||||
);
|
||||
|
||||
// Recompute the trim preview for a pointer x. Shared by the pointermove resize
|
||||
@@ -363,215 +355,33 @@ export function useTimelineClipDrag({
|
||||
stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
|
||||
|
||||
useMountEffect(() => {
|
||||
const clearSuppressedClick = () => {
|
||||
requestAnimationFrame(() => {
|
||||
suppressClickRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
/* ── pointermove branch handlers (dispatched by drag/resize/blocked) ── */
|
||||
const handleResizePointerMove = (e: PointerEvent, resize: ResizingClipState) => {
|
||||
const distance = Math.abs(e.clientX - resize.originClientX);
|
||||
if (!resize.started && distance < 2) return;
|
||||
|
||||
setShowPopover(false);
|
||||
setRangeSelectionRef.current?.(null);
|
||||
|
||||
applyResizePointerRef.current(resize, e.clientX);
|
||||
// Edge auto-scroll during a trim, exactly like the move branch — lets a
|
||||
// right-edge trim keep extending past the current viewport (the stepper
|
||||
// re-runs the scroll-compensated preview each frame).
|
||||
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const handleBlockedPointerMove = (e: PointerEvent, blocked: BlockedClipState) => {
|
||||
const distance = Math.hypot(
|
||||
e.clientX - blocked.originClientX,
|
||||
e.clientY - blocked.originClientY,
|
||||
);
|
||||
const threshold = blocked.intent === "move" ? 4 : 2;
|
||||
if (!blocked.started && distance < threshold) return;
|
||||
if (!blocked.started) {
|
||||
blocked.started = true;
|
||||
blockedClipRef.current = blocked;
|
||||
suppressClickRef.current = true;
|
||||
setShowPopover(false);
|
||||
setRangeSelectionRef.current?.(null);
|
||||
onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragPointerMove = (e: PointerEvent, drag: DraggedClipState) => {
|
||||
const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
|
||||
if (!drag.started && distance < 4) return;
|
||||
|
||||
setShowPopover(false);
|
||||
setRangeSelectionRef.current?.(null);
|
||||
|
||||
setDraggedClip((prev) =>
|
||||
prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev,
|
||||
);
|
||||
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const handleWindowPointerMove = (e: PointerEvent) => {
|
||||
const resize = resizingClipRef.current;
|
||||
if (resize) return handleResizePointerMove(e, resize);
|
||||
const blocked = blockedClipRef.current;
|
||||
if (blocked) return handleBlockedPointerMove(e, blocked);
|
||||
const drag = draggedClipRef.current;
|
||||
if (drag) handleDragPointerMove(e, drag);
|
||||
};
|
||||
|
||||
/* ── pointerup commit handlers (dispatched by drag/resize/blocked) ──── */
|
||||
const commitResizePointerUp = (resize: ResizingClipState) => {
|
||||
resizingClipRef.current = null;
|
||||
setResizingClip(null);
|
||||
const groupSession = groupResizeRef.current;
|
||||
groupResizeRef.current = null;
|
||||
if (!resize.started) {
|
||||
// No preview ran, so no group store-mutation to undo; guard is defensive.
|
||||
if (groupSession) restoreGroupResizeMembers(groupSession);
|
||||
return;
|
||||
}
|
||||
|
||||
suppressClickRef.current = true;
|
||||
clearSuppressedClick();
|
||||
|
||||
if (groupSession) {
|
||||
commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasChanged =
|
||||
resize.previewStart !== resize.element.start ||
|
||||
resize.previewDuration !== resize.element.duration ||
|
||||
resize.previewPlaybackStart !== resize.element.playbackStart;
|
||||
if (!hasChanged) return;
|
||||
|
||||
const resizeKey = resize.element.key ?? resize.element.id;
|
||||
const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]);
|
||||
updateElement(resizeKey, {
|
||||
start: resize.previewStart,
|
||||
duration: resize.previewDuration,
|
||||
playbackStart: resize.previewPlaybackStart,
|
||||
});
|
||||
|
||||
Promise.resolve(
|
||||
onResizeElementRef.current?.(resize.element, {
|
||||
start: resize.previewStart,
|
||||
duration: resize.previewDuration,
|
||||
playbackStart: resize.previewPlaybackStart,
|
||||
}),
|
||||
).catch((error) => {
|
||||
rollbackLatestTimelineOptimisticGesture(updateElement, revision, [
|
||||
{
|
||||
key: resizeKey,
|
||||
updates: {
|
||||
start: resize.element.start,
|
||||
duration: resize.element.duration,
|
||||
playbackStart: resize.element.playbackStart,
|
||||
},
|
||||
},
|
||||
]);
|
||||
console.error("[Timeline] Failed to persist clip resize", error);
|
||||
});
|
||||
};
|
||||
|
||||
const finishBlockedPointerUp = (blocked: BlockedClipState) => {
|
||||
blockedClipRef.current = null;
|
||||
if (!blocked.started) return;
|
||||
clearSuppressedClick();
|
||||
};
|
||||
|
||||
const commitDragPointerUp = (drag: DraggedClipState) => {
|
||||
draggedClipRef.current = null;
|
||||
setDraggedClip(null);
|
||||
if (!drag.started) return;
|
||||
|
||||
suppressClickRef.current = true;
|
||||
clearSuppressedClick();
|
||||
|
||||
// Commit the drag — insert (new track), main-track ripple (reflow contiguous),
|
||||
// a plain single-clip move, or a multi-selection move (every selected clip
|
||||
// shifts by the dragged clip's time delta). See timelineClipDragCommit.
|
||||
commitDraggedClipMove(drag, {
|
||||
elements: elementsRef.current,
|
||||
trackOrder: trackOrderRef.current,
|
||||
updateElement,
|
||||
onMoveElement: onMoveElementRef.current,
|
||||
onMoveElements: onMoveElementsRef.current,
|
||||
selectedKeys: usePlayerStore.getState().selectedElementIds,
|
||||
// Lane ↔ stacking: engages only when the timeline layer provisions both
|
||||
// deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync.
|
||||
readZIndex: readZIndexRef.current,
|
||||
onStackingPatches: onStackingPatchesRef.current,
|
||||
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
const handleWindowPointerUp = () => {
|
||||
stopClipDragAutoScrollRef.current();
|
||||
|
||||
const resize = resizingClipRef.current;
|
||||
if (resize) return commitResizePointerUp(resize);
|
||||
|
||||
const blocked = blockedClipRef.current;
|
||||
if (blocked) return finishBlockedPointerUp(blocked);
|
||||
|
||||
const drag = draggedClipRef.current;
|
||||
if (!drag) {
|
||||
// Escape-cancel leaves the click suppressor armed so the click this
|
||||
// pointerup generates can't act on the clip; disarm it right after.
|
||||
if (suppressClickRef.current) clearSuppressedClick();
|
||||
return;
|
||||
}
|
||||
commitDragPointerUp(drag);
|
||||
};
|
||||
|
||||
// Escape cancels the in-progress gesture: no commit, no undo entry. The
|
||||
// previews live only in the drag/resize state (the store is untouched
|
||||
// until the pointerup commit), so clearing them restores the pre-drag
|
||||
// timeline. Clip drags never take pointer capture (all tracking runs on
|
||||
// these window listeners), so there is no capture to release; the null
|
||||
// refs make the remaining pointermove/pointerup a no-op.
|
||||
const handleWindowKeyDown = (e: KeyboardEvent) => {
|
||||
const decision = resolveTimelineDragEscape({
|
||||
key: e.key,
|
||||
drag: draggedClipRef.current,
|
||||
resize: resizingClipRef.current,
|
||||
blocked: blockedClipRef.current,
|
||||
});
|
||||
if (!decision.cancel) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stopClipDragAutoScrollRef.current();
|
||||
draggedClipRef.current = null;
|
||||
setDraggedClip(null);
|
||||
resizingClipRef.current = null;
|
||||
setResizingClip(null);
|
||||
// Undo any group-resize preview store-mutation (non-grabbed members) so the
|
||||
// cancelled gesture restores the pre-drag timeline, like the single-clip path.
|
||||
const groupSession = groupResizeRef.current;
|
||||
groupResizeRef.current = null;
|
||||
if (groupSession) restoreGroupResizeMembers(groupSession);
|
||||
blockedClipRef.current = null;
|
||||
// The pointer is usually still down; keep the suppressor armed until the
|
||||
// eventual pointerup (which disarms it) so its click can't reselect.
|
||||
if (decision.suppressClick) suppressClickRef.current = true;
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleWindowPointerMove);
|
||||
window.addEventListener("pointerup", handleWindowPointerUp);
|
||||
window.addEventListener("pointercancel", handleWindowPointerUp);
|
||||
window.addEventListener("keydown", handleWindowKeyDown, true);
|
||||
return () => {
|
||||
stopClipDragAutoScrollRef.current();
|
||||
window.removeEventListener("pointermove", handleWindowPointerMove);
|
||||
window.removeEventListener("pointerup", handleWindowPointerUp);
|
||||
window.removeEventListener("pointercancel", handleWindowPointerUp);
|
||||
window.removeEventListener("keydown", handleWindowKeyDown, true);
|
||||
};
|
||||
return mountTimelineClipDragGestureLifecycle({
|
||||
draggedClipRef,
|
||||
resizingClipRef,
|
||||
blockedClipRef,
|
||||
groupResizeRef,
|
||||
suppressClickRef,
|
||||
elementsRef,
|
||||
trackOrderRef,
|
||||
setDraggedClip,
|
||||
setResizingClip,
|
||||
setShowPopover,
|
||||
setRangeSelectionRef,
|
||||
applyResizePointerRef,
|
||||
syncClipDragAutoScrollRef,
|
||||
stopClipDragAutoScrollRef,
|
||||
updateDraggedClipPreviewRef,
|
||||
restoreGroupResizeMembers,
|
||||
updateElement,
|
||||
onMoveElementRef,
|
||||
onMoveElementsRef,
|
||||
onResizeElementRef,
|
||||
onResizeElementsRef,
|
||||
onBlockedEditAttemptRef,
|
||||
readZIndexRef,
|
||||
onStackingPatchesRef,
|
||||
refreshAfterLaneMoveRef,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo, type RefObject } from "react";
|
||||
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
|
||||
import type { TimelineRowGeometry } from "./timelineLayout";
|
||||
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
||||
import { useTimelineRevealClip } from "./useTimelineRevealClip";
|
||||
|
||||
interface UseTimelineClipRenderWindowInput {
|
||||
tracks: Parameters<typeof createTimelineClipIndex>[0];
|
||||
viewport: TimelineScrollViewportSnapshot;
|
||||
pixelsPerSecond: number;
|
||||
contentOrigin: number;
|
||||
duration: number;
|
||||
selectedElementId?: string;
|
||||
draggedElementId?: string;
|
||||
resizingElementId?: string;
|
||||
revealElementId?: string;
|
||||
focusedEaseElementId?: string;
|
||||
clipContextMenuElementId?: string;
|
||||
keyframeContextMenuElementId?: string;
|
||||
focusedElementId?: string;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
elements: readonly TimelineElement[];
|
||||
rowGeometry: TimelineRowGeometry;
|
||||
allowHorizontalReveal: boolean;
|
||||
rowVirtualizationActive: boolean;
|
||||
sessionEpoch: number;
|
||||
}
|
||||
|
||||
export function useTimelineClipRenderWindow({
|
||||
tracks,
|
||||
viewport,
|
||||
pixelsPerSecond,
|
||||
contentOrigin,
|
||||
duration,
|
||||
selectedElementId,
|
||||
draggedElementId,
|
||||
resizingElementId,
|
||||
revealElementId,
|
||||
focusedEaseElementId,
|
||||
clipContextMenuElementId,
|
||||
keyframeContextMenuElementId,
|
||||
focusedElementId,
|
||||
scrollRef,
|
||||
elements,
|
||||
rowGeometry,
|
||||
allowHorizontalReveal,
|
||||
rowVirtualizationActive,
|
||||
sessionEpoch,
|
||||
}: UseTimelineClipRenderWindowInput) {
|
||||
const clipIndex = useMemo(() => createTimelineClipIndex(tracks), [tracks]);
|
||||
const renderTimeRange = useMemo(
|
||||
() => getTimelineRenderTimeRange(viewport, pixelsPerSecond, contentOrigin, duration),
|
||||
[contentOrigin, duration, pixelsPerSecond, viewport],
|
||||
);
|
||||
const pinnedClipIdentities = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
[
|
||||
selectedElementId,
|
||||
draggedElementId,
|
||||
resizingElementId,
|
||||
revealElementId,
|
||||
focusedEaseElementId,
|
||||
clipContextMenuElementId,
|
||||
keyframeContextMenuElementId,
|
||||
focusedElementId,
|
||||
].filter((identity): identity is string => identity !== undefined),
|
||||
),
|
||||
[
|
||||
clipContextMenuElementId,
|
||||
draggedElementId,
|
||||
focusedEaseElementId,
|
||||
focusedElementId,
|
||||
keyframeContextMenuElementId,
|
||||
resizingElementId,
|
||||
revealElementId,
|
||||
selectedElementId,
|
||||
],
|
||||
);
|
||||
useTimelineRevealClip({
|
||||
scrollRef,
|
||||
elements,
|
||||
rowGeometry,
|
||||
pixelsPerSecond,
|
||||
contentOrigin,
|
||||
allowHorizontal: allowHorizontalReveal,
|
||||
deferFocusUntilViewportUpdate: rowVirtualizationActive,
|
||||
focusedElementId,
|
||||
viewportVersion: viewport,
|
||||
sessionEpoch,
|
||||
});
|
||||
return { clipIndex, renderTimeRange, pinnedClipIdentities };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, type RefObject } from "react";
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { usePlayerStore, type TimelineElement, type ZoomMode } from "../store/playerStore";
|
||||
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
||||
import {
|
||||
@@ -78,13 +78,6 @@ export function useTimelineGeometry({
|
||||
resizeGhostEndPx,
|
||||
});
|
||||
const displayDuration = pps > 0 ? displayContentWidth / pps : effectiveDuration;
|
||||
const clipStateVersion = useMemo(
|
||||
() =>
|
||||
expandedElements
|
||||
.map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`)
|
||||
.join("|"),
|
||||
[expandedElements],
|
||||
);
|
||||
const zoomModeRef = useRef(zoomMode);
|
||||
zoomModeRef.current = zoomMode;
|
||||
const manualZoomPercentRef = useRef(manualZoomPercent);
|
||||
@@ -92,7 +85,7 @@ export function useTimelineGeometry({
|
||||
fitPpsRef.current = fitPps;
|
||||
|
||||
// Restore the horizontal scroll offset after an edit re-derives the elements
|
||||
// (clipStateVersion changes) so the reload doesn't jump the view. Only in manual
|
||||
// (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:
|
||||
@@ -109,7 +102,7 @@ export function useTimelineGeometry({
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [clipStateVersion, zoomMode]);
|
||||
}, [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
|
||||
@@ -125,7 +118,7 @@ export function useTimelineGeometry({
|
||||
fitPps,
|
||||
displayContentWidth,
|
||||
displayDuration,
|
||||
clipStateVersion,
|
||||
clipStateVersion: expandedElements,
|
||||
zoomModeRef,
|
||||
manualZoomPercentRef,
|
||||
};
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useRef, useCallback, useEffect, useLayoutEffect } from "react";
|
||||
import { liveTime, type ZoomMode } from "../store/playerStore";
|
||||
import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { getPinchTimelineZoomPercent } from "./timelineZoom";
|
||||
import {
|
||||
getTimelinePlaybackFollowScrollLeft,
|
||||
getTimelinePlayheadLeft,
|
||||
getTimelineScrubTime,
|
||||
getTimelineScrollLeftForZoomTransition,
|
||||
getTimelineScrollLeftForZoomAnchor,
|
||||
shouldAutoScrollTimeline,
|
||||
} from "./timelineLayout";
|
||||
import { applyTimelineHorizontalAutoScrollStep } from "./timelineEditing";
|
||||
|
||||
interface UseTimelinePlayheadInput {
|
||||
playheadRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -121,9 +123,31 @@ export function useTimelinePlayhead({
|
||||
useMountEffect(() => {
|
||||
const unsub = liveTime.subscribe((t) => {
|
||||
if (!playheadRef.current || durationRef.current <= 0) return;
|
||||
// Playback deliberately does NOT scroll the viewport to chase the playhead —
|
||||
// the user's scroll position is theirs; the playhead may run off-screen.
|
||||
playheadRef.current.style.left = `${getTimelinePlayheadLeft(t, ppsRef.current, contentOriginRef.current)}px`;
|
||||
const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current;
|
||||
playheadRef.current.style.left = `${getTimelinePlayheadLeft(
|
||||
t,
|
||||
ppsRef.current,
|
||||
contentOriginRef.current,
|
||||
)}px`;
|
||||
const scroll = scrollRef.current;
|
||||
if (
|
||||
!scroll ||
|
||||
!usePlayerStore.getState().isPlaying ||
|
||||
isDragging.current ||
|
||||
zoomModeRef.current === "fit"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextScrollLeft = getTimelinePlaybackFollowScrollLeft({
|
||||
playheadX,
|
||||
currentScrollLeft: scroll.scrollLeft,
|
||||
viewportWidth: scroll.clientWidth,
|
||||
contentOrigin: contentOriginRef.current,
|
||||
maxScrollLeft: scroll.scrollWidth - scroll.clientWidth,
|
||||
});
|
||||
if (Math.abs(nextScrollLeft - scroll.scrollLeft) >= 0.5) {
|
||||
scroll.scrollLeft = nextScrollLeft;
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
@@ -157,16 +181,7 @@ export function useTimelinePlayhead({
|
||||
!shouldAutoScrollTimeline(zoomModeRef.current, el.scrollWidth, el.clientWidth)
|
||||
)
|
||||
return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const edgeZone = 40;
|
||||
const maxSpeed = 12;
|
||||
let scrollDelta = 0;
|
||||
if (clientX < rect.left + edgeZone)
|
||||
scrollDelta = -maxSpeed * Math.max(0, 1 - (clientX - rect.left) / edgeZone);
|
||||
else if (clientX > rect.right - edgeZone)
|
||||
scrollDelta = maxSpeed * Math.max(0, 1 - (rect.right - clientX) / edgeZone);
|
||||
if (scrollDelta !== 0) {
|
||||
el.scrollLeft += scrollDelta;
|
||||
if (applyTimelineHorizontalAutoScrollStep(el, clientX)) {
|
||||
seekFromX(clientX);
|
||||
dragScrollRaf.current = requestAnimationFrame(() => autoScrollDuringDrag(clientX));
|
||||
}
|
||||
@@ -187,6 +202,7 @@ export function useTimelinePlayhead({
|
||||
e.deltaY,
|
||||
zoomModeRef.current,
|
||||
manualZoomPercentRef.current,
|
||||
fitPpsRef.current,
|
||||
);
|
||||
if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual")
|
||||
return;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type MarqueeClipInput,
|
||||
} from "./timelineMarquee";
|
||||
import type { Rect } from "../../utils/marqueeGeometry";
|
||||
import type { TimelineRowGeometry } from "./timelineLayout";
|
||||
|
||||
interface UseTimelineRangeSelectionInput {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -30,7 +31,7 @@ interface UseTimelineRangeSelectionInput {
|
||||
setShowPopover: (v: boolean) => void;
|
||||
elementsRef: React.RefObject<TimelineElement[]>;
|
||||
trackOrderRef: React.RefObject<number[]>;
|
||||
rowHeightsRef: React.RefObject<readonly number[]>;
|
||||
rowGeometryRef: React.RefObject<TimelineRowGeometry>;
|
||||
onSelectElement?: (element: TimelineElement | null) => void;
|
||||
contentOrigin: number;
|
||||
}
|
||||
@@ -107,7 +108,7 @@ export function useTimelineRangeSelection({
|
||||
setShowPopover,
|
||||
elementsRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
rowGeometryRef,
|
||||
onSelectElement,
|
||||
contentOrigin,
|
||||
}: UseTimelineRangeSelectionInput) {
|
||||
@@ -176,12 +177,12 @@ export function useTimelineRangeSelection({
|
||||
marquee,
|
||||
elementsRef.current ?? [],
|
||||
trackOrderRef.current ?? [],
|
||||
rowHeightsRef.current,
|
||||
rowGeometryRef.current.rowHeights,
|
||||
ppsRef.current,
|
||||
contentOrigin,
|
||||
);
|
||||
},
|
||||
[toContentPoint, elementsRef, trackOrderRef, rowHeightsRef, ppsRef, contentOrigin],
|
||||
[toContentPoint, elementsRef, trackOrderRef, rowGeometryRef, ppsRef, contentOrigin],
|
||||
);
|
||||
|
||||
const stopMarqueeAutoScroll = useCallback(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, { act } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
||||
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
||||
import { getTimelineRowGeometry } from "./timelineLayout";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -60,7 +61,7 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
|
||||
const isDragging = { current: false };
|
||||
const elementsRef = { current: [] };
|
||||
const trackOrderRef = { current: [] };
|
||||
const rowHeightsRef = { current: [] };
|
||||
const rowGeometryRef = { current: getTimelineRowGeometry([]) };
|
||||
|
||||
function Probe(): null {
|
||||
latest = useTimelineRangeSelection({
|
||||
@@ -75,7 +76,7 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
|
||||
setShowPopover: vi.fn(),
|
||||
elementsRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
rowGeometryRef,
|
||||
contentOrigin: 0,
|
||||
});
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, useRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { createTimelineRowGeometry } from "./timelineLayout";
|
||||
import { useTimelineRevealClip } from "./useTimelineRevealClip";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const element: TimelineElement = {
|
||||
id: "hero",
|
||||
tag: "div",
|
||||
start: 20,
|
||||
duration: 2,
|
||||
track: 1,
|
||||
};
|
||||
const geometry = createTimelineRowGeometry([1], [48]);
|
||||
|
||||
function createHarnessRoot() {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
return { host, root: createRoot(host) };
|
||||
}
|
||||
|
||||
interface HarnessProps {
|
||||
mounted: boolean;
|
||||
version: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
deferFocusUntilViewportUpdate?: boolean;
|
||||
focusedElementId?: string;
|
||||
}
|
||||
|
||||
function Harness({
|
||||
mounted,
|
||||
version,
|
||||
width = 300,
|
||||
height = 100,
|
||||
deferFocusUntilViewportUpdate = false,
|
||||
focusedElementId,
|
||||
}: HarnessProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useTimelineRevealClip({
|
||||
scrollRef,
|
||||
elements: [element],
|
||||
rowGeometry: geometry,
|
||||
pixelsPerSecond: 100,
|
||||
contentOrigin: 32,
|
||||
allowHorizontal: true,
|
||||
deferFocusUntilViewportUpdate,
|
||||
focusedElementId,
|
||||
viewportVersion: version,
|
||||
sessionEpoch: 1,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={(node) => {
|
||||
scrollRef.current = node;
|
||||
if (node) {
|
||||
Object.defineProperty(node, "clientWidth", { configurable: true, value: width });
|
||||
Object.defineProperty(node, "clientHeight", { configurable: true, value: height });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mounted && <div data-el-id="hero" tabIndex={-1} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderHarness(root: Root, props: HarnessProps): Promise<void> {
|
||||
await act(async () => root.render(<Harness {...props} />));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("useTimelineRevealClip", () => {
|
||||
it("scrolls from model coordinates, then consumes only after the clip mounts", async () => {
|
||||
const { host, root } = createHarnessRoot();
|
||||
usePlayerStore.getState().requestClipReveal("hero");
|
||||
|
||||
await renderHarness(root, { mounted: false, version: 0 });
|
||||
const scroll = host.firstElementChild as HTMLDivElement;
|
||||
expect(scroll.scrollLeft).toBe(1_944);
|
||||
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
|
||||
|
||||
await renderHarness(root, { mounted: true, version: 1 });
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero");
|
||||
|
||||
scroll.scrollLeft = 0;
|
||||
await act(async () => usePlayerStore.getState().requestClipReveal("hero"));
|
||||
await renderHarness(root, { mounted: true, version: 2 });
|
||||
expect(scroll.scrollLeft).toBe(1_944);
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("consumes an invalid target without scrolling", async () => {
|
||||
const { root } = createHarnessRoot();
|
||||
usePlayerStore.getState().requestClipReveal("missing");
|
||||
await renderHarness(root, { mounted: false, version: 0 });
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps a reveal pending through zero-size viewport and virtualized focus handoff", async () => {
|
||||
const { host, root } = createHarnessRoot();
|
||||
usePlayerStore.getState().requestClipReveal("hero");
|
||||
|
||||
await renderHarness(root, { mounted: true, version: 0, width: 0, height: 0 });
|
||||
const scroll = host.firstElementChild as HTMLDivElement;
|
||||
expect(scroll.scrollLeft).toBe(0);
|
||||
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
|
||||
expect(document.activeElement?.getAttribute("data-el-id")).not.toBe("hero");
|
||||
|
||||
await renderHarness(root, {
|
||||
mounted: true,
|
||||
version: 1,
|
||||
deferFocusUntilViewportUpdate: true,
|
||||
});
|
||||
expect(scroll.scrollLeft).toBe(1_944);
|
||||
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
|
||||
await renderHarness(root, {
|
||||
mounted: true,
|
||||
version: 2,
|
||||
deferFocusUntilViewportUpdate: true,
|
||||
focusedElementId: "hero",
|
||||
});
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero");
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -1,56 +1,180 @@
|
||||
/**
|
||||
* Consumes playerStore.clipRevealRequest: when another surface (the sidebar
|
||||
* asset card / audio row) asks for a clip to be revealed, smooth-scroll the
|
||||
* timeline's scroll container so that clip is visible — horizontally to its
|
||||
* time and vertically to its lane.
|
||||
*
|
||||
* The request is consumed (cleared) whether or not the clip node is found, so
|
||||
* a stale request can never replay a scroll later. Respects zoom mode: in
|
||||
* "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
|
||||
* only the vertical axis is scrolled there.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { GUTTER, RULER_H } from "./timelineLayout";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import { CLIP_Y, RULER_H, type TimelineRowGeometry } from "./timelineLayout";
|
||||
import { computeRevealScroll } from "./timelineRevealScroll";
|
||||
|
||||
export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
|
||||
const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
|
||||
interface UseTimelineRevealClipInput {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
elements: readonly TimelineElement[];
|
||||
rowGeometry: TimelineRowGeometry;
|
||||
pixelsPerSecond: number;
|
||||
contentOrigin: number;
|
||||
allowHorizontal: boolean;
|
||||
deferFocusUntilViewportUpdate: boolean;
|
||||
focusedElementId?: string;
|
||||
viewportVersion: unknown;
|
||||
sessionEpoch: number;
|
||||
}
|
||||
|
||||
function escapeSelectorValue(value: string): string {
|
||||
return typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
||||
? CSS.escape(value)
|
||||
: value.replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function scrollToTimelineElement(
|
||||
container: HTMLDivElement,
|
||||
element: TimelineElement,
|
||||
row: number,
|
||||
rowGeometry: TimelineRowGeometry,
|
||||
pixelsPerSecond: number,
|
||||
contentOrigin: number,
|
||||
allowHorizontal: boolean,
|
||||
): boolean {
|
||||
const clipLeft = contentOrigin + element.start * pixelsPerSecond;
|
||||
const target = computeRevealScroll({
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
viewportWidth: container.clientWidth,
|
||||
viewportHeight: container.clientHeight,
|
||||
clipLeft,
|
||||
clipRight: clipLeft + Math.max(element.duration * pixelsPerSecond, 4),
|
||||
clipTop: rowGeometry.getRowTop(row) + CLIP_Y,
|
||||
clipBottom: rowGeometry.getRowTop(row) + rowGeometry.getRowHeight(row) - CLIP_Y,
|
||||
stickyLeft: contentOrigin,
|
||||
stickyTop: RULER_H,
|
||||
allowHorizontal,
|
||||
});
|
||||
if (target.left !== null) container.scrollLeft = target.left;
|
||||
if (target.top !== null) container.scrollTop = target.top;
|
||||
const didScroll = target.left !== null || target.top !== null;
|
||||
if (didScroll) container.dispatchEvent(new Event("scroll"));
|
||||
return didScroll;
|
||||
}
|
||||
|
||||
function focusRevealedElement(container: HTMLDivElement, elementId: string): boolean {
|
||||
const clip = container.querySelector(`[data-el-id="${escapeSelectorValue(elementId)}"]`);
|
||||
if (!(clip instanceof HTMLElement)) return false;
|
||||
const alreadyHighlighted = clip.hasAttribute("data-reveal-highlight");
|
||||
clip.setAttribute("data-reveal-highlight", "true");
|
||||
clip.focus({ preventScroll: true });
|
||||
if (document.activeElement !== clip) {
|
||||
clip.removeAttribute("data-reveal-highlight");
|
||||
return false;
|
||||
}
|
||||
if (!alreadyHighlighted) {
|
||||
clip.addEventListener("blur", () => clip.removeAttribute("data-reveal-highlight"), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusAndConsumeReveal(
|
||||
container: HTMLDivElement,
|
||||
request: { elementId: string; nonce: number },
|
||||
deferUntilFocusPin: boolean,
|
||||
focusedElementId?: string,
|
||||
): void {
|
||||
if (!focusRevealedElement(container, request.elementId)) return;
|
||||
if (deferUntilFocusPin && focusedElementId !== request.elementId) return;
|
||||
if (usePlayerStore.getState().clipRevealRequest === request) {
|
||||
usePlayerStore.getState().clearClipRevealRequest();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRevealTarget(
|
||||
elements: readonly TimelineElement[],
|
||||
rowGeometry: TimelineRowGeometry,
|
||||
elementId: string,
|
||||
): { element: TimelineElement; row: number } | null {
|
||||
const element = elements.find((candidate) => getTimelineElementIdentity(candidate) === elementId);
|
||||
if (!element) return null;
|
||||
const row = rowGeometry.getRowIndex(element.track);
|
||||
return row < 0 ? null : { element, row };
|
||||
}
|
||||
|
||||
function shouldScrollReveal(
|
||||
previous: { request: { elementId: string; nonce: number }; sessionEpoch: number } | null,
|
||||
request: { elementId: string; nonce: number },
|
||||
sessionEpoch: number,
|
||||
): boolean {
|
||||
return previous?.request !== request || previous.sessionEpoch !== sessionEpoch;
|
||||
}
|
||||
|
||||
/** Coordinate-first reveal; the request remains pinned until its clip mounts. */
|
||||
export function useTimelineRevealClip({
|
||||
scrollRef,
|
||||
elements,
|
||||
rowGeometry,
|
||||
pixelsPerSecond,
|
||||
contentOrigin,
|
||||
allowHorizontal,
|
||||
deferFocusUntilViewportUpdate,
|
||||
focusedElementId,
|
||||
viewportVersion,
|
||||
sessionEpoch,
|
||||
}: UseTimelineRevealClipInput): void {
|
||||
const revealRequest = usePlayerStore((state) => state.clipRevealRequest);
|
||||
const scrolledRequestRef = useRef<{
|
||||
request: { elementId: string; nonce: number };
|
||||
sessionEpoch: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealRequest) return;
|
||||
// Consume the request first — reveal is one-shot, even when the clip node
|
||||
// isn't currently rendered (e.g. drilled into a different composition).
|
||||
usePlayerStore.getState().clearClipRevealRequest();
|
||||
|
||||
if (!revealRequest) {
|
||||
scrolledRequestRef.current = null;
|
||||
return;
|
||||
}
|
||||
const target = resolveRevealTarget(elements, rowGeometry, revealRequest.elementId);
|
||||
if (!target) {
|
||||
usePlayerStore.getState().clearClipRevealRequest();
|
||||
return;
|
||||
}
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
|
||||
if (!(clip instanceof HTMLElement)) return;
|
||||
if (container.clientWidth <= 0 || container.clientHeight <= 0) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const clipRect = clip.getBoundingClientRect();
|
||||
const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
|
||||
const clipTop = clipRect.top - containerRect.top + container.scrollTop;
|
||||
if (shouldScrollReveal(scrolledRequestRef.current, revealRequest, sessionEpoch)) {
|
||||
scrolledRequestRef.current = { request: revealRequest, sessionEpoch };
|
||||
const didScroll = scrollToTimelineElement(
|
||||
container,
|
||||
target.element,
|
||||
target.row,
|
||||
rowGeometry,
|
||||
pixelsPerSecond,
|
||||
contentOrigin,
|
||||
allowHorizontal,
|
||||
);
|
||||
// Keep the reveal pin alive until the scroll snapshot catches up. If the
|
||||
// request were consumed here, horizontal windowing could unmount and
|
||||
// recreate the focused clip between the programmatic scroll and the next
|
||||
// viewport publication.
|
||||
if (didScroll && deferFocusUntilViewportUpdate) return;
|
||||
}
|
||||
|
||||
const target = computeRevealScroll({
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
viewportWidth: container.clientWidth,
|
||||
viewportHeight: container.clientHeight,
|
||||
clipLeft,
|
||||
clipRight: clipLeft + clipRect.width,
|
||||
clipTop,
|
||||
clipBottom: clipTop + clipRect.height,
|
||||
stickyLeft: GUTTER,
|
||||
stickyTop: RULER_H,
|
||||
allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
|
||||
});
|
||||
if (target.left === null && target.top === null) return;
|
||||
container.scrollTo({
|
||||
left: target.left ?? container.scrollLeft,
|
||||
top: target.top ?? container.scrollTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [revealRequest, scrollRef]);
|
||||
// Focus is the durable row/clip pin. Do not consume the reveal pin until
|
||||
// the focus listener has published that replacement, or windowing can
|
||||
// briefly unmount and recreate the element between the two owners.
|
||||
focusAndConsumeReveal(
|
||||
container,
|
||||
revealRequest,
|
||||
deferFocusUntilViewportUpdate,
|
||||
focusedElementId,
|
||||
);
|
||||
}, [
|
||||
allowHorizontal,
|
||||
contentOrigin,
|
||||
deferFocusUntilViewportUpdate,
|
||||
elements,
|
||||
focusedElementId,
|
||||
pixelsPerSecond,
|
||||
revealRequest,
|
||||
rowGeometry,
|
||||
scrollRef,
|
||||
sessionEpoch,
|
||||
viewportVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FocusEvent as ReactFocusEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { resolveTimelineFocusIdentity } from "./timelineFocusIdentity";
|
||||
import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry";
|
||||
import type { TimelineRowGeometry } from "./timelineLayout";
|
||||
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
||||
import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag";
|
||||
import { useTimelineVirtualRows } from "./useTimelineVirtualRows";
|
||||
|
||||
interface UseTimelineRowVirtualizationInput {
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
viewport: TimelineScrollViewportSnapshot;
|
||||
rowGeometry: TimelineRowGeometry;
|
||||
sessionEpoch: number;
|
||||
elements: TimelineElement[];
|
||||
selectedElementId: string | null;
|
||||
revealElementId: string | null;
|
||||
draggedRowKey?: number;
|
||||
resizingRowKey?: number;
|
||||
clipContextMenuRowKey?: number;
|
||||
keyframeContextMenuRowKey?: number;
|
||||
lastScrollLeftRef: RefObject<number>;
|
||||
syncScrollViewport: (element: HTMLDivElement, isScrolling?: boolean) => void;
|
||||
}
|
||||
|
||||
interface TimelineDomFocusPin {
|
||||
readonly rowKey?: number;
|
||||
readonly elementId?: string;
|
||||
}
|
||||
|
||||
function getTimelineDomFocusPin(target: EventTarget | null): TimelineDomFocusPin | undefined {
|
||||
if (!(target instanceof Element)) return undefined;
|
||||
const value = target.closest<HTMLElement>("[data-timeline-row-key]")?.dataset.timelineRowKey;
|
||||
const parsedRowKey = value === undefined ? undefined : Number(value);
|
||||
const rowKey =
|
||||
parsedRowKey !== undefined && Number.isFinite(parsedRowKey) ? parsedRowKey : undefined;
|
||||
const elementId = target.closest<HTMLElement>("[data-el-id]")?.dataset.elId;
|
||||
return rowKey === undefined && elementId === undefined ? undefined : { rowKey, elementId };
|
||||
}
|
||||
|
||||
export function useTimelineRowVirtualization({
|
||||
scrollRef,
|
||||
viewport,
|
||||
rowGeometry,
|
||||
sessionEpoch,
|
||||
elements,
|
||||
selectedElementId,
|
||||
revealElementId,
|
||||
draggedRowKey,
|
||||
resizingRowKey,
|
||||
clipContextMenuRowKey,
|
||||
keyframeContextMenuRowKey,
|
||||
lastScrollLeftRef,
|
||||
syncScrollViewport,
|
||||
}: UseTimelineRowVirtualizationInput) {
|
||||
const enabled = STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED;
|
||||
const [domFocusPin, setDomFocusPin] = useState<TimelineDomFocusPin>();
|
||||
const onTimelineFocus = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
|
||||
setDomFocusPin(getTimelineDomFocusPin(event.target));
|
||||
}, []);
|
||||
const onTimelineBlur = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
|
||||
setDomFocusPin(getTimelineDomFocusPin(event.relatedTarget));
|
||||
}, []);
|
||||
const focusIdentity = useMemo(
|
||||
() => resolveTimelineFocusIdentity(elements, selectedElementId),
|
||||
[elements, selectedElementId],
|
||||
);
|
||||
const revealIdentity = useMemo(
|
||||
() => resolveTimelineFocusIdentity(elements, revealElementId),
|
||||
[elements, revealElementId],
|
||||
);
|
||||
const pinnedRowKeys = useMemo(
|
||||
() =>
|
||||
[
|
||||
draggedRowKey,
|
||||
resizingRowKey,
|
||||
revealIdentity?.rowKey,
|
||||
clipContextMenuRowKey,
|
||||
keyframeContextMenuRowKey,
|
||||
].filter((rowKey): rowKey is number => rowKey !== undefined),
|
||||
[
|
||||
clipContextMenuRowKey,
|
||||
draggedRowKey,
|
||||
keyframeContextMenuRowKey,
|
||||
resizingRowKey,
|
||||
revealIdentity,
|
||||
],
|
||||
);
|
||||
const virtualRows = useTimelineVirtualRows({
|
||||
enabled,
|
||||
scrollRef,
|
||||
viewport,
|
||||
rowGeometry,
|
||||
sessionEpoch,
|
||||
pinnedRowKeys,
|
||||
focusedRowKey: domFocusPin?.rowKey ?? focusIdentity?.rowKey,
|
||||
});
|
||||
|
||||
const previousLayoutRef = useRef(rowGeometry);
|
||||
const previousSessionEpochRef = useRef(sessionEpoch);
|
||||
useLayoutEffect(() => {
|
||||
const scroll = scrollRef.current;
|
||||
const previousGeometry = previousLayoutRef.current;
|
||||
if (previousSessionEpochRef.current !== sessionEpoch) {
|
||||
previousSessionEpochRef.current = sessionEpoch;
|
||||
lastScrollLeftRef.current = 0;
|
||||
if (scroll) {
|
||||
scroll.scrollLeft = 0;
|
||||
scroll.scrollTop = 0;
|
||||
syncScrollViewport(scroll);
|
||||
}
|
||||
} else if (scroll && previousGeometry !== rowGeometry) {
|
||||
const nextScrollTop = getTimelineScrollTopForGeometryChange(
|
||||
previousGeometry,
|
||||
rowGeometry,
|
||||
scroll.scrollTop,
|
||||
);
|
||||
if (nextScrollTop !== scroll.scrollTop) {
|
||||
scroll.scrollTop = nextScrollTop;
|
||||
syncScrollViewport(scroll);
|
||||
}
|
||||
}
|
||||
previousLayoutRef.current = rowGeometry;
|
||||
}, [lastScrollLeftRef, rowGeometry, scrollRef, sessionEpoch, syncScrollViewport]);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
virtualRows,
|
||||
focusedElementId: domFocusPin?.elementId,
|
||||
timelineFocusProps: { onFocus: onTimelineFocus, onBlur: onTimelineBlur },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let resizeCallback: ResizeObserverCallback | null = null;
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
resizeCallback = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
interface Harness {
|
||||
hook: () => ReturnType<
|
||||
typeof import("./useTimelineScrollViewport").useTimelineScrollViewport
|
||||
> | null;
|
||||
element: HTMLDivElement;
|
||||
values: {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scrollWidth: number;
|
||||
scrollHeight: number;
|
||||
};
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row-virtualization flag is a module constant, so the env stub has to be in
|
||||
* place before the hook module is imported. Each harness therefore resets the
|
||||
* module registry and imports fresh, which is what lets one file exercise both
|
||||
* flag states.
|
||||
*/
|
||||
async function mountHarness(rowVirtualizationEnabled: boolean): Promise<Harness> {
|
||||
vi.stubEnv(
|
||||
"VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED",
|
||||
rowVirtualizationEnabled ? "1" : "0",
|
||||
);
|
||||
vi.resetModules();
|
||||
const { useTimelineScrollViewport } = await import("./useTimelineScrollViewport");
|
||||
|
||||
let current: ReturnType<typeof useTimelineScrollViewport> | null = null;
|
||||
function Probe() {
|
||||
current = useTimelineScrollViewport(useRef<HTMLDivElement>(null), []);
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(React.createElement(Probe)));
|
||||
|
||||
const element = document.createElement("div");
|
||||
const values = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 640,
|
||||
height: 240,
|
||||
scrollWidth: 1200,
|
||||
scrollHeight: 800,
|
||||
};
|
||||
Object.defineProperties(element, {
|
||||
scrollLeft: { configurable: true, get: () => values.left },
|
||||
scrollTop: { configurable: true, get: () => values.top },
|
||||
clientWidth: { configurable: true, get: () => values.width },
|
||||
clientHeight: { configurable: true, get: () => values.height },
|
||||
scrollWidth: { configurable: true, get: () => values.scrollWidth },
|
||||
scrollHeight: { configurable: true, get: () => values.scrollHeight },
|
||||
});
|
||||
|
||||
return { hook: () => current, element, values, unmount: () => act(() => root.unmount()) };
|
||||
}
|
||||
|
||||
function attachScrollElement({ hook, element }: Harness): void {
|
||||
act(() => hook()?.setScrollRef(element));
|
||||
}
|
||||
|
||||
function publishResize(harness: Harness, width: number): void {
|
||||
harness.values.width = width;
|
||||
act(() => resizeCallback?.([], {} as ResizeObserver));
|
||||
}
|
||||
|
||||
function publishScroll(
|
||||
harness: Harness,
|
||||
{ left, top }: { left: number; top: number },
|
||||
isScrolling = true,
|
||||
): void {
|
||||
harness.values.left = left;
|
||||
harness.values.top = top;
|
||||
act(() => harness.hook()?.syncScrollViewport(harness.element, isScrolling));
|
||||
act(() => vi.advanceTimersByTime(16));
|
||||
}
|
||||
|
||||
function expectResizePublication(harness: Harness): void {
|
||||
attachScrollElement(harness);
|
||||
expect(harness.hook()?.viewport.clientWidth).toBe(640);
|
||||
|
||||
publishResize(harness, 800);
|
||||
expect(harness.hook()?.viewport.clientWidth).toBe(800);
|
||||
}
|
||||
|
||||
describe("useTimelineScrollViewport with row virtualization on", () => {
|
||||
it("publishes resize, scroll, and settled snapshots", async () => {
|
||||
const harness = await mountHarness(true);
|
||||
const { hook, unmount } = harness;
|
||||
|
||||
expectResizePublication(harness);
|
||||
|
||||
publishScroll(harness, { left: 120, top: 48 });
|
||||
expect(hook()?.viewport).toMatchObject({ scrollLeft: 120, scrollTop: 48, isScrolling: true });
|
||||
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
expect(hook()?.viewport.isScrolling).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTimelineScrollViewport with row virtualization off", () => {
|
||||
it("publishes nothing while scrolling", async () => {
|
||||
const harness = await mountHarness(false);
|
||||
const { hook, unmount } = harness;
|
||||
|
||||
attachScrollElement(harness);
|
||||
const before = hook()?.viewport;
|
||||
|
||||
publishScroll(harness, { left: 120, top: 48 });
|
||||
|
||||
expect(hook()?.viewport).toBe(before);
|
||||
expect(hook()?.viewport.scrollLeft).toBe(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("never reports isScrolling, so the settle timer has nothing to undo", async () => {
|
||||
const harness = await mountHarness(false);
|
||||
const { hook, unmount } = harness;
|
||||
|
||||
attachScrollElement(harness);
|
||||
publishScroll(harness, { left: 0, top: 0 });
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
|
||||
expect(hook()?.viewport.isScrolling).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("schedules no frame or timer for a scroll sync", async () => {
|
||||
const harness = await mountHarness(false);
|
||||
const { hook, element, unmount } = harness;
|
||||
attachScrollElement(harness);
|
||||
|
||||
const rafSpy = vi.spyOn(globalThis, "requestAnimationFrame");
|
||||
const timerSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
act(() => hook()?.syncScrollViewport(element, true));
|
||||
|
||||
expect(rafSpy).not.toHaveBeenCalled();
|
||||
expect(timerSpy).not.toHaveBeenCalled();
|
||||
rafSpy.mockRestore();
|
||||
timerSpy.mockRestore();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("still publishes resize-driven snapshots", async () => {
|
||||
const harness = await mountHarness(false);
|
||||
const { unmount } = harness;
|
||||
|
||||
expectResizePublication(harness);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("still publishes programmatic non-scrolling syncs", async () => {
|
||||
const harness = await mountHarness(false);
|
||||
const { hook, unmount } = harness;
|
||||
|
||||
attachScrollElement(harness);
|
||||
publishScroll(harness, { left: 240, top: 96 }, false);
|
||||
|
||||
expect(hook()?.viewport).toMatchObject({
|
||||
scrollLeft: 240,
|
||||
scrollTop: 96,
|
||||
isScrolling: false,
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,45 @@
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { shouldShowTimelineShortcutHint } from "./timelineLayout";
|
||||
import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag";
|
||||
|
||||
export interface TimelineScrollViewportSnapshot {
|
||||
readonly scrollLeft: number;
|
||||
readonly scrollTop: number;
|
||||
readonly clientWidth: number;
|
||||
readonly clientHeight: number;
|
||||
readonly scrollWidth: number;
|
||||
readonly scrollHeight: number;
|
||||
readonly isScrolling: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_VIEWPORT: TimelineScrollViewportSnapshot = Object.freeze({
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
clientWidth: 0,
|
||||
clientHeight: 0,
|
||||
scrollWidth: 0,
|
||||
scrollHeight: 0,
|
||||
isScrolling: false,
|
||||
});
|
||||
|
||||
function readTimelineScrollViewport(
|
||||
element: Pick<
|
||||
HTMLElement,
|
||||
"scrollLeft" | "scrollTop" | "clientWidth" | "clientHeight" | "scrollWidth" | "scrollHeight"
|
||||
>,
|
||||
isScrolling: boolean,
|
||||
): TimelineScrollViewportSnapshot {
|
||||
return {
|
||||
scrollLeft: element.scrollLeft,
|
||||
scrollTop: element.scrollTop,
|
||||
clientWidth: element.clientWidth,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollWidth: element.scrollWidth,
|
||||
scrollHeight: element.scrollHeight,
|
||||
isScrolling,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline scroll container's viewport plumbing — extracted verbatim from
|
||||
@@ -14,14 +53,46 @@ export function useTimelineScrollViewport(
|
||||
scrollRef: RefObject<HTMLDivElement | null>,
|
||||
resyncShortcutHintOn: ReadonlyArray<unknown>,
|
||||
): {
|
||||
viewportWidth: number;
|
||||
viewport: TimelineScrollViewportSnapshot;
|
||||
showShortcutHint: boolean;
|
||||
setScrollRef: (el: HTMLDivElement | null) => void;
|
||||
syncScrollViewport: (el: HTMLDivElement, isScrolling?: boolean) => void;
|
||||
} {
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
const [viewport, setViewport] = useState<TimelineScrollViewportSnapshot>(EMPTY_VIEWPORT);
|
||||
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const shortcutHintRafRef = useRef(0);
|
||||
const viewportRafRef = useRef(0);
|
||||
const scrollSettledTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scrollingRef = useRef(false);
|
||||
|
||||
const syncScrollViewport = useCallback((el: HTMLDivElement, isScrolling = false) => {
|
||||
// Row virtualization is the only consumer of the per-frame scroll snapshot.
|
||||
// With the flag off, publishing it re-rendered every mounted clip on every
|
||||
// scroll frame and bought nothing, so the scroll path stops here and
|
||||
// `isScrolling` stays false. Resize-driven and programmatic syncs arrive
|
||||
// through the immediate path below and still publish.
|
||||
if (isScrolling && !STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED) return;
|
||||
scrollingRef.current = isScrolling;
|
||||
const publish = () => {
|
||||
viewportRafRef.current = 0;
|
||||
setViewport(readTimelineScrollViewport(el, scrollingRef.current));
|
||||
};
|
||||
if (isScrolling) {
|
||||
if (!viewportRafRef.current) viewportRafRef.current = requestAnimationFrame(publish);
|
||||
} else {
|
||||
if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current);
|
||||
scrollSettledTimerRef.current = setTimeout(() => {
|
||||
scrollSettledTimerRef.current = null;
|
||||
scrollingRef.current = false;
|
||||
if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
|
||||
publish();
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const syncShortcutHintVisibility = useCallback(() => {
|
||||
const scroll = scrollRef.current;
|
||||
@@ -45,23 +116,30 @@ export function useTimelineScrollViewport(
|
||||
roRef.current = null;
|
||||
}
|
||||
scrollRef.current = el;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current);
|
||||
scrollSettledTimerRef.current = null;
|
||||
scrollingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const syncScrollViewport = () => {
|
||||
setViewportWidth(el.clientWidth);
|
||||
const syncResize = () => {
|
||||
syncScrollViewport(el, scrollingRef.current);
|
||||
scheduleShortcutHintVisibilitySync();
|
||||
};
|
||||
|
||||
syncScrollViewport();
|
||||
roRef.current = new ResizeObserver(syncScrollViewport);
|
||||
syncResize();
|
||||
roRef.current = new ResizeObserver(syncResize);
|
||||
roRef.current.observe(el);
|
||||
},
|
||||
[scrollRef, scheduleShortcutHintVisibilitySync],
|
||||
[scrollRef, scheduleShortcutHintVisibilitySync, syncScrollViewport],
|
||||
);
|
||||
|
||||
useMountEffect(() => () => {
|
||||
roRef.current?.disconnect();
|
||||
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
||||
if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
|
||||
if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,5 +147,5 @@ export function useTimelineScrollViewport(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [syncShortcutHintVisibility, ...resyncShortcutHintOn]);
|
||||
|
||||
return { viewportWidth, showShortcutHint, setScrollRef };
|
||||
return { viewport, showShortcutHint, setScrollRef, syncScrollViewport };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
|
||||
export function useTimelineSelectionLifecycle(
|
||||
elements: TimelineElement[],
|
||||
selectedElementId: string | null,
|
||||
setShowPopover: (show: boolean) => void,
|
||||
clearRangeSelection: () => void,
|
||||
): void {
|
||||
const selectedElement = useMemo(
|
||||
() =>
|
||||
elements.find((element) => getTimelineElementIdentity(element) === selectedElementId) ?? null,
|
||||
[elements, selectedElementId],
|
||||
);
|
||||
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
|
||||
selectedElementRef.current = selectedElement;
|
||||
const previousSelectedRef = useRef(selectedElementRef.current);
|
||||
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
const previous = previousSelectedRef.current;
|
||||
const current = selectedElementRef.current;
|
||||
previousSelectedRef.current = current;
|
||||
if (previous && !current) {
|
||||
setShowPopover(false);
|
||||
clearRangeSelection();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
export function useTimelineShiftModifier(): boolean {
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
useMountEffect(() => {
|
||||
const handleKey = (event: KeyboardEvent) =>
|
||||
event.key === "Shift" && setShiftHeld(event.type === "keydown");
|
||||
const handleBlur = () => setShiftHeld(false);
|
||||
window.addEventListener("keydown", handleKey);
|
||||
window.addEventListener("keyup", handleKey);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKey);
|
||||
window.removeEventListener("keyup", handleKey);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
});
|
||||
return shiftHeld;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMemo } from "react";
|
||||
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||
import type { TimelineTimeDisplayMode } from "../../utils/studioUiPreferences";
|
||||
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
||||
import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
|
||||
|
||||
export function useTimelineTicks(
|
||||
duration: number,
|
||||
pixelsPerSecond: number,
|
||||
timeDisplayMode: TimelineTimeDisplayMode,
|
||||
renderTimeRange?: TimelineTimeRange,
|
||||
) {
|
||||
const frameRate = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
|
||||
const ticks = useMemo(
|
||||
() => generateTicks(duration, pixelsPerSecond, frameRate, renderTimeRange),
|
||||
[duration, frameRate, pixelsPerSecond, renderTimeRange],
|
||||
);
|
||||
return {
|
||||
...ticks,
|
||||
majorTickInterval: getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate),
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,28 @@ afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
function renderTrackLayout(
|
||||
elements: TimelineElement[],
|
||||
animations: Map<string, GsapAnimation[]>,
|
||||
): {
|
||||
layout: ReturnType<typeof useTimelineTrackLayout>;
|
||||
unmount: () => void;
|
||||
} {
|
||||
usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
|
||||
|
||||
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
||||
function Probe() {
|
||||
layout = useTimelineTrackLayout(elements, animations, null, new Set());
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(React.createElement(Probe)));
|
||||
if (!layout) throw new Error("Timeline track layout did not render");
|
||||
|
||||
return { layout, unmount: () => act(() => root.unmount()) };
|
||||
}
|
||||
|
||||
describe("useTimelineTrackLayout", () => {
|
||||
it("counts a flat tween lane and reserves its expanded row height", () => {
|
||||
const elements: TimelineElement[] = [
|
||||
@@ -36,20 +58,13 @@ describe("useTimelineTrackLayout", () => {
|
||||
],
|
||||
],
|
||||
]);
|
||||
usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
|
||||
const { layout, unmount } = renderTrackLayout(elements, animations);
|
||||
|
||||
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
||||
function Probe() {
|
||||
layout = useTimelineTrackLayout(elements, animations, null, new Set());
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(React.createElement(Probe)));
|
||||
|
||||
expect(layout?.laneCounts.get("clip-1")).toBe(1);
|
||||
expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]);
|
||||
act(() => root.unmount());
|
||||
expect(layout.laneCounts.get("clip-1")).toBe(1);
|
||||
expect(layout.rowHeights).toEqual([TRACK_H + LANE_H]);
|
||||
expect(layout.rowGeometry.rowKeys).toEqual([0]);
|
||||
expect(layout.rowGeometry.canvasHeight).toBeGreaterThan(TRACK_H + LANE_H);
|
||||
unmount();
|
||||
});
|
||||
|
||||
// The row height reserved here and the lanes actually rendered are two
|
||||
@@ -69,20 +84,11 @@ describe("useTimelineTrackLayout", () => {
|
||||
properties: { x: 420, opacity: 1 },
|
||||
};
|
||||
const animations = new Map<string, GsapAnimation[]>([["clip-1", [mixed]]]);
|
||||
usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
|
||||
|
||||
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
||||
function Probe() {
|
||||
layout = useTimelineTrackLayout(elements, animations, null, new Set());
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(React.createElement(Probe)));
|
||||
const { layout, unmount } = renderTrackLayout(elements, animations);
|
||||
|
||||
expect(getTimelinePropertyLanes([mixed], 0, 1)).toHaveLength(2);
|
||||
expect(layout?.laneCounts.get("clip-1")).toBe(2);
|
||||
expect(layout?.rowHeights).toEqual([TRACK_H + 2 * LANE_H]);
|
||||
act(() => root.unmount());
|
||||
expect(layout.laneCounts.get("clip-1")).toBe(2);
|
||||
expect(layout.rowHeights).toEqual([TRACK_H + 2 * LANE_H]);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
||||
import {
|
||||
TRACK_H,
|
||||
getTimelineCanvasHeight,
|
||||
getTimelineRowHeight,
|
||||
createTimelineRowGeometry,
|
||||
type TimelineRowGeometry,
|
||||
trackHeights,
|
||||
type TimelineTrackHeightClip,
|
||||
} from "./timelineLayout";
|
||||
@@ -72,7 +72,7 @@ function useTimelineRowHeights(
|
||||
selectedElementIds: ReadonlySet<string>,
|
||||
) {
|
||||
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
||||
const { laneCounts, rowHeights } = useMemo(() => {
|
||||
const { laneCounts, rowGeometry } = useMemo(() => {
|
||||
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
|
||||
// Row height follows only the active keyframe clip, so a track with several
|
||||
// keyframed elements never reserves empty lanes for the ones not shown.
|
||||
@@ -87,14 +87,23 @@ function useTimelineRowHeights(
|
||||
const clipId = active.key ?? active.id;
|
||||
return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }];
|
||||
});
|
||||
const rowHeights = trackHeights(heightTracks, expandedClipIds);
|
||||
return {
|
||||
laneCounts,
|
||||
rowHeights: trackHeights(heightTracks, expandedClipIds),
|
||||
rowGeometry: createTimelineRowGeometry(
|
||||
tracks.map(([track]) => track),
|
||||
rowHeights,
|
||||
),
|
||||
};
|
||||
}, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
|
||||
const rowHeightsRef = useRef<readonly number[]>(rowHeights);
|
||||
rowHeightsRef.current = rowHeights;
|
||||
return { laneCounts, rowHeights, rowHeightsRef };
|
||||
const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
|
||||
rowGeometryRef.current = rowGeometry;
|
||||
return {
|
||||
laneCounts,
|
||||
rowGeometry,
|
||||
rowGeometryRef,
|
||||
rowHeights: rowGeometry.rowHeights,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimelineTrackLayout(
|
||||
@@ -106,7 +115,7 @@ export function useTimelineTrackLayout(
|
||||
const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
|
||||
const trackOrderRef = useRef(trackOrder);
|
||||
trackOrderRef.current = trackOrder;
|
||||
const { laneCounts, rowHeights, rowHeightsRef } = useTimelineRowHeights(
|
||||
const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
|
||||
tracks,
|
||||
gsapAnimations,
|
||||
selectedElementId,
|
||||
@@ -119,23 +128,23 @@ export function useTimelineTrackLayout(
|
||||
trackOrder,
|
||||
trackOrderRef,
|
||||
laneCounts,
|
||||
rowGeometry,
|
||||
rowGeometryRef,
|
||||
rowHeights,
|
||||
rowHeightsRef,
|
||||
};
|
||||
}
|
||||
|
||||
function useDisplayRowHeights(
|
||||
displayTrackOrder: readonly number[],
|
||||
trackOrder: readonly number[],
|
||||
rowHeights: readonly number[],
|
||||
rowGeometry: TimelineRowGeometry,
|
||||
) {
|
||||
return useMemo(
|
||||
() =>
|
||||
displayTrackOrder.map((track) => {
|
||||
const row = trackOrder.indexOf(track);
|
||||
return row < 0 ? TRACK_H : getTimelineRowHeight(row, rowHeights);
|
||||
const row = rowGeometry.getRowIndex(track);
|
||||
return row < 0 ? TRACK_H : rowGeometry.getRowHeight(row);
|
||||
}),
|
||||
[displayTrackOrder, trackOrder, rowHeights],
|
||||
[displayTrackOrder, rowGeometry],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,10 +158,18 @@ function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder:
|
||||
export function useTimelineDisplayLayout(
|
||||
draggedClip: DraggedClipState | null,
|
||||
trackOrder: number[],
|
||||
rowHeights: readonly number[],
|
||||
rowGeometry: TimelineRowGeometry,
|
||||
) {
|
||||
const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder);
|
||||
const displayRowHeights = useDisplayRowHeights(displayTrackOrder, trackOrder, rowHeights);
|
||||
const totalH = getTimelineCanvasHeight(displayRowHeights);
|
||||
return { displayTrackOrder, displayRowHeights, totalH };
|
||||
const displayRowHeights = useDisplayRowHeights(displayTrackOrder, rowGeometry);
|
||||
const displayRowGeometry = useMemo(
|
||||
() => createTimelineRowGeometry(displayTrackOrder, displayRowHeights),
|
||||
[displayTrackOrder, displayRowHeights],
|
||||
);
|
||||
return {
|
||||
displayTrackOrder,
|
||||
displayRowHeights: displayRowGeometry.rowHeights,
|
||||
rowGeometry: displayRowGeometry,
|
||||
totalH: displayRowGeometry.canvasHeight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createTimelineRowGeometry, RULER_H, TRACKS_TOP_PAD } from "./timelineLayout";
|
||||
import { extractTimelineVirtualRowRange, useTimelineVirtualRows } from "./useTimelineVirtualRows";
|
||||
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||
observe(target: Element) {
|
||||
const { width, height } = target.getBoundingClientRect();
|
||||
this.callback(
|
||||
[
|
||||
{
|
||||
target,
|
||||
borderBoxSize: [{ inlineSize: width, blockSize: height }],
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
this as unknown as ResizeObserver,
|
||||
);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function createScrollElement(scrollTop: number, width = 800, height = 192): HTMLDivElement {
|
||||
const element = document.createElement("div");
|
||||
document.body.append(element);
|
||||
Object.defineProperties(element, {
|
||||
scrollTop: { configurable: true, writable: true, value: scrollTop },
|
||||
scrollLeft: { configurable: true, writable: true, value: 0 },
|
||||
clientWidth: { configurable: true, value: width },
|
||||
clientHeight: { configurable: true, value: height },
|
||||
scrollWidth: { configurable: true, value: width },
|
||||
scrollHeight: { configurable: true, value: 60_000 },
|
||||
});
|
||||
element.getBoundingClientRect = () =>
|
||||
({ width, height, top: 0, left: 0, right: width, bottom: height, x: 0, y: 0 }) as DOMRect;
|
||||
return element;
|
||||
}
|
||||
|
||||
function viewport(element: HTMLDivElement): TimelineScrollViewportSnapshot {
|
||||
return {
|
||||
scrollLeft: element.scrollLeft,
|
||||
scrollTop: element.scrollTop,
|
||||
clientWidth: element.clientWidth,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollWidth: element.scrollWidth,
|
||||
scrollHeight: element.scrollHeight,
|
||||
isScrolling: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createLargeGeometry(keyOffset = 0) {
|
||||
const keys = Array.from({ length: 1_000 }, (_, index) => index + keyOffset);
|
||||
return createTimelineRowGeometry(
|
||||
keys,
|
||||
keys.map(() => 48),
|
||||
);
|
||||
}
|
||||
|
||||
describe("extractTimelineVirtualRowRange", () => {
|
||||
it("unions visible overscan with unique actor pins", () => {
|
||||
expect(
|
||||
extractTimelineVirtualRowRange(
|
||||
{ startIndex: 10, endIndex: 12, overscan: 2, count: 100 },
|
||||
[0, 11, 99, 99, -1, 100],
|
||||
),
|
||||
).toEqual([0, 8, 9, 10, 11, 12, 13, 14, 99]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTimelineVirtualRows", () => {
|
||||
it("mounts a bounded vertical range plus the focused row", () => {
|
||||
const geometry = createLargeGeometry(0.5);
|
||||
const scroll = createScrollElement(RULER_H + TRACKS_TOP_PAD + 500 * 48);
|
||||
const scrollRef = { current: scroll };
|
||||
let rows: ReturnType<typeof useTimelineVirtualRows> = [];
|
||||
|
||||
function Probe() {
|
||||
rows = useTimelineVirtualRows({
|
||||
enabled: true,
|
||||
scrollRef,
|
||||
viewport: viewport(scroll),
|
||||
rowGeometry: geometry,
|
||||
sessionEpoch: 1,
|
||||
pinnedRowKeys: [],
|
||||
focusedRowKey: 900.5,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(React.createElement(Probe)));
|
||||
act(() => {});
|
||||
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.length).toBeLessThanOrEqual(16);
|
||||
expect(rows.some((row) => row.index === 500)).toBe(true);
|
||||
expect(rows.some((row) => row.rowKey === 900.5)).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps compatibility rendering complete while the gate is disabled", () => {
|
||||
const geometry = createTimelineRowGeometry([10, 5.5, 20], [48, 76, 48]);
|
||||
const scroll = createScrollElement(0);
|
||||
let rows: ReturnType<typeof useTimelineVirtualRows> = [];
|
||||
|
||||
function Probe() {
|
||||
rows = useTimelineVirtualRows({
|
||||
enabled: false,
|
||||
scrollRef: { current: scroll },
|
||||
viewport: viewport(scroll),
|
||||
rowGeometry: geometry,
|
||||
sessionEpoch: 1,
|
||||
pinnedRowKeys: [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(React.createElement(Probe)));
|
||||
expect(rows.map((row) => row.rowKey)).toEqual([10, 5.5, 20]);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("observes Timeline's authoritative scroll reset when the session epoch changes", () => {
|
||||
const geometry = createLargeGeometry();
|
||||
const scroll = createScrollElement(RULER_H + TRACKS_TOP_PAD + 500 * 48);
|
||||
const scrollRef = { current: scroll };
|
||||
let rows: ReturnType<typeof useTimelineVirtualRows> = [];
|
||||
|
||||
function Probe({ sessionEpoch }: { sessionEpoch: number }) {
|
||||
rows = useTimelineVirtualRows({
|
||||
enabled: true,
|
||||
scrollRef,
|
||||
viewport: viewport(scroll),
|
||||
rowGeometry: geometry,
|
||||
sessionEpoch,
|
||||
pinnedRowKeys: [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(React.createElement(Probe, { sessionEpoch: 1 })));
|
||||
expect(rows.some((row) => row.index === 500)).toBe(true);
|
||||
|
||||
scroll.scrollTop = 0;
|
||||
act(() => root.render(React.createElement(Probe, { sessionEpoch: 2 })));
|
||||
expect(rows.some((row) => row.index === 0)).toBe(true);
|
||||
expect(rows.some((row) => row.index === 500)).toBe(false);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useMemo, type RefObject } from "react";
|
||||
import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual";
|
||||
import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
|
||||
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
||||
import { RULER_H, TRACKS_TOP_PAD, type TimelineRowGeometry } from "./timelineLayout";
|
||||
|
||||
export interface TimelineVirtualRow {
|
||||
readonly index: number;
|
||||
readonly rowKey: number;
|
||||
}
|
||||
|
||||
export function extractTimelineVirtualRowRange(
|
||||
range: Range,
|
||||
pinnedRowIndexes: readonly number[],
|
||||
): number[] {
|
||||
const indexes = new Set(defaultRangeExtractor(range));
|
||||
for (const index of pinnedRowIndexes) {
|
||||
if (index >= 0 && index < range.count) indexes.add(index);
|
||||
}
|
||||
return [...indexes].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function extractTimelineVirtualRowSeed(
|
||||
count: number,
|
||||
pinnedRowIndexes: readonly number[],
|
||||
): number[] {
|
||||
const seedCount = Math.min(count, TIMELINE_VIEWPORT_BUDGETS.rowOverscanPerSide * 2 + 1);
|
||||
const indexes = new Set(Array.from({ length: seedCount }, (_, index) => index));
|
||||
for (const index of pinnedRowIndexes) {
|
||||
if (index >= 0 && index < count) indexes.add(index);
|
||||
}
|
||||
return [...indexes].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
interface UseTimelineVirtualRowsInput {
|
||||
enabled: boolean;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
viewport: TimelineScrollViewportSnapshot;
|
||||
rowGeometry: TimelineRowGeometry;
|
||||
sessionEpoch: number;
|
||||
pinnedRowKeys: readonly number[];
|
||||
focusedRowKey?: number;
|
||||
}
|
||||
|
||||
export function useTimelineVirtualRows({
|
||||
enabled,
|
||||
scrollRef,
|
||||
viewport,
|
||||
rowGeometry,
|
||||
sessionEpoch,
|
||||
pinnedRowKeys,
|
||||
focusedRowKey,
|
||||
}: UseTimelineVirtualRowsInput): readonly TimelineVirtualRow[] {
|
||||
const viewportReady = viewport.clientWidth > 0 && viewport.clientHeight > 0;
|
||||
const pinnedRowIndexes = useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
[...pinnedRowKeys, ...(focusedRowKey === undefined ? [] : [focusedRowKey])].map((key) =>
|
||||
rowGeometry.getRowIndex(key),
|
||||
),
|
||||
),
|
||||
],
|
||||
[focusedRowKey, pinnedRowKeys, rowGeometry],
|
||||
);
|
||||
const estimateSize = useCallback(
|
||||
(index: number) => rowGeometry.getRowHeight(index),
|
||||
[rowGeometry],
|
||||
);
|
||||
const getItemKey = useCallback(
|
||||
(index: number) => rowGeometry.rowKeys[index] ?? index,
|
||||
[rowGeometry],
|
||||
);
|
||||
const rangeExtractor = useCallback(
|
||||
(range: Range) => extractTimelineVirtualRowRange(range, pinnedRowIndexes),
|
||||
[pinnedRowIndexes],
|
||||
);
|
||||
const initialOffset = useCallback(() => viewport.scrollTop, [viewport.scrollTop]);
|
||||
const virtualizer = useVirtualizer({
|
||||
enabled: enabled && viewportReady,
|
||||
useFlushSync: false,
|
||||
count: rowGeometry.rowKeys.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize,
|
||||
getItemKey,
|
||||
overscan: TIMELINE_VIEWPORT_BUDGETS.rowOverscanPerSide,
|
||||
rangeExtractor,
|
||||
scrollMargin: RULER_H + TRACKS_TOP_PAD,
|
||||
initialRect: { width: viewport.clientWidth, height: viewport.clientHeight },
|
||||
initialOffset,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !viewportReady) return;
|
||||
virtualizer.measure();
|
||||
// Timeline owns the epoch reset. This event only makes the virtualizer
|
||||
// observe the authoritative DOM offset after that reset; it never writes
|
||||
// scrollTop itself.
|
||||
scrollRef.current?.dispatchEvent(new Event("scroll"));
|
||||
}, [enabled, scrollRef, sessionEpoch, viewportReady, virtualizer]);
|
||||
|
||||
const focusedRowIndex = focusedRowKey === undefined ? -1 : rowGeometry.getRowIndex(focusedRowKey);
|
||||
const focusedRowHeight = focusedRowIndex < 0 ? 0 : rowGeometry.getRowHeight(focusedRowIndex);
|
||||
useEffect(() => {
|
||||
if (enabled && viewportReady && focusedRowIndex >= 0) {
|
||||
virtualizer.resizeItem(focusedRowIndex, focusedRowHeight);
|
||||
}
|
||||
}, [enabled, focusedRowHeight, focusedRowIndex, sessionEpoch, viewportReady, virtualizer]);
|
||||
|
||||
const compatibilityRows = useMemo(
|
||||
() => (enabled ? null : rowGeometry.rowKeys.map((rowKey, index) => ({ index, rowKey }))),
|
||||
[enabled, rowGeometry],
|
||||
);
|
||||
if (compatibilityRows) return compatibilityRows;
|
||||
if (!viewportReady) {
|
||||
return extractTimelineVirtualRowSeed(rowGeometry.rowKeys.length, pinnedRowIndexes).map(
|
||||
(index) => ({
|
||||
index,
|
||||
rowKey: rowGeometry.rowKeys[index] ?? index,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return virtualizer.getVirtualItems().map(({ index }) => ({
|
||||
index,
|
||||
rowKey: rowGeometry.rowKeys[index] ?? index,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
/** Whether a derived timeline changes any field that affects rendering. */
|
||||
export function timelineElementsChanged(
|
||||
previous: TimelineElement[],
|
||||
next: TimelineElement[],
|
||||
): boolean {
|
||||
if (next.length !== previous.length) return true;
|
||||
return next.some((element, index) => {
|
||||
const prior = previous[index];
|
||||
return (
|
||||
!prior ||
|
||||
element.id !== prior.id ||
|
||||
element.start !== prior.start ||
|
||||
element.duration !== prior.duration ||
|
||||
element.track !== prior.track ||
|
||||
element.sourceDuration !== prior.sourceDuration
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useTimelinePlayer } from "./useTimelinePlayer";
|
||||
import { liveTime, usePlayerStore } from "../store/playerStore";
|
||||
import { setTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -40,10 +41,30 @@ function renderTimelinePlayerHarness() {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setTimelinePerformanceFixtureLease(false);
|
||||
document.body.innerHTML = "";
|
||||
resetPlayerStore();
|
||||
});
|
||||
|
||||
function attachIframeWindow(
|
||||
api: ReturnType<typeof useTimelinePlayer>,
|
||||
iframeWindow: Record<string, unknown>,
|
||||
): void {
|
||||
const iframe = document.createElement("iframe");
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
value: iframeWindow,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(iframe, "contentDocument", {
|
||||
value: document.implementation.createHTMLDocument("preview"),
|
||||
configurable: true,
|
||||
});
|
||||
act(() => {
|
||||
api.iframeRef.current = iframe;
|
||||
api.onIframeLoad();
|
||||
});
|
||||
}
|
||||
|
||||
function attachIframeAdapter(
|
||||
api: ReturnType<typeof useTimelinePlayer>,
|
||||
options: {
|
||||
@@ -52,7 +73,6 @@ function attachIframeAdapter(
|
||||
duration?: number;
|
||||
} = {},
|
||||
) {
|
||||
const iframe = document.createElement("iframe");
|
||||
let currentTime = 0;
|
||||
let playing = false;
|
||||
const adapter = {
|
||||
@@ -69,24 +89,13 @@ function attachIframeAdapter(
|
||||
getDuration: () => options.duration ?? 30,
|
||||
isPlaying: () => playing,
|
||||
};
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
value: {
|
||||
__player: adapter,
|
||||
__timelines: options.timelines,
|
||||
postMessage: options.postMessage ?? (() => {}),
|
||||
scrollTo: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(iframe, "contentDocument", {
|
||||
value: document.implementation.createHTMLDocument("preview"),
|
||||
configurable: true,
|
||||
});
|
||||
act(() => {
|
||||
api.iframeRef.current = iframe;
|
||||
api.onIframeLoad();
|
||||
attachIframeWindow(api, {
|
||||
__player: adapter,
|
||||
__timelines: options.timelines,
|
||||
postMessage: options.postMessage ?? (() => {}),
|
||||
scrollTo: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
});
|
||||
return adapter;
|
||||
}
|
||||
@@ -129,6 +138,61 @@ function expectStorePlaybackState(
|
||||
}
|
||||
|
||||
describe("useTimelinePlayer seek hydration", () => {
|
||||
it("ignores runtime timeline work while the performance fixture lease is active", () => {
|
||||
const { api, root } = renderTimelinePlayerHarness();
|
||||
attachIframeAdapter(api);
|
||||
const iframeWindow = api.iframeRef.current?.contentWindow;
|
||||
const iframeDocument = api.iframeRef.current?.contentDocument;
|
||||
if (!iframeWindow || !iframeDocument) throw new Error("iframe did not attach");
|
||||
const querySelector = vi.spyOn(iframeDocument, "querySelector");
|
||||
const clips = [
|
||||
{
|
||||
id: "runtime-clip",
|
||||
label: "Runtime clip",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
kind: "element",
|
||||
tagName: "div",
|
||||
compositionId: null,
|
||||
parentCompositionId: null,
|
||||
compositionSrc: null,
|
||||
assetUrl: null,
|
||||
},
|
||||
];
|
||||
const dispatchTimeline = () =>
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: iframeWindow,
|
||||
data: {
|
||||
source: "hf-preview",
|
||||
type: "timeline",
|
||||
clips,
|
||||
durationInFrames: 30,
|
||||
fps: 30,
|
||||
},
|
||||
}),
|
||||
);
|
||||
try {
|
||||
act(() => {
|
||||
usePlayerStore.setState({ clipManifest: null });
|
||||
setTimelinePerformanceFixtureLease(true);
|
||||
dispatchTimeline();
|
||||
});
|
||||
expect(usePlayerStore.getState().clipManifest).toBeNull();
|
||||
expect(querySelector).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
setTimelinePerformanceFixtureLease(false);
|
||||
dispatchTimeline();
|
||||
});
|
||||
expect(usePlayerStore.getState().clipManifest).toEqual(clips);
|
||||
expect(querySelector).toHaveBeenCalled();
|
||||
} finally {
|
||||
unmountWithAct(root);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an external seek request until the iframe adapter is ready", () => {
|
||||
const observedTimes: number[] = [];
|
||||
const unsubscribe = liveTime.subscribe((time) => {
|
||||
@@ -156,26 +220,13 @@ describe("useTimelinePlayer seek hydration", () => {
|
||||
|
||||
it("does not settle from an unsupported runtime protocol message", () => {
|
||||
const { api, root } = renderTimelinePlayerHarness();
|
||||
const iframe = document.createElement("iframe");
|
||||
const iframeWindow = {
|
||||
postMessage: vi.fn(),
|
||||
scrollTo: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as Record<string, unknown>;
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
value: iframeWindow,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(iframe, "contentDocument", {
|
||||
value: document.implementation.createHTMLDocument("preview"),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
api.iframeRef.current = iframe;
|
||||
api.onIframeLoad();
|
||||
});
|
||||
attachIframeWindow(api, iframeWindow);
|
||||
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
||||
|
||||
iframeWindow.__player = {
|
||||
@@ -339,7 +390,6 @@ describe("useTimelinePlayer RAF loop wrap-around", () => {
|
||||
type SeekCall = { time: number; options?: { keepPlaying?: boolean } };
|
||||
|
||||
function attachInstrumentedAdapter(api: ReturnType<typeof useTimelinePlayer>, duration = 30) {
|
||||
const iframe = document.createElement("iframe");
|
||||
let currentTime = 0;
|
||||
let playing = false;
|
||||
const seekCalls: SeekCall[] = [];
|
||||
@@ -361,23 +411,12 @@ describe("useTimelinePlayer RAF loop wrap-around", () => {
|
||||
currentTime = t;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
value: {
|
||||
__player: adapter,
|
||||
postMessage: () => {},
|
||||
scrollTo: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(iframe, "contentDocument", {
|
||||
value: document.implementation.createHTMLDocument("preview"),
|
||||
configurable: true,
|
||||
});
|
||||
act(() => {
|
||||
api.iframeRef.current = iframe;
|
||||
api.onIframeLoad();
|
||||
attachIframeWindow(api, {
|
||||
__player: adapter,
|
||||
postMessage: () => {},
|
||||
scrollTo: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
});
|
||||
return { adapter, seekCalls };
|
||||
}
|
||||
|
||||
@@ -39,30 +39,12 @@ import {
|
||||
import { normalizeToZones } from "../components/timelineZones";
|
||||
import { setPreviewMediaMuted, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
|
||||
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
||||
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
||||
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
|
||||
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
|
||||
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
||||
import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
|
||||
|
||||
/**
|
||||
* Whether the derived elements differ from the current ones in any field that
|
||||
* affects rendering (identity, timing, track, or source length) — used to skip
|
||||
* redundant store writes.
|
||||
*/
|
||||
function timelineElementsChanged(prev: TimelineElement[], next: TimelineElement[]): boolean {
|
||||
if (next.length !== prev.length) return true;
|
||||
return next.some((el, i) => {
|
||||
const p = prev[i];
|
||||
return (
|
||||
!p ||
|
||||
el.id !== p.id ||
|
||||
el.start !== p.start ||
|
||||
el.duration !== p.duration ||
|
||||
el.track !== p.track ||
|
||||
el.sourceDuration !== p.sourceDuration
|
||||
);
|
||||
});
|
||||
}
|
||||
import { timelineElementsChanged } from "./timelinePlayerSync";
|
||||
|
||||
export function useTimelinePlayer() {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
@@ -81,8 +63,13 @@ export function useTimelinePlayer() {
|
||||
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
|
||||
usePlayerStore.getState();
|
||||
|
||||
// The fixture lease belongs at this shared synchronization boundary so every
|
||||
// iframe discovery path has the same owner for deciding whether it may write.
|
||||
const syncTimelineElements = useCallback(
|
||||
// The lease guard adds one deliberate branch at the shared synchronization boundary.
|
||||
// fallow-ignore-next-line complexity
|
||||
(elements: TimelineElement[], nextDuration?: number) => {
|
||||
if (hasTimelinePerformanceFixtureLease()) return;
|
||||
const state = usePlayerStore.getState();
|
||||
const resolvedDuration = nextDuration ?? state.duration;
|
||||
// applyCachedSourceDurations re-applies the cached probe duration: re-derived
|
||||
@@ -480,6 +467,7 @@ export function useTimelinePlayer() {
|
||||
// Pre-existing message-router complexity — surfaced by line shifts, not new logic.
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (hasTimelinePerformanceFixtureLease()) return;
|
||||
const data = e.data;
|
||||
const ourIframe = iframeRef.current;
|
||||
if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { isMusicTrack } from "../../utils/timelineInspector";
|
||||
import { scrubPreviewAudio, stopScrubPreviewAudio } from "./timelineIframeHelpers";
|
||||
import { getTimelineElementIndexes } from "./timelineElementIndexes";
|
||||
|
||||
export { stopScrubPreviewAudio };
|
||||
|
||||
@@ -8,7 +8,7 @@ export { stopScrubPreviewAudio };
|
||||
// Skipped when audio is muted or the time falls outside the music clip.
|
||||
export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: number): void {
|
||||
const s = usePlayerStore.getState();
|
||||
const music = s.elements.find(isMusicTrack);
|
||||
const music = getTimelineElementIndexes(s.elements).musicElement;
|
||||
if (!music || s.audioMuted) return;
|
||||
const rel = nextTime - music.start;
|
||||
const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { getTimelineElementIdentity } from "./timelineElementHelpers";
|
||||
import { createTimelineClipIndex, queryTimelineClipIndex } from "./timelineClipIndex";
|
||||
|
||||
function clip(
|
||||
id: string,
|
||||
start: number,
|
||||
duration: number,
|
||||
track = 1,
|
||||
hidden = false,
|
||||
): TimelineElement {
|
||||
return { id, tag: "div", start, duration, track, hidden };
|
||||
}
|
||||
|
||||
function ids(elements: readonly TimelineElement[]): string[] {
|
||||
return elements.map(getTimelineElementIdentity);
|
||||
}
|
||||
|
||||
function createDeterministicRandom(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0;
|
||||
return state / 0x1_0000_0000;
|
||||
};
|
||||
}
|
||||
|
||||
function linearOracle(
|
||||
elements: readonly TimelineElement[],
|
||||
range: { start: number; end: number },
|
||||
pinned: ReadonlySet<string>,
|
||||
): readonly TimelineElement[] {
|
||||
return elements.filter((element) => {
|
||||
if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return false;
|
||||
if (pinned.has(getTimelineElementIdentity(element))) return true;
|
||||
if (range.end <= range.start) return false;
|
||||
const end = element.start + Math.max(0, element.duration);
|
||||
if (end <= element.start) return element.start >= range.start && element.start < range.end;
|
||||
return element.start < range.end && end > range.start;
|
||||
});
|
||||
}
|
||||
|
||||
describe("timelineClipIndex", () => {
|
||||
it("finds a long predecessor spanning the window", () => {
|
||||
const index = createTimelineClipIndex([[1, [clip("long", 0, 100), clip("late", 80, 1)]]]);
|
||||
expect(ids(queryTimelineClipIndex(index, 1, { start: 50, end: 51 }))).toEqual(["long"]);
|
||||
});
|
||||
|
||||
it("prunes 50k ended clips around one long interval in a late window", () => {
|
||||
const elements = [
|
||||
clip("long", 0, 50_000),
|
||||
...Array.from({ length: 49_999 }, (_, index) => clip(`short-${index}`, index, 0.25)),
|
||||
];
|
||||
const index = createTimelineClipIndex([[1, elements]]);
|
||||
expect(ids(queryTimelineClipIndex(index, 1, { start: 49_999, end: 49_999.5 }))).toEqual([
|
||||
"long",
|
||||
]);
|
||||
});
|
||||
|
||||
it("queries a shifted multi-drag window on a 50k-clip row without a row scan", () => {
|
||||
const elements = Array.from({ length: 50_000 }, (_, index) =>
|
||||
clip(`clip-${index}`, index, 0.25),
|
||||
);
|
||||
const index = createTimelineClipIndex([[1, elements]]);
|
||||
const selected = new Set(["clip-10", "clip-49"]);
|
||||
|
||||
expect(
|
||||
ids(
|
||||
queryTimelineClipIndex(index, 1, { start: 60_000, end: 60_001 }, new Set(), [
|
||||
{ range: { start: 10, end: 11 }, identities: selected },
|
||||
]),
|
||||
),
|
||||
).toEqual(["clip-10"]);
|
||||
});
|
||||
|
||||
it("uses half-open render boundaries and retains zero-duration points", () => {
|
||||
const index = createTimelineClipIndex([
|
||||
[
|
||||
1,
|
||||
[
|
||||
clip("left", 0, 2),
|
||||
clip("right", 2, 2),
|
||||
clip("point", 3, 0),
|
||||
clip("negative", 3.5, -2),
|
||||
clip("micro", 4, 1e-9),
|
||||
],
|
||||
],
|
||||
]);
|
||||
expect(ids(queryTimelineClipIndex(index, 1, { start: 2, end: 4 }))).toEqual([
|
||||
"right",
|
||||
"point",
|
||||
"negative",
|
||||
]);
|
||||
expect(ids(queryTimelineClipIndex(index, 1, { start: 4, end: 5 }))).toEqual(["micro"]);
|
||||
});
|
||||
|
||||
it("preserves projection order after overlap and pin union", () => {
|
||||
const index = createTimelineClipIndex([
|
||||
[1, [clip("pinned", 10, 1), clip("visible-b", 2, 1), clip("visible-a", 1, 3)]],
|
||||
]);
|
||||
expect(
|
||||
ids(queryTimelineClipIndex(index, 1, { start: 1.5, end: 2.5 }, new Set(["pinned"]))),
|
||||
).toEqual(["pinned", "visible-b", "visible-a"]);
|
||||
});
|
||||
|
||||
it("keeps duplicate identities deterministic and ignores stale pins", () => {
|
||||
const index = createTimelineClipIndex([
|
||||
[1, [clip("duplicate", 10, 1), clip("duplicate", 20, 1), clip("hidden", 30, 1, 1, true)]],
|
||||
[2, [clip("duplicate", 40, 1, 2)]],
|
||||
]);
|
||||
expect(
|
||||
ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }, new Set(["duplicate", "stale"]))),
|
||||
).toEqual(["duplicate", "duplicate"]);
|
||||
expect(
|
||||
ids(queryTimelineClipIndex(index, 2, { start: 0, end: 1 }, new Set(["duplicate"]))),
|
||||
).toEqual(["duplicate"]);
|
||||
});
|
||||
|
||||
it("keeps an immutable interval snapshot when the source projection array changes", () => {
|
||||
const source = [clip("first", 0, 2), clip("second", 5, 2)];
|
||||
const index = createTimelineClipIndex([[1, source]]);
|
||||
source.splice(0, source.length, clip("replacement", 0, 20));
|
||||
expect(ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }))).toEqual(["first"]);
|
||||
});
|
||||
|
||||
it("excludes clips with non-finite timing", () => {
|
||||
const index = createTimelineClipIndex([
|
||||
[1, [clip("bad-start", Number.NaN, 1), clip("bad-duration", 0, Number.POSITIVE_INFINITY)]],
|
||||
]);
|
||||
expect(ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("indexes synthetic fractional display rows independently", () => {
|
||||
const index = createTimelineClipIndex([
|
||||
[1, [clip("host", 0, 1)]],
|
||||
[1.5, [clip("child", 10, 1, 1.5)]],
|
||||
]);
|
||||
expect(ids(queryTimelineClipIndex(index, 1.5, { start: 10, end: 11 }))).toEqual(["child"]);
|
||||
expect(ids(queryTimelineClipIndex(index, 1, { start: 10, end: 11 }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches a linear oracle across deterministic randomized windows and pins", () => {
|
||||
const random = createDeterministicRandom(0x2698);
|
||||
for (let scenario = 0; scenario < 50; scenario += 1) {
|
||||
const elements = Array.from({ length: 60 }, (_, index) => {
|
||||
const element = clip(
|
||||
`clip-${scenario}-${index}`,
|
||||
Math.floor(random() * 120) - 10,
|
||||
random() < 0.15 ? -Math.floor(random() * 5) : Math.floor(random() * 30),
|
||||
);
|
||||
if (index % 4 === 0) element.key = `index.html#${element.id}`;
|
||||
return element;
|
||||
});
|
||||
const index = createTimelineClipIndex([[1, elements]]);
|
||||
for (let query = 0; query < 12; query += 1) {
|
||||
const start = Math.floor(random() * 120) - 10;
|
||||
const range = { start, end: start + Math.floor(random() * 35) };
|
||||
const pinned = new Set(
|
||||
elements
|
||||
.filter(() => random() < 0.05)
|
||||
.map((element) => getTimelineElementIdentity(element)),
|
||||
);
|
||||
expect(queryTimelineClipIndex(index, 1, range, pinned)).toEqual(
|
||||
linearOracle(elements, range, pinned),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { getTimelineElementIdentity } from "./timelineElementHelpers";
|
||||
|
||||
export interface TimelineTimeRange {
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
}
|
||||
|
||||
export interface TimelineClipQueryWindow {
|
||||
readonly range: TimelineTimeRange;
|
||||
readonly identities: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface TimelineClipInterval {
|
||||
readonly element: TimelineElement;
|
||||
readonly identity: string;
|
||||
readonly ordinal: number;
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
}
|
||||
|
||||
interface TimelineClipRowIndex {
|
||||
readonly byStart: readonly TimelineClipInterval[];
|
||||
readonly treeLeafCount: number;
|
||||
readonly maxPositiveEndTree: readonly number[];
|
||||
readonly maxPointStartTree: readonly number[];
|
||||
readonly byIdentity: ReadonlyMap<string, readonly TimelineClipInterval[]>;
|
||||
}
|
||||
|
||||
export interface TimelineClipIndex {
|
||||
readonly rows: ReadonlyMap<number, TimelineClipRowIndex>;
|
||||
}
|
||||
|
||||
function clipInterval(element: TimelineElement, ordinal: number): TimelineClipInterval | null {
|
||||
if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return null;
|
||||
const duration = Math.max(0, element.duration);
|
||||
return Object.freeze({
|
||||
element,
|
||||
identity: getTimelineElementIdentity(element),
|
||||
ordinal,
|
||||
start: element.start,
|
||||
end: element.start + duration,
|
||||
});
|
||||
}
|
||||
|
||||
function timelineClipTreeLeafCount(intervalCount: number): number {
|
||||
let leafCount = 1;
|
||||
while (leafCount < intervalCount) leafCount *= 2;
|
||||
return leafCount;
|
||||
}
|
||||
|
||||
function createTimelineClipMaxTrees(intervals: readonly TimelineClipInterval[]) {
|
||||
const treeLeafCount = timelineClipTreeLeafCount(intervals.length);
|
||||
const maxPositiveEndTree = Array<number>(treeLeafCount * 2).fill(Number.NEGATIVE_INFINITY);
|
||||
const maxPointStartTree = Array<number>(treeLeafCount * 2).fill(Number.NEGATIVE_INFINITY);
|
||||
for (let index = 0; index < intervals.length; index += 1) {
|
||||
const interval = intervals[index];
|
||||
if (!interval) continue;
|
||||
const leaf = treeLeafCount + index;
|
||||
if (interval.end > interval.start) maxPositiveEndTree[leaf] = interval.end;
|
||||
else maxPointStartTree[leaf] = interval.start;
|
||||
}
|
||||
for (let node = treeLeafCount - 1; node > 0; node -= 1) {
|
||||
maxPositiveEndTree[node] = Math.max(
|
||||
maxPositiveEndTree[node * 2] ?? Number.NEGATIVE_INFINITY,
|
||||
maxPositiveEndTree[node * 2 + 1] ?? Number.NEGATIVE_INFINITY,
|
||||
);
|
||||
maxPointStartTree[node] = Math.max(
|
||||
maxPointStartTree[node * 2] ?? Number.NEGATIVE_INFINITY,
|
||||
maxPointStartTree[node * 2 + 1] ?? Number.NEGATIVE_INFINITY,
|
||||
);
|
||||
}
|
||||
return {
|
||||
treeLeafCount,
|
||||
maxPositiveEndTree: Object.freeze(maxPositiveEndTree),
|
||||
maxPointStartTree: Object.freeze(maxPointStartTree),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an immutable snapshot for one exact display-track projection.
|
||||
* Rebuild only when that projection's array identity changes. Construction is
|
||||
* O(n log n) overall because each row is sorted once.
|
||||
*/
|
||||
export function createTimelineClipIndex(
|
||||
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
|
||||
): TimelineClipIndex {
|
||||
const rows = new Map<number, TimelineClipRowIndex>();
|
||||
for (const [rowKey, elements] of tracks) {
|
||||
const intervals = elements
|
||||
.map(clipInterval)
|
||||
.filter((interval): interval is TimelineClipInterval => interval !== null);
|
||||
const byStart = Object.freeze(
|
||||
[...intervals].sort(
|
||||
(left, right) => left.start - right.start || left.ordinal - right.ordinal,
|
||||
),
|
||||
);
|
||||
const maxTrees = createTimelineClipMaxTrees(byStart);
|
||||
const mutableByIdentity = new Map<string, TimelineClipInterval[]>();
|
||||
for (const interval of intervals) {
|
||||
const matches = mutableByIdentity.get(interval.identity) ?? [];
|
||||
matches.push(interval);
|
||||
mutableByIdentity.set(interval.identity, matches);
|
||||
}
|
||||
const byIdentity = new Map(
|
||||
[...mutableByIdentity].map(([identity, matches]) => [identity, Object.freeze(matches)]),
|
||||
);
|
||||
rows.set(rowKey, Object.freeze({ byStart, ...maxTrees, byIdentity }));
|
||||
}
|
||||
return Object.freeze({ rows });
|
||||
}
|
||||
|
||||
function upperBoundStart(intervals: readonly TimelineClipInterval[], end: number): number {
|
||||
let low = 0;
|
||||
let high = intervals.length;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if ((intervals[mid]?.start ?? Number.POSITIVE_INFINITY) < end) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
function overlaps(interval: TimelineClipInterval, range: TimelineTimeRange): boolean {
|
||||
if (interval.end <= interval.start)
|
||||
return interval.start >= range.start && interval.start < range.end;
|
||||
return interval.start < range.end && interval.end > range.start;
|
||||
}
|
||||
|
||||
function collectTimelineClipOverlaps(
|
||||
row: TimelineClipRowIndex,
|
||||
candidateCount: number,
|
||||
range: TimelineTimeRange,
|
||||
selected: Set<TimelineClipInterval>,
|
||||
identities?: ReadonlySet<string>,
|
||||
): void {
|
||||
const visit = (node: number, left: number, right: number) => {
|
||||
if (
|
||||
left >= candidateCount ||
|
||||
((row.maxPositiveEndTree[node] ?? Number.NEGATIVE_INFINITY) <= range.start &&
|
||||
(row.maxPointStartTree[node] ?? Number.NEGATIVE_INFINITY) < range.start)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (right - left === 1) {
|
||||
const interval = row.byStart[left];
|
||||
if (interval) selectTimelineClipOverlap(interval, range, selected, identities);
|
||||
return;
|
||||
}
|
||||
const middle = Math.floor((left + right) / 2);
|
||||
visit(node * 2, left, middle);
|
||||
visit(node * 2 + 1, middle, right);
|
||||
};
|
||||
visit(1, 0, row.treeLeafCount);
|
||||
}
|
||||
|
||||
function selectTimelineClipOverlap(
|
||||
interval: TimelineClipInterval,
|
||||
range: TimelineTimeRange,
|
||||
selected: Set<TimelineClipInterval>,
|
||||
identities: ReadonlySet<string> | undefined,
|
||||
): void {
|
||||
if (!overlaps(interval, range)) return;
|
||||
if (identities !== undefined && !identities.has(interval.identity)) return;
|
||||
selected.add(interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query one display row. The overlap set and explicit actor pins are returned
|
||||
* in the row's original projection order, so windowing never changes z/DOM order.
|
||||
* The start lookup is O(log n). Balanced max trees prune both children whose
|
||||
* positive intervals and point clips cannot reach the window. The overlap walk
|
||||
* is O((k + 1) log n) for k candidates across the primary and actor windows;
|
||||
* pin lookup is O(p), followed by the projection-order sort of the unique
|
||||
* result set.
|
||||
*/
|
||||
export function queryTimelineClipIndex(
|
||||
index: TimelineClipIndex,
|
||||
rowKey: number,
|
||||
range: TimelineTimeRange,
|
||||
pinnedIdentities: ReadonlySet<string> = new Set(),
|
||||
actorWindows: readonly TimelineClipQueryWindow[] = [],
|
||||
): readonly TimelineElement[] {
|
||||
const row = index.rows.get(rowKey);
|
||||
if (!row) return Object.freeze([]);
|
||||
const selected = new Set<TimelineClipInterval>();
|
||||
if (range.end > range.start) {
|
||||
collectTimelineClipOverlaps(row, upperBoundStart(row.byStart, range.end), range, selected);
|
||||
}
|
||||
for (const actorWindow of actorWindows) {
|
||||
if (actorWindow.range.end <= actorWindow.range.start) continue;
|
||||
collectTimelineClipOverlaps(
|
||||
row,
|
||||
upperBoundStart(row.byStart, actorWindow.range.end),
|
||||
actorWindow.range,
|
||||
selected,
|
||||
actorWindow.identities,
|
||||
);
|
||||
}
|
||||
for (const identity of pinnedIdentities) {
|
||||
for (const interval of row.byIdentity.get(identity) ?? []) selected.add(interval);
|
||||
}
|
||||
return Object.freeze(
|
||||
[...selected]
|
||||
.sort((left, right) => left.ordinal - right.ordinal)
|
||||
.map((interval) => interval.element),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { getTimelineElementIndexes } from "./timelineElementIndexes";
|
||||
|
||||
describe("getTimelineElementIndexes", () => {
|
||||
const elements: TimelineElement[] = [
|
||||
{ id: "hero", tag: "img", src: "hero.png", start: 0, duration: 2, track: 0 },
|
||||
{
|
||||
id: "bgm",
|
||||
tag: "audio",
|
||||
src: "music.wav",
|
||||
start: 0,
|
||||
duration: 10,
|
||||
track: 3,
|
||||
timelineRole: "music",
|
||||
},
|
||||
];
|
||||
|
||||
it("indexes media and music identities", () => {
|
||||
const indexes = getTimelineElementIndexes(elements);
|
||||
expect(indexes.byKey.get("hero")).toBe(elements[0]);
|
||||
expect(indexes.musicElement).toBe(elements[1]);
|
||||
expect(indexes.mediaElements).toEqual(elements);
|
||||
expect(indexes.audioTracks).toEqual(new Set([3]));
|
||||
});
|
||||
|
||||
it("reuses one index for the same immutable array snapshot", () => {
|
||||
expect(getTimelineElementIndexes(elements)).toBe(getTimelineElementIndexes(elements));
|
||||
expect(getTimelineElementIndexes([...elements])).not.toBe(getTimelineElementIndexes(elements));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { getTimelineElementIdentity } from "./timelineElementHelpers";
|
||||
|
||||
export interface TimelineElementIndexes {
|
||||
readonly byKey: ReadonlyMap<string, TimelineElement>;
|
||||
readonly musicElement: TimelineElement | null;
|
||||
readonly mediaElements: readonly TimelineElement[];
|
||||
readonly audioTracks: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
const indexCache = new WeakMap<readonly TimelineElement[], TimelineElementIndexes>();
|
||||
|
||||
/**
|
||||
* Index a store element snapshot once. Playback-only Zustand updates keep the
|
||||
* same array identity, so selectors can reuse this object without rescanning a
|
||||
* large timeline or triggering a component render.
|
||||
*/
|
||||
export function getTimelineElementIndexes(
|
||||
elements: readonly TimelineElement[],
|
||||
): TimelineElementIndexes {
|
||||
const cached = indexCache.get(elements);
|
||||
if (cached) return cached;
|
||||
|
||||
const byKey = new Map<string, TimelineElement>();
|
||||
const mediaElements: TimelineElement[] = [];
|
||||
const audioTracks = new Set<number>();
|
||||
let musicElement: TimelineElement | null = null;
|
||||
for (const element of elements) {
|
||||
byKey.set(getTimelineElementIdentity(element), element);
|
||||
if (element.src) mediaElements.push(element);
|
||||
if (isAudioTimelineElement(element)) audioTracks.add(element.track);
|
||||
if (!musicElement && isMusicTrack(element)) musicElement = element;
|
||||
}
|
||||
|
||||
const indexes = Object.freeze({
|
||||
byKey,
|
||||
musicElement,
|
||||
mediaElements: Object.freeze(mediaElements),
|
||||
audioTracks,
|
||||
});
|
||||
indexCache.set(elements, indexes);
|
||||
return indexes;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getTimelineResourceBudgetStatus,
|
||||
readTimelinePerformanceDiagnostics,
|
||||
resolveTimelineScrollStrategy,
|
||||
} from "./timelinePerformanceDiagnostics";
|
||||
import { resolveTimelineViewportBudgets } from "./timelineViewportBudgets";
|
||||
|
||||
describe("timeline performance diagnostics", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("reads mounted resources without mutating the timeline", () => {
|
||||
document.body.innerHTML = `
|
||||
<div aria-label="Timeline" data-timeline-scheduler-queued="3"
|
||||
data-timeline-scheduler-active="2" data-timeline-cache-bytes="4096">
|
||||
<div data-timeline-row><div data-clip="true"></div><div data-clip="true"></div></div>
|
||||
<div data-timeline-row><div data-clip="true"></div></div>
|
||||
<div data-clip="true"></div>
|
||||
<div data-timeline-grid-cell></div><div data-timeline-grid-cell></div>
|
||||
<div data-timeline-poster-state="ready"></div>
|
||||
<div data-timeline-poster-state="error"></div>
|
||||
<div data-timeline-poster-state="constructor"></div>
|
||||
</div>`;
|
||||
const before = document.body.innerHTML;
|
||||
|
||||
expect(readTimelinePerformanceDiagnostics()).toMatchObject({
|
||||
timelineRoots: 1,
|
||||
mountedRows: 2,
|
||||
mountedClipRoots: 4,
|
||||
maxMountedClipRootsInOneRow: 2,
|
||||
mountedTimeGridCells: 2,
|
||||
schedulerQueued: 3,
|
||||
schedulerActive: 2,
|
||||
cacheBytes: 4096,
|
||||
posterStates: { idle: 0, loading: 0, ready: 1, fallback: 0, error: 1 },
|
||||
});
|
||||
expect(document.body.innerHTML).toBe(before);
|
||||
});
|
||||
|
||||
it("returns the zero baseline after unmount or reset removes the DOM", () => {
|
||||
document.body.innerHTML = '<div aria-label="Timeline"><div data-clip="true"></div></div>';
|
||||
expect(readTimelinePerformanceDiagnostics().mountedClipRoots).toBe(1);
|
||||
|
||||
document.body.replaceChildren();
|
||||
|
||||
expect(readTimelinePerformanceDiagnostics()).toEqual({
|
||||
timelineRoots: 0,
|
||||
mountedRows: 0,
|
||||
mountedClipRoots: 0,
|
||||
maxMountedClipRootsInOneRow: 0,
|
||||
mountedTimeGridCells: 0,
|
||||
mountedTimelineDescendants: 0,
|
||||
schedulerQueued: 0,
|
||||
schedulerActive: 0,
|
||||
cacheBytes: 0,
|
||||
posterStates: { idle: 0, loading: 0, ready: 0, fallback: 0, error: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats every DOM ceiling as inclusive", () => {
|
||||
const budgets = resolveTimelineViewportBudgets({
|
||||
maxMountedClipRoots: 2,
|
||||
maxMountedClipRootsPerRow: 1,
|
||||
maxMountedRows: 2,
|
||||
maxMountedTimelineDescendants: 4,
|
||||
});
|
||||
expect(
|
||||
getTimelineResourceBudgetStatus(
|
||||
{
|
||||
...readTimelinePerformanceDiagnostics(),
|
||||
mountedClipRoots: 2,
|
||||
maxMountedClipRootsInOneRow: 2,
|
||||
mountedTimelineDescendants: 4,
|
||||
},
|
||||
budgets,
|
||||
),
|
||||
).toEqual({
|
||||
timelineRoot: false,
|
||||
rows: true,
|
||||
clipRoots: true,
|
||||
clipRootsPerRow: false,
|
||||
descendants: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails the resource status when the timeline is absent", () => {
|
||||
expect(getTimelineResourceBudgetStatus(readTimelinePerformanceDiagnostics())).toMatchObject({
|
||||
timelineRoot: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("selects direct scrolling only through the configured safety envelope", () => {
|
||||
expect(resolveTimelineScrollStrategy(8_000_000)).toBe("direct");
|
||||
expect(resolveTimelineScrollStrategy(8_000_001)).toBe("segmented");
|
||||
expect(() => resolveTimelineScrollStrategy(Number.NaN)).toThrow("content width");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { TIMELINE_VIEWPORT_BUDGETS, type TimelineViewportBudgets } from "./timelineViewportBudgets";
|
||||
|
||||
export type TimelinePosterState = "idle" | "loading" | "ready" | "fallback" | "error";
|
||||
|
||||
export interface TimelinePerformanceDiagnostics {
|
||||
timelineRoots: number;
|
||||
mountedRows: number;
|
||||
mountedClipRoots: number;
|
||||
maxMountedClipRootsInOneRow: number;
|
||||
mountedTimeGridCells: number;
|
||||
mountedTimelineDescendants: number;
|
||||
schedulerQueued: number;
|
||||
schedulerActive: number;
|
||||
cacheBytes: number;
|
||||
posterStates: Readonly<Record<TimelinePosterState, number>>;
|
||||
}
|
||||
|
||||
export interface TimelineResourceBudgetStatus {
|
||||
timelineRoot: boolean;
|
||||
rows: boolean;
|
||||
clipRoots: boolean;
|
||||
clipRootsPerRow: boolean;
|
||||
descendants: boolean;
|
||||
}
|
||||
|
||||
function readNonNegativeNumber(value: string | undefined): number {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0 ? number : 0;
|
||||
}
|
||||
|
||||
function countPosters(root: ParentNode): Readonly<Record<TimelinePosterState, number>> {
|
||||
const counts: Record<TimelinePosterState, number> = {
|
||||
idle: 0,
|
||||
loading: 0,
|
||||
ready: 0,
|
||||
fallback: 0,
|
||||
error: 0,
|
||||
};
|
||||
for (const node of root.querySelectorAll<HTMLElement>("[data-timeline-poster-state]")) {
|
||||
const state = node.dataset.timelinePosterState;
|
||||
if (state && Object.hasOwn(counts, state)) counts[state as TimelinePosterState] += 1;
|
||||
}
|
||||
return Object.freeze(counts);
|
||||
}
|
||||
|
||||
function maxClipsInOneRow(root: ParentNode): number {
|
||||
const byRow = new Map<Element, number>();
|
||||
for (const clip of root.querySelectorAll<HTMLElement>('[data-clip="true"]')) {
|
||||
const row = clip.closest("[data-timeline-row]");
|
||||
if (!row) continue;
|
||||
byRow.set(row, (byRow.get(row) ?? 0) + 1);
|
||||
}
|
||||
return Math.max(0, ...byRow.values());
|
||||
}
|
||||
|
||||
function sumDataAttribute(root: ParentNode, selector: string, dataKey: string): number {
|
||||
let total = 0;
|
||||
for (const node of root.querySelectorAll<HTMLElement>(selector)) {
|
||||
total += readNonNegativeNumber(node.dataset[dataKey]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current timeline costs directly from the mounted DOM. No counters are
|
||||
* retained, so an unmount or project reset is reflected as a zero baseline on
|
||||
* the next read rather than depending on cleanup ordering.
|
||||
*/
|
||||
export function readTimelinePerformanceDiagnostics(
|
||||
root: ParentNode = document,
|
||||
): Readonly<TimelinePerformanceDiagnostics> {
|
||||
const timelineRoots = root.querySelectorAll<HTMLElement>('[aria-label="Timeline"]');
|
||||
let mountedTimelineDescendants = 0;
|
||||
for (const timelineRoot of timelineRoots) {
|
||||
mountedTimelineDescendants += timelineRoot.querySelectorAll("*").length;
|
||||
}
|
||||
return Object.freeze({
|
||||
timelineRoots: timelineRoots.length,
|
||||
mountedRows: root.querySelectorAll("[data-timeline-row]").length,
|
||||
mountedClipRoots: root.querySelectorAll('[data-clip="true"]').length,
|
||||
maxMountedClipRootsInOneRow: maxClipsInOneRow(root),
|
||||
mountedTimeGridCells: root.querySelectorAll("[data-timeline-grid-cell]").length,
|
||||
mountedTimelineDescendants,
|
||||
schedulerQueued: sumDataAttribute(
|
||||
root,
|
||||
"[data-timeline-scheduler-queued]",
|
||||
"timelineSchedulerQueued",
|
||||
),
|
||||
schedulerActive: sumDataAttribute(
|
||||
root,
|
||||
"[data-timeline-scheduler-active]",
|
||||
"timelineSchedulerActive",
|
||||
),
|
||||
cacheBytes: sumDataAttribute(root, "[data-timeline-cache-bytes]", "timelineCacheBytes"),
|
||||
posterStates: countPosters(root),
|
||||
});
|
||||
}
|
||||
|
||||
export function getTimelineResourceBudgetStatus(
|
||||
diagnostics: TimelinePerformanceDiagnostics,
|
||||
budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
|
||||
): Readonly<TimelineResourceBudgetStatus> {
|
||||
return Object.freeze({
|
||||
timelineRoot: diagnostics.timelineRoots === 1,
|
||||
rows: diagnostics.mountedRows <= budgets.maxMountedRows,
|
||||
clipRoots: diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots,
|
||||
clipRootsPerRow: diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow,
|
||||
descendants: diagnostics.mountedTimelineDescendants <= budgets.maxMountedTimelineDescendants,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTimelineScrollStrategy(
|
||||
contentWidthPx: number,
|
||||
budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
|
||||
): "direct" | "segmented" {
|
||||
if (!Number.isFinite(contentWidthPx) || contentWidthPx < 0) {
|
||||
throw new RangeError("Timeline content width must be a finite non-negative number");
|
||||
}
|
||||
return contentWidthPx <= budgets.directScrollSafetyPx ? "direct" : "segmented";
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
|
||||
|
||||
export type TimelinePerformanceFixtureProfile =
|
||||
| "dense-short"
|
||||
| "long-overlap"
|
||||
| "keyframe-heavy-expanded"
|
||||
| "composition-heavy"
|
||||
| "remote-unsupported";
|
||||
|
||||
export interface TimelinePerformanceFixtureSpec {
|
||||
elementCount: 1_000 | 50_000;
|
||||
profile: TimelinePerformanceFixtureProfile;
|
||||
}
|
||||
|
||||
export interface TimelinePerformanceFixtureSummary extends TimelinePerformanceFixtureSpec {
|
||||
duration: number;
|
||||
trackCount: number;
|
||||
keyframedElementCount: number;
|
||||
expandedElementCount: number;
|
||||
}
|
||||
|
||||
export interface TimelinePerformanceFixture {
|
||||
summary: Readonly<TimelinePerformanceFixtureSummary>;
|
||||
elements: TimelineElement[];
|
||||
keyframeCache: Map<string, KeyframeCacheEntry>;
|
||||
gsapAnimations: Map<string, GsapAnimation[]>;
|
||||
expandedClipIds: Set<string>;
|
||||
}
|
||||
|
||||
const TRACK_COUNT = 1_000;
|
||||
let fixtureLeaseActive = false;
|
||||
const PROFILE_GEOMETRY: Readonly<
|
||||
Record<TimelinePerformanceFixtureProfile, { duration: number; clipDuration: number }>
|
||||
> = Object.freeze({
|
||||
"dense-short": { duration: 120, clipDuration: 1.5 },
|
||||
"long-overlap": { duration: 7_200, clipDuration: 120 },
|
||||
"keyframe-heavy-expanded": { duration: 600, clipDuration: 8 },
|
||||
"composition-heavy": { duration: 900, clipDuration: 12 },
|
||||
"remote-unsupported": { duration: 900, clipDuration: 12 },
|
||||
});
|
||||
|
||||
/** Prevent live iframe discovery from replacing an explicitly loaded dev fixture. */
|
||||
export function setTimelinePerformanceFixtureLease(active: boolean): void {
|
||||
fixtureLeaseActive = active;
|
||||
}
|
||||
|
||||
export function hasTimelinePerformanceFixtureLease(): boolean {
|
||||
return fixtureLeaseActive;
|
||||
}
|
||||
|
||||
function validateFixtureSpec(spec: TimelinePerformanceFixtureSpec) {
|
||||
if (spec.elementCount !== 1_000 && spec.elementCount !== 50_000) {
|
||||
throw new RangeError("Timeline performance fixture elementCount must be 1000 or 50000");
|
||||
}
|
||||
if (!Object.hasOwn(PROFILE_GEOMETRY, spec.profile)) {
|
||||
throw new RangeError(`Unknown timeline performance fixture profile: ${spec.profile}`);
|
||||
}
|
||||
const geometry = PROFILE_GEOMETRY[spec.profile];
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function fixtureTrack(index: number, spec: TimelinePerformanceFixtureSpec): number {
|
||||
if (index < TRACK_COUNT) return index;
|
||||
if (spec.profile !== "dense-short") return index % TRACK_COUNT;
|
||||
// Keep the dense profile inside the declared 128-roots-per-row envelope while
|
||||
// still representing every one of the 1,000 logical tracks.
|
||||
const denseTrackCount = Math.ceil((spec.elementCount - TRACK_COUNT) / 127);
|
||||
return (index - TRACK_COUNT) % Math.max(1, denseTrackCount);
|
||||
}
|
||||
|
||||
function fixtureStart(
|
||||
index: number,
|
||||
profile: TimelinePerformanceFixtureProfile,
|
||||
duration: number,
|
||||
clipDuration: number,
|
||||
): number {
|
||||
const available = Math.max(0, duration - clipDuration);
|
||||
if (profile === "dense-short") return (index % 128) * 0.5;
|
||||
if (profile === "long-overlap") return (index * 37) % Math.max(1, available);
|
||||
return (index * 17) % Math.max(1, available);
|
||||
}
|
||||
|
||||
function keyframeData(): KeyframeCacheEntry {
|
||||
return {
|
||||
format: "percentage",
|
||||
keyframes: [0, 33, 66, 100].map((percentage) => ({
|
||||
percentage,
|
||||
propertyGroup: "position",
|
||||
properties: { x: percentage },
|
||||
ease: "power2.inOut",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function fixtureAnimation(id: string, start: number, duration: number): GsapAnimation {
|
||||
return {
|
||||
id: `animation-${id}`,
|
||||
targetSelector: `#${id}`,
|
||||
method: "to",
|
||||
position: start,
|
||||
resolvedStart: start,
|
||||
duration,
|
||||
propertyGroup: "position",
|
||||
fromProperties: { x: 0 },
|
||||
properties: { x: 100 },
|
||||
ease: "power2.inOut",
|
||||
};
|
||||
}
|
||||
|
||||
/** Pure deterministic generator; the dev test hook performs the one store mutation. */
|
||||
export function createTimelinePerformanceFixture(
|
||||
spec: TimelinePerformanceFixtureSpec,
|
||||
): TimelinePerformanceFixture {
|
||||
const geometry = validateFixtureSpec(spec);
|
||||
const elements: TimelineElement[] = [];
|
||||
const keyframeCache = new Map<string, KeyframeCacheEntry>();
|
||||
const gsapAnimations = new Map<string, GsapAnimation[]>();
|
||||
const expandedClipIds = new Set<string>();
|
||||
|
||||
for (let index = 0; index < spec.elementCount; index += 1) {
|
||||
const id = `perf-${spec.profile}-${spec.elementCount}-${index}`;
|
||||
const start = fixtureStart(index, spec.profile, geometry.duration, geometry.clipDuration);
|
||||
const track = fixtureTrack(index, spec);
|
||||
const element: TimelineElement = {
|
||||
id,
|
||||
key: id,
|
||||
domId: id,
|
||||
selector: `#${id}`,
|
||||
label: `Fixture ${index + 1}`,
|
||||
tag: spec.profile === "remote-unsupported" && index % 2 === 0 ? "video" : "div",
|
||||
start,
|
||||
duration: geometry.clipDuration,
|
||||
track,
|
||||
authoredTrack: track,
|
||||
};
|
||||
|
||||
if (spec.profile === "composition-heavy") {
|
||||
element.compositionSrc = `compositions/perf-${index % 32}.html`;
|
||||
} else if (spec.profile === "remote-unsupported") {
|
||||
element.src =
|
||||
index % 2 === 0
|
||||
? `https://media.invalid/perf-${index % 32}.mp4`
|
||||
: `assets/perf-${index % 32}.unsupported`;
|
||||
}
|
||||
if (spec.profile === "keyframe-heavy-expanded") {
|
||||
keyframeCache.set(id, keyframeData());
|
||||
gsapAnimations.set(id, [fixtureAnimation(id, start, geometry.clipDuration)]);
|
||||
expandedClipIds.add(id);
|
||||
}
|
||||
elements.push(element);
|
||||
}
|
||||
|
||||
return {
|
||||
summary: Object.freeze({
|
||||
...spec,
|
||||
duration: geometry.duration,
|
||||
trackCount: TRACK_COUNT,
|
||||
keyframedElementCount: keyframeCache.size,
|
||||
expandedElementCount: expandedClipIds.size,
|
||||
}),
|
||||
elements,
|
||||
keyframeCache,
|
||||
gsapAnimations,
|
||||
expandedClipIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
TIMELINE_VIEWPORT_BUDGETS,
|
||||
resolveTimelineViewportBudgets,
|
||||
} from "./timelineViewportBudgets";
|
||||
|
||||
describe("timeline viewport budgets", () => {
|
||||
it("owns the agreed direct-scroll, DOM, media, and measurement ceilings", () => {
|
||||
expect(TIMELINE_VIEWPORT_BUDGETS).toMatchObject({
|
||||
directScrollSafetyPx: 8_000_000,
|
||||
rowOverscanPerSide: 2,
|
||||
timeOverscanViewportRatio: 0.25,
|
||||
maxMountedRows: 64,
|
||||
maxMountedClipRoots: 512,
|
||||
maxMountedClipRootsPerRow: 128,
|
||||
maxMountedTimelineDescendants: 5_000,
|
||||
thumbnailCacheBytes: 64 * 1024 * 1024,
|
||||
waveformCacheBytes: 16 * 1024 * 1024,
|
||||
interactionP95Ms: 50,
|
||||
constrainedInteractionP95Ms: 75,
|
||||
constrainedFrameIntervalP95Ms: 75,
|
||||
longTaskLimitMs: 50,
|
||||
constrainedLongTaskLimitMs: 300,
|
||||
posterCoverageRatio: 0.9,
|
||||
supportedFixtureFallbackRatio: 0.02,
|
||||
warmupRuns: 3,
|
||||
measuredRuns: 5,
|
||||
requiredPassingRuns: 4,
|
||||
});
|
||||
expect(Object.isFrozen(TIMELINE_VIEWPORT_BUDGETS)).toBe(true);
|
||||
});
|
||||
|
||||
it("creates an immutable test override without changing production defaults", () => {
|
||||
const resolved = resolveTimelineViewportBudgets({
|
||||
directScrollSafetyPx: 256,
|
||||
measuredRuns: 1,
|
||||
requiredPassingRuns: 1,
|
||||
});
|
||||
|
||||
expect(resolved.directScrollSafetyPx).toBe(256);
|
||||
expect(resolved.maxMountedClipRoots).toBe(512);
|
||||
expect(TIMELINE_VIEWPORT_BUDGETS.directScrollSafetyPx).toBe(8_000_000);
|
||||
expect(Object.isFrozen(resolved)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ maxMountedClipRoots: -1 }, "maxMountedClipRoots"],
|
||||
[{ frameIntervalP95Ms: Number.NaN }, "frameIntervalP95Ms"],
|
||||
[{ warmupRuns: 0.5 }, "warmupRuns"],
|
||||
[{ measuredRuns: 0 }, "measuredRuns"],
|
||||
[{ requiredPassingRuns: 0 }, "requiredPassingRuns"],
|
||||
[{ measuredRuns: 1.5, requiredPassingRuns: 1 }, "measuredRuns"],
|
||||
[{ measuredRuns: 4, requiredPassingRuns: 5 }, "requiredPassingRuns"],
|
||||
[{ posterCoverageRatio: 1.1 }, "posterCoverageRatio"],
|
||||
] as const)("rejects an invalid override %#", (overrides, message) => {
|
||||
expect(() => resolveTimelineViewportBudgets(overrides)).toThrow(message);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
export interface TimelineViewportBudgets {
|
||||
directScrollSafetyPx: number;
|
||||
rowOverscanPerSide: number;
|
||||
timeOverscanViewportRatio: number;
|
||||
maxMountedRows: number;
|
||||
maxMountedClipRoots: number;
|
||||
maxMountedClipRootsPerRow: number;
|
||||
maxMountedTimelineDescendants: number;
|
||||
posterMaxPhysicalWidth: number;
|
||||
posterMaxPhysicalHeight: number;
|
||||
posterDprCap: number;
|
||||
richPreviewFrameCount: number;
|
||||
concurrentVideoDecodes: number;
|
||||
concurrentMetadataJobs: number;
|
||||
concurrentCompositionFetches: number;
|
||||
concurrentServerPages: number;
|
||||
thumbnailCacheBytes: number;
|
||||
thumbnailCacheEntries: number;
|
||||
thumbnailCacheEntriesPerProject: number;
|
||||
metadataRegistryEntries: number;
|
||||
metadataFailureTtlMs: number;
|
||||
waveformCacheBytes: number;
|
||||
waveformCacheEntries: number;
|
||||
compositionDiskCacheBytes: number;
|
||||
compositionDiskCacheMaxAgeMs: number;
|
||||
interactionP95Ms: number;
|
||||
frameIntervalP95Ms: number;
|
||||
constrainedInteractionP95Ms: number;
|
||||
constrainedFrameIntervalP95Ms: number;
|
||||
longTaskLimitMs: number;
|
||||
constrainedLongTaskLimitMs: number;
|
||||
memoryReturnToleranceRatio: number;
|
||||
posterColdP95Ms: number;
|
||||
posterCachedP95Ms: number;
|
||||
constrainedPosterColdP95Ms: number;
|
||||
constrainedPosterCachedP95Ms: number;
|
||||
posterCoverageRatio: number;
|
||||
posterCoverageSettleMs: number;
|
||||
constrainedPosterCoverageSettleMs: number;
|
||||
richPreviewP95Ms: number;
|
||||
constrainedRichPreviewP95Ms: number;
|
||||
supportedFixtureFallbackRatio: number;
|
||||
warmupRuns: number;
|
||||
measuredRuns: number;
|
||||
requiredPassingRuns: number;
|
||||
}
|
||||
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The sole default budget owner for timeline viewport and media virtualization.
|
||||
* Consumers may resolve an immutable per-test override; production defaults are
|
||||
* never mutated globally.
|
||||
*/
|
||||
export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Object.freeze({
|
||||
directScrollSafetyPx: 8_000_000,
|
||||
rowOverscanPerSide: 2,
|
||||
timeOverscanViewportRatio: 0.25,
|
||||
maxMountedRows: 64,
|
||||
maxMountedClipRoots: 512,
|
||||
maxMountedClipRootsPerRow: 128,
|
||||
maxMountedTimelineDescendants: 5_000,
|
||||
posterMaxPhysicalWidth: 240,
|
||||
posterMaxPhysicalHeight: 135,
|
||||
posterDprCap: 1.5,
|
||||
richPreviewFrameCount: 6,
|
||||
concurrentVideoDecodes: 2,
|
||||
concurrentMetadataJobs: 4,
|
||||
concurrentCompositionFetches: 2,
|
||||
concurrentServerPages: 1,
|
||||
thumbnailCacheBytes: 64 * MEBIBYTE,
|
||||
thumbnailCacheEntries: 256,
|
||||
thumbnailCacheEntriesPerProject: 96,
|
||||
metadataRegistryEntries: 512,
|
||||
metadataFailureTtlMs: 30_000,
|
||||
waveformCacheBytes: 16 * MEBIBYTE,
|
||||
waveformCacheEntries: 256,
|
||||
compositionDiskCacheBytes: 512 * MEBIBYTE,
|
||||
compositionDiskCacheMaxAgeMs: 14 * DAY_MS,
|
||||
interactionP95Ms: 50,
|
||||
frameIntervalP95Ms: 33.3,
|
||||
constrainedInteractionP95Ms: 75,
|
||||
constrainedFrameIntervalP95Ms: 75,
|
||||
longTaskLimitMs: 50,
|
||||
constrainedLongTaskLimitMs: 300,
|
||||
memoryReturnToleranceRatio: 0.15,
|
||||
posterColdP95Ms: 750,
|
||||
posterCachedP95Ms: 250,
|
||||
constrainedPosterColdP95Ms: 1_200,
|
||||
constrainedPosterCachedP95Ms: 400,
|
||||
posterCoverageRatio: 0.9,
|
||||
posterCoverageSettleMs: 1_500,
|
||||
constrainedPosterCoverageSettleMs: 2_500,
|
||||
richPreviewP95Ms: 750,
|
||||
constrainedRichPreviewP95Ms: 1_200,
|
||||
supportedFixtureFallbackRatio: 0.02,
|
||||
warmupRuns: 3,
|
||||
measuredRuns: 5,
|
||||
requiredPassingRuns: 4,
|
||||
});
|
||||
|
||||
function assertValidBudget(name: keyof TimelineViewportBudgets, value: number): void {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new RangeError(`Timeline viewport budget ${name} must be a finite non-negative number`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTimelineViewportBudgets(
|
||||
overrides: Partial<TimelineViewportBudgets> = {},
|
||||
): Readonly<TimelineViewportBudgets> {
|
||||
for (const [name, value] of Object.entries(overrides)) {
|
||||
assertValidBudget(name as keyof TimelineViewportBudgets, value);
|
||||
}
|
||||
const resolved = { ...TIMELINE_VIEWPORT_BUDGETS, ...overrides };
|
||||
for (const name of ["warmupRuns", "measuredRuns", "requiredPassingRuns"] as const) {
|
||||
if (!Number.isInteger(resolved[name])) {
|
||||
throw new RangeError(`Timeline viewport budget ${name} must be an integer`);
|
||||
}
|
||||
}
|
||||
if (resolved.measuredRuns === 0 || resolved.requiredPassingRuns === 0) {
|
||||
throw new RangeError(
|
||||
"Timeline viewport budget measuredRuns and requiredPassingRuns must be greater than zero",
|
||||
);
|
||||
}
|
||||
if (resolved.requiredPassingRuns > resolved.measuredRuns) {
|
||||
throw new RangeError("Timeline viewport budget requiredPassingRuns cannot exceed measuredRuns");
|
||||
}
|
||||
for (const name of [
|
||||
"memoryReturnToleranceRatio",
|
||||
"posterCoverageRatio",
|
||||
"supportedFixtureFallbackRatio",
|
||||
] as const) {
|
||||
if (resolved[name] > 1) {
|
||||
throw new RangeError(`Timeline viewport budget ${name} cannot exceed 1`);
|
||||
}
|
||||
}
|
||||
return Object.freeze(resolved);
|
||||
}
|
||||
@@ -408,9 +408,11 @@ describe("usePlayerStore", () => {
|
||||
expect(usePlayerStore.getState().manualZoomPercent).toBe(10);
|
||||
});
|
||||
|
||||
it("clamps to the maximum supported zoom percent", () => {
|
||||
usePlayerStore.getState().setManualZoomPercent(5000);
|
||||
expect(usePlayerStore.getState().manualZoomPercent).toBe(2000);
|
||||
it("clamps to the frame-level zoom for the current fit scale", () => {
|
||||
usePlayerStore.getState().setTimelineScale(12, 12);
|
||||
usePlayerStore.getState().setManualZoomPercent(100_000);
|
||||
expect(usePlayerStore.getState().manualZoomPercent).toBe(12_000);
|
||||
usePlayerStore.getState().setTimelineScale(100, 100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -441,6 +443,21 @@ describe("usePlayerStore", () => {
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("increments the session epoch only for a hard project switch", () => {
|
||||
usePlayerStore.getState().beginTimelineSession("project-a");
|
||||
const firstEpoch = usePlayerStore.getState().timelineSessionEpoch;
|
||||
|
||||
usePlayerStore.getState().reset();
|
||||
expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch);
|
||||
|
||||
usePlayerStore.getState().beginTimelineSession("project-a");
|
||||
expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch);
|
||||
|
||||
usePlayerStore.getState().beginTimelineSession("project-b");
|
||||
expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch + 1);
|
||||
expect(usePlayerStore.getState().timelineProjectId).toBe("project-b");
|
||||
});
|
||||
|
||||
it("resets all state to defaults", () => {
|
||||
// Mutate everything
|
||||
const store = usePlayerStore.getState();
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { BeatEditState } from "../../utils/beatEditing";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
import { computePinnedZoomPercent } from "../components/timelineZoom";
|
||||
import { createKeyframeSlice, type KeyframeSlice } from "./keyframeSlice";
|
||||
import {
|
||||
readStudioUiPreferences,
|
||||
writeStudioUiPreferences,
|
||||
type TimelineTimeDisplayMode,
|
||||
} from "../../utils/studioUiPreferences";
|
||||
import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
|
||||
import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
|
||||
|
||||
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
||||
|
||||
@@ -101,6 +106,10 @@ interface PlayerState extends KeyframeSlice {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
timelineReady: boolean;
|
||||
/** Increments exactly once when the Studio switches to a different project. */
|
||||
timelineSessionEpoch: number;
|
||||
/** Project owning the current timeline session; null outside a project-scoped reset. */
|
||||
timelineProjectId: string | null;
|
||||
/** True while a beat dot is being dragged — hides the playhead guideline. */
|
||||
beatDragging: boolean;
|
||||
elements: TimelineElement[];
|
||||
@@ -155,18 +164,15 @@ interface PlayerState extends KeyframeSlice {
|
||||
timelineSnapEnabled: boolean;
|
||||
setTimelineSnapEnabled: (enabled: boolean) => void;
|
||||
/** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
|
||||
timeDisplayMode: "time" | "frame";
|
||||
setTimeDisplayMode: (mode: "time" | "frame") => void;
|
||||
timeDisplayMode: TimelineTimeDisplayMode;
|
||||
setTimeDisplayMode: (mode: TimelineTimeDisplayMode) => void;
|
||||
/**
|
||||
* Pin the timeline zoom to its current visual scale before a duration-changing
|
||||
* edit, so a subsequent duration change (which recomputes fit-pps) stops
|
||||
* rescaling every clip. No-op once already pinned (mode is "manual").
|
||||
*/
|
||||
pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
|
||||
/**
|
||||
* The timeline's live pixels-per-second + fit basis, published by <Timeline> on
|
||||
* every render. Non-reactive scratch state (never read as a render input).
|
||||
*/
|
||||
/** The timeline's live pixels-per-second + fit basis, published by <Timeline>. */
|
||||
timelinePps: number;
|
||||
timelineFitPps: number;
|
||||
setTimelineScale: (pps: number, fitPps: number) => void;
|
||||
@@ -201,6 +207,9 @@ interface PlayerState extends KeyframeSlice {
|
||||
bumpZEditVersion: () => void;
|
||||
setInPoint: (time: number | null) => void;
|
||||
setOutPoint: (time: number | null) => void;
|
||||
/** Owns the hard project boundary; repeated calls for one project are no-ops. */
|
||||
beginTimelineSession: (projectId: string) => void;
|
||||
/** Clears project data without creating a new hard-project session. */
|
||||
reset: () => void;
|
||||
|
||||
/**
|
||||
@@ -283,11 +292,49 @@ export const liveTime = {
|
||||
},
|
||||
};
|
||||
|
||||
export function createTimelineResetState() {
|
||||
return {
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
timelineReady: false,
|
||||
beatDragging: false,
|
||||
elements: [],
|
||||
selectedElementId: null,
|
||||
zEditVersion: 0,
|
||||
inPoint: null,
|
||||
outPoint: null,
|
||||
activeTool: "select" as const,
|
||||
activeKeyframePct: null,
|
||||
motionPathArmed: false,
|
||||
motionPathCreateAvailable: false,
|
||||
selectedKeyframes: new Set<string>(),
|
||||
expandedClipIds: new Set<string>(),
|
||||
focusedEaseSegment: null,
|
||||
selectedElementIds: new Set<string>(),
|
||||
requestedSeekTime: null,
|
||||
clipRevealRequest: null,
|
||||
lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
|
||||
keyframeCache: new Map<string, KeyframeCacheEntry>(),
|
||||
gsapAnimations: new Map<string, GsapAnimation[]>(),
|
||||
beatAnalysis: null,
|
||||
beatEdits: null,
|
||||
beatUndo: [],
|
||||
beatRedo: [],
|
||||
beatPersist: null,
|
||||
clipManifest: null,
|
||||
clipParentMap: new Map<string, string>(),
|
||||
domClipChildren: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
timelineReady: false,
|
||||
timelineSessionEpoch: 0,
|
||||
timelineProjectId: null,
|
||||
beatDragging: false,
|
||||
elements: [],
|
||||
selectedElementId: null,
|
||||
@@ -436,12 +483,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
return { zoomMode: "manual", manualZoomPercent: percent };
|
||||
}),
|
||||
setTimelineScale: (pps, fitPps) => {
|
||||
// Non-reactive publish: mutate in place + reuse the same object identity so no
|
||||
// subscriber re-renders (these fields are never a render input, only read
|
||||
// imperatively before pinning).
|
||||
const state = get();
|
||||
state.timelinePps = pps;
|
||||
state.timelineFitPps = fitPps;
|
||||
if (state.timelinePps === pps && state.timelineFitPps === fitPps) return;
|
||||
set({ timelinePps: pps, timelineFitPps: fitPps });
|
||||
},
|
||||
setInPoint: (time) =>
|
||||
set((state) => {
|
||||
@@ -465,7 +509,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
setManualZoomPercent: (percent) =>
|
||||
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
|
||||
set((state) => ({
|
||||
manualZoomPercent: clampTimelineZoomPercent(percent, state.timelineFitPps),
|
||||
})),
|
||||
bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
|
||||
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
|
||||
setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
|
||||
@@ -514,37 +560,19 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
(el.key ?? el.id) === elementId ? { ...el, ...updates } : el,
|
||||
),
|
||||
})),
|
||||
// Resets project-specific state when switching compositions.
|
||||
// playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
|
||||
// because they are user preferences that should survive project switches.
|
||||
reset: () =>
|
||||
set({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
timelineReady: false,
|
||||
beatDragging: false,
|
||||
elements: [],
|
||||
selectedElementId: null,
|
||||
inPoint: null,
|
||||
outPoint: null,
|
||||
activeTool: "select",
|
||||
selectedKeyframes: new Set(),
|
||||
expandedClipIds: new Set(),
|
||||
focusedEaseSegment: null,
|
||||
selectedElementIds: new Set(),
|
||||
clipRevealRequest: null,
|
||||
keyframeCache: new Map(),
|
||||
gsapAnimations: new Map(),
|
||||
beatAnalysis: null,
|
||||
beatEdits: null,
|
||||
beatUndo: [],
|
||||
beatRedo: [],
|
||||
beatPersist: null,
|
||||
clipManifest: null,
|
||||
clipParentMap: new Map(),
|
||||
domClipChildren: [],
|
||||
// playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are
|
||||
// intentionally absent from createTimelineResetState because they are user
|
||||
// preferences that survive both source refreshes and project switches.
|
||||
beginTimelineSession: (projectId) =>
|
||||
set((state) => {
|
||||
if (state.timelineProjectId === projectId) return state;
|
||||
return {
|
||||
...createTimelineResetState(),
|
||||
timelineSessionEpoch: state.timelineSessionEpoch + 1,
|
||||
timelineProjectId: projectId,
|
||||
};
|
||||
}),
|
||||
reset: () => set(createTimelineResetState()),
|
||||
}));
|
||||
|
||||
// Bug-bash aid: expose the store so a reproduction can dump live state from the
|
||||
|
||||
Reference in New Issue
Block a user