Skip to content

Commit 6f4ff16

Browse files
committed
refactor(studio): extract timeline render contracts
1 parent a289615 commit 6f4ff16

9 files changed

Lines changed: 255 additions & 209 deletions

packages/studio/src/player/components/Timeline.tsx

Lines changed: 21 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react";
1+
import { useRef, useMemo, useCallback, useState, memo } from "react";
22
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
33
import { isMusicTrack } from "../../utils/timelineInspector";
44
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
55
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
66
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
7-
import { useMountEffect } from "../../hooks/useMountEffect";
87
import { defaultTimelineTheme } from "./timelineTheme";
98
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
109
import { useTimelinePlayhead } from "./useTimelinePlayhead";
@@ -16,14 +15,12 @@ import { TimelineCanvas } from "./TimelineCanvas";
1615
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
1716
import { useTimelineClipDrag } from "./useTimelineClipDrag";
1817
import { TimelineOverlays } from "./TimelineOverlays";
19-
import { animationContributesLane } from "./TimelinePropertyLanes";
2018
import { useTimelineEditPinning } from "./useTimelineEditPinning";
2119
import { useTimelineStackingSync } from "./useTimelineStackingSync";
2220
import { useTimelineGeometry } from "./useTimelineGeometry";
2321
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
24-
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD, generateTicks } from "./timelineLayout";
22+
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
2523
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
26-
import { STUDIO_PREVIEW_FPS } from "../lib/time";
2724
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
2825
import type { TimelineProps } from "./TimelineTypes";
2926
import {
@@ -37,6 +34,14 @@ import { useTrackGapMenu } from "./useTrackGapMenu";
3734
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
3835
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
3936
import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
37+
import {
38+
getEffectiveTimelineDuration,
39+
getTimelinePreviewElement,
40+
hasKeyframedTimelineClips,
41+
} from "./timelineViewModel";
42+
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
43+
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
44+
import { useTimelineTicks } from "./useTimelineTicks";
4045

4146
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
4247
export {
@@ -117,13 +122,7 @@ export const Timeline = memo(function Timeline({
117122
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
118123
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
119124
const hasKeyframedClips = useMemo(
120-
() =>
121-
Array.from(gsapAnimations.values()).some((list) =>
122-
// Same lane-contribution predicate the layout uses: real keyframes OR a
123-
// synthesizable flat tween. Checking animation.keyframes alone left a
124-
// flat-tween-only comp without its reserved label column.
125-
list.some((animation) => animationContributesLane(animation)),
126-
),
125+
() => hasKeyframedTimelineClips(gsapAnimations),
127126
[gsapAnimations],
128127
);
129128
const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips;
@@ -142,20 +141,7 @@ export const Timeline = memo(function Timeline({
142141
const activeTool = usePlayerStore((s) => s.activeTool);
143142
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
144143
const isDragging = useRef(false);
145-
const [shiftHeld, setShiftHeld] = useState(false);
146-
147-
useMountEffect(() => {
148-
const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown");
149-
const blur = () => setShiftHeld(false);
150-
window.addEventListener("keydown", key);
151-
window.addEventListener("keyup", key);
152-
window.addEventListener("blur", blur);
153-
return () => {
154-
window.removeEventListener("keydown", key);
155-
window.removeEventListener("keyup", key);
156-
window.removeEventListener("blur", blur);
157-
};
158-
});
144+
const shiftHeld = useTimelineShiftModifier();
159145

160146
const [showPopover, setShowPopover] = useState(false);
161147
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
@@ -172,12 +158,10 @@ export const Timeline = memo(function Timeline({
172158
// Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
173159
const lastScrollLeftRef = useRef(0);
174160

175-
const effectiveDuration = useMemo(() => {
176-
const safeDur = Number.isFinite(duration) ? duration : 0;
177-
if (rawElements.length === 0) return safeDur;
178-
const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration));
179-
return Number.isFinite(result) ? result : safeDur;
180-
}, [rawElements, duration]);
161+
const effectiveDuration = useMemo(
162+
() => getEffectiveTimelineDuration(duration, rawElements),
163+
[duration, rawElements],
164+
);
181165

182166
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
183167
useAutoExpandKeyframedClips(gsapAnimations);
@@ -295,14 +279,6 @@ export const Timeline = memo(function Timeline({
295279
toggleSelectedKeyframe,
296280
});
297281

298-
const selectedElement = useMemo(
299-
() =>
300-
expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
301-
[expandedElements, selectedElementId],
302-
);
303-
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
304-
selectedElementRef.current = selectedElement;
305-
306282
const {
307283
pps,
308284
fitPps,
@@ -401,41 +377,15 @@ export const Timeline = memo(function Timeline({
401377
});
402378
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
403379

404-
const prevSelectedRef = useRef(selectedElementRef.current);
405-
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
406-
useEffect(() => {
407-
const prev = prevSelectedRef.current;
408-
const curr = selectedElementRef.current;
409-
prevSelectedRef.current = curr;
410-
if (prev && !curr) {
411-
setShowPopover(false);
412-
setRangeSelection(null);
413-
}
414-
});
415-
416-
// Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames.
417-
const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
418-
const { major, minor } = useMemo(
419-
() => generateTicks(displayDuration, pps, tickFps),
420-
[displayDuration, pps, tickFps],
380+
useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () =>
381+
setRangeSelection(null),
421382
);
383+
384+
const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode);
422385
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
423386

424387
const getPreviewElement = useCallback(
425-
(element: TimelineElement): TimelineElement => {
426-
if (
427-
resizingClip &&
428-
(resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
429-
) {
430-
return {
431-
...element,
432-
start: resizingClip.previewStart,
433-
duration: resizingClip.previewDuration,
434-
playbackStart: resizingClip.previewPlaybackStart,
435-
};
436-
}
437-
return element;
438-
},
388+
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
439389
[resizingClip],
440390
);
441391

packages/studio/src/player/components/timelineLayout.ts

Lines changed: 2 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { formatTime } from "../lib/time";
21
import type { ZoomMode } from "../store/playerStore";
32

3+
export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";
4+
45
/* ── Layout constants ──────────────────────────────────────────────── */
56
export const GUTTER = 32;
67
export const LABEL_COL_W = 232;
@@ -193,123 +194,6 @@ export const MIN_TIMELINE_EXTENT_S = 60;
193194
export const FIT_ZOOM_HEADROOM = 1.2;
194195

195196
/* ── Tick generation ──────────────────────────────────────────────── */
196-
// fallow-ignore-next-line complexity
197-
function getMajorTickInterval(
198-
duration: number,
199-
pixelsPerSecond?: number,
200-
frameRate?: number,
201-
): number {
202-
// "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
203-
// minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
204-
// readable (non-colliding) labels instead of the old 10m fallback everywhere.
205-
const zoomIntervals = [
206-
0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
207-
];
208-
let interval: number;
209-
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
210-
const targetMajorPx = 88;
211-
interval =
212-
zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
213-
3600;
214-
} else {
215-
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
216-
const target = duration / 6;
217-
interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
218-
}
219-
// Frame display mode: labels are frame numbers, so a major step must be a
220-
// WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
221-
// (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
222-
// the label spacing never drops below the readability target.
223-
if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
224-
const fps = frameRate ?? 0;
225-
return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
226-
}
227-
return interval;
228-
}
229-
230-
// How many equal parts to split each major interval into for minor ticks. Prefer
231-
// quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
232-
// none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
233-
// the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
234-
// sub-frame time is not a seekable position), so only divisors of the major
235-
// step's frame count qualify — quarters, then fifths (15/30-frame majors),
236-
// thirds, halves.
237-
// fallow-ignore-next-line complexity
238-
function getMinorSubdivisions(
239-
majorInterval: number,
240-
pixelsPerSecond?: number,
241-
frameRate?: number,
242-
): number {
243-
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
244-
if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
245-
const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
246-
const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
247-
const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
248-
for (const parts of candidates) {
249-
if (fps > 0 && majorFrames % parts !== 0) continue;
250-
if ((majorInterval / parts) * pps >= 8) return parts;
251-
}
252-
return 0;
253-
}
254-
255-
// Ticks are exact multiples of the interval (multiplied per index, never
256-
// accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
257-
// keep values/keys clean without disturbing frame-exact positions like 2/30s.
258-
function roundTickValue(t: number): number {
259-
return Math.round(t * 1e6) / 1e6;
260-
}
261-
262-
export function generateTicks(
263-
duration: number,
264-
pixelsPerSecond?: number,
265-
frameRate?: number,
266-
): { major: number[]; minor: number[] } {
267-
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
268-
return { major: [], minor: [] };
269-
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
270-
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
271-
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
272-
const major: number[] = [];
273-
const minor: number[] = [];
274-
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
275-
for (let i = 0; major.length < maxTicks; i++) {
276-
const t = i * majorInterval;
277-
if (t > duration + 0.001) break;
278-
major.push(roundTickValue(t));
279-
// Emit the (subdivisions - 1) minor ticks between this major and the next.
280-
for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
281-
const m = t + k * minorInterval;
282-
if (m <= duration + 0.001) minor.push(roundTickValue(m));
283-
}
284-
}
285-
return { major, minor };
286-
}
287-
288-
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
289-
if (!Number.isFinite(time)) return "00:00";
290-
const safeTime = Math.max(0, time);
291-
if (majorInterval < 0.1) {
292-
const totalHundredths = Math.round(safeTime * 100);
293-
const wholeSeconds = Math.floor(totalHundredths / 100);
294-
const hundredth = totalHundredths % 100;
295-
return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
296-
}
297-
if (majorInterval < 1) {
298-
const totalTenths = Math.round(safeTime * 10);
299-
const wholeSeconds = Math.floor(totalTenths / 10);
300-
const tenth = totalTenths % 10;
301-
return `${formatTime(wholeSeconds)}.${tenth}`;
302-
}
303-
if (duration >= 3600 || safeTime >= 3600) {
304-
const totalSeconds = Math.floor(safeTime);
305-
const hours = Math.floor(totalSeconds / 3600);
306-
const minutes = Math.floor((totalSeconds % 3600) / 60);
307-
const seconds = totalSeconds % 60;
308-
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
309-
}
310-
return formatTime(safeTime);
311-
}
312-
313197
/* ── Width / duration derivation ──────────────────────────────────── */
314198
/**
315199
* Fit-mode pixels-per-second: fill the viewport with the composition plus
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { formatTime } from "../lib/time";
2+
3+
// fallow-ignore-next-line complexity
4+
function getTimelineMajorTickInterval(
5+
duration: number,
6+
pixelsPerSecond?: number,
7+
frameRate?: number,
8+
): number {
9+
const zoomIntervals = [
10+
0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
11+
];
12+
let interval: number;
13+
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
14+
const targetMajorPx = 88;
15+
interval =
16+
zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
17+
3600;
18+
} else {
19+
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
20+
const target = duration / 6;
21+
interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
22+
}
23+
if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
24+
const fps = frameRate ?? 0;
25+
return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
26+
}
27+
return interval;
28+
}
29+
30+
// fallow-ignore-next-line complexity
31+
function getMinorSubdivisions(
32+
majorInterval: number,
33+
pixelsPerSecond?: number,
34+
frameRate?: number,
35+
): number {
36+
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
37+
if (pps <= 0) return 4;
38+
const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
39+
const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
40+
const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
41+
for (const parts of candidates) {
42+
if (fps > 0 && majorFrames % parts !== 0) continue;
43+
if ((majorInterval / parts) * pps >= 8) return parts;
44+
}
45+
return 0;
46+
}
47+
48+
function roundTickValue(time: number): number {
49+
return Math.round(time * 1e6) / 1e6;
50+
}
51+
52+
export function generateTicks(
53+
duration: number,
54+
pixelsPerSecond?: number,
55+
frameRate?: number,
56+
): { major: number[]; minor: number[] } {
57+
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400) {
58+
return { major: [], minor: [] };
59+
}
60+
const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate);
61+
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
62+
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
63+
const major: number[] = [];
64+
const minor: number[] = [];
65+
const maxTicks = 2000;
66+
for (let index = 0; major.length < maxTicks; index++) {
67+
const time = index * majorInterval;
68+
if (time > duration + 0.001) break;
69+
major.push(roundTickValue(time));
70+
for (let part = 1; part < subdivisions && major.length + minor.length < maxTicks; part++) {
71+
const minorTime = time + part * minorInterval;
72+
if (minorTime <= duration + 0.001) minor.push(roundTickValue(minorTime));
73+
}
74+
}
75+
return { major, minor };
76+
}
77+
78+
export function formatTimelineTickLabel(
79+
time: number,
80+
duration: number,
81+
majorInterval: number,
82+
): string {
83+
if (!Number.isFinite(time)) return "00:00";
84+
const safeTime = Math.max(0, time);
85+
if (majorInterval < 0.1) {
86+
const totalHundredths = Math.round(safeTime * 100);
87+
const wholeSeconds = Math.floor(totalHundredths / 100);
88+
const hundredth = totalHundredths % 100;
89+
return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
90+
}
91+
if (majorInterval < 1) {
92+
const totalTenths = Math.round(safeTime * 10);
93+
const wholeSeconds = Math.floor(totalTenths / 10);
94+
const tenth = totalTenths % 10;
95+
return `${formatTime(wholeSeconds)}.${tenth}`;
96+
}
97+
if (duration >= 3600 || safeTime >= 3600) {
98+
const totalSeconds = Math.floor(safeTime);
99+
const hours = Math.floor(totalSeconds / 3600);
100+
const minutes = Math.floor((totalSeconds % 3600) / 60);
101+
const seconds = totalSeconds % 60;
102+
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
103+
}
104+
return formatTime(safeTime);
105+
}

0 commit comments

Comments
 (0)