Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/studio/src/components/nle/NLEContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface NLEContextValue {
compositionLoading: boolean;
setCompositionLoading: (loading: boolean) => void;
timelineDisabled: boolean;
timelineSessionEpoch: number;
hasLoadedOnceRef: React.MutableRefObject<boolean>;
// preview composition size (for preview block drop)
previewCompositionSize: { width: number; height: number } | null;
Expand Down Expand Up @@ -103,7 +104,7 @@ export function NLEProvider({
// project would otherwise keep rendering (and re-fetching from) the old project
// after switching.
useEffect(() => {
usePlayerStore.getState().reset();
usePlayerStore.getState().beginTimelineSession(projectId);
useAssetPreviewStore.getState().clearPreviewAsset();
}, [projectId]);

Expand Down Expand Up @@ -289,6 +290,7 @@ export function NLEProvider({
setCompositionLoadingRaw(loading);
}, []);
const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
const timelineSessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);

useEffect(() => {
onCompositionLoadingChange?.(compositionLoading);
Expand Down Expand Up @@ -319,6 +321,7 @@ export function NLEProvider({
compositionLoading,
setCompositionLoading,
timelineDisabled,
timelineSessionEpoch,
hasLoadedOnceRef,
previewCompositionSize,
setPreviewCompositionSize,
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/components/nle/TimelinePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function TimelinePane({
persistTimelineH,
containerRef,
timelineDisabled,
timelineSessionEpoch,
} = useNLEContext();

// Move/resize/split come from the timeline edit context, not props — the
Expand Down Expand Up @@ -271,6 +272,7 @@ export function TimelinePane({
>
<div className="flex-shrink-0">{timelineToolbar}</div>
<Timeline
sessionEpoch={timelineSessionEpoch}
onSeek={seek}
onDrillDown={handleDrillDown}
renderClipContent={renderClipContent}
Expand Down
5 changes: 5 additions & 0 deletions packages/studio/src/hooks/useStudioTestHooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../player/store/playerStore";
import {
createTimelinePerformanceFixture,
hasTimelinePerformanceFixtureLease,
setTimelinePerformanceFixtureLease,
type TimelinePerformanceFixtureProfile,
} from "../player/lib/timelinePerformanceFixture";
import { useStudioTestHooks } from "./useStudioTestHooks";
Expand All @@ -30,6 +32,7 @@ function Probe(): null {

describe("timeline performance fixture", () => {
afterEach(() => {
setTimelinePerformanceFixtureLease(false);
window.__studioTest = undefined;
usePlayerStore.getState().reset();
});
Expand Down Expand Up @@ -101,9 +104,11 @@ describe("timeline performance fixture", () => {
});
expect(usePlayerStore.getState().elements).toHaveLength(1_000);
expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000);
expect(hasTimelinePerformanceFixtureLease()).toBe(true);
unsubscribe();
act(() => root.unmount());
expect(window.__studioTest).toBeUndefined();
expect(hasTimelinePerformanceFixtureLease()).toBe(false);
});

it("does not mutate state when the fixture request is invalid", () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/studio/src/hooks/useStudioTestHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "../player/lib/timelinePerformanceDiagnostics";
import {
createTimelinePerformanceFixture,
setTimelinePerformanceFixtureLease,
type TimelinePerformanceFixtureSpec,
type TimelinePerformanceFixtureSummary,
} from "../player/lib/timelinePerformanceFixture";
Expand Down Expand Up @@ -70,6 +71,7 @@ export function useStudioTestHooks({
},
loadTimelinePerformanceFixture: (spec) => {
const fixture = createTimelinePerformanceFixture(spec);
setTimelinePerformanceFixtureLease(true);
usePlayerStore.setState({
currentTime: 0,
duration: fixture.summary.duration,
Expand All @@ -91,6 +93,7 @@ export function useStudioTestHooks({
};
window.__studioTest = api;
return () => {
setTimelinePerformanceFixtureLease(false);
window.__studioTest = undefined;
};
}, [applyDomSelection, buildDomSelectionFromTarget, previewIframeRef]);
Expand Down
22 changes: 22 additions & 0 deletions packages/studio/src/player/components/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
shouldShowTimelineShortcutHint,
shouldHandleTimelineDeleteKey,
shouldAutoScrollTimeline,
getTimelineVisibleTimeRange,
getTimelineScrollTopForGeometryChange,
} from "./Timeline";
import {
CLIP_Y,
Expand All @@ -32,6 +34,7 @@ import {
getTimelineDisplayContentWidth,
getTimelineFitPps,
getTimelineLaneTop,
createTimelineRowGeometry,
} from "./timelineLayout";
import { formatTime } from "../lib/time";
import { usePlayerStore } from "../store/playerStore";
Expand All @@ -44,6 +47,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}`);
Expand Down
79 changes: 64 additions & 15 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useRef, useMemo, useCallback, useState, memo } from "react";
import { useRef, useMemo, useCallback, useState, useLayoutEffect, 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";
Expand Down Expand Up @@ -42,6 +41,8 @@ import {
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry";

// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
Expand All @@ -58,6 +59,11 @@ export {
getDefaultDroppedTrack,
} from "./timelineLayout";

export {
getTimelineScrollTopForGeometryChange,
getTimelineVisibleTimeRange,
} from "./timelineViewportGeometry";

export const Timeline = memo(function Timeline({
onSeek,
onDrillDown,
Expand All @@ -76,6 +82,7 @@ export const Timeline = memo(function Timeline({
onSplitElement: onSplitElementOverride,
onSelectElement,
theme: themeOverrides,
sessionEpoch = 0,
}: TimelineProps = {}) {
const {
onMoveElement,
Expand Down Expand Up @@ -108,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),
Expand Down Expand Up @@ -163,8 +170,20 @@ export const Timeline = memo(function Timeline({

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;

Expand Down Expand Up @@ -231,7 +250,7 @@ export const Timeline = memo(function Timeline({
ppsRef,
durationRef,
trackOrderRef,
rowHeightsRef,
rowGeometryRef,
onMoveElement: pinnedOnMoveElement,
onMoveElements: pinnedOnMoveElements,
onResizeElement: pinnedOnResizeElement,
Expand All @@ -250,20 +269,47 @@ 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 { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
timelineReady,
expandedElements.length,
displayLayout.totalH,
]);
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
useTimelineScrollViewport(scrollRef, [
timelineReady,
expandedElements.length,
displayLayout.totalH,
]);
const previousLayoutRef = useRef(displayLayout.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 !== displayLayout.rowGeometry) {
const nextScrollTop = getTimelineScrollTopForGeometryChange(
previousGeometry,
displayLayout.rowGeometry,
scroll.scrollTop,
);
if (nextScrollTop !== scroll.scrollTop) {
scroll.scrollTop = nextScrollTop;
syncScrollViewport(scroll);
}
}
previousLayoutRef.current = displayLayout.rowGeometry;
}, [displayLayout.rowGeometry, sessionEpoch, syncScrollViewport]);
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
Expand All @@ -286,7 +332,7 @@ export const Timeline = memo(function Timeline({
zoomModeRef,
manualZoomPercentRef,
} = useTimelineGeometry({
viewportWidth,
viewportWidth: viewport.clientWidth,
effectiveDuration,
zoomMode,
manualZoomPercent,
Expand Down Expand Up @@ -369,7 +415,7 @@ export const Timeline = memo(function Timeline({
setShowPopover,
elementsRef: expandedElementsRef,
trackOrderRef,
rowHeightsRef,
rowGeometryRef,
onSelectElement,
contentOrigin,
});
Expand Down Expand Up @@ -403,6 +449,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}
Expand All @@ -414,10 +461,12 @@ export const Timeline = memo(function Timeline({
>
<div
ref={setScrollRef}
data-timeline-scroll-viewport
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
syncScrollViewport(e.currentTarget, true);
}}
onDragOver={handleAssetDragOver}
onDragLeave={() => clearDropPreview()}
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/player/components/TimelineTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: (
Expand Down
10 changes: 5 additions & 5 deletions packages/studio/src/player/components/timelineDragDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ 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 {
scrollRef: RefObject<HTMLDivElement | null>;
ppsRef: RefObject<number>;
durationRef: RefObject<number>;
trackOrderRef: RefObject<number[]>;
rowHeightsRef: RefObject<readonly number[]>;
rowGeometryRef: RefObject<TimelineRowGeometry>;
contentOrigin: number;
}

Expand Down Expand Up @@ -56,7 +56,7 @@ export function useTimelineAssetDrop({
ppsRef,
durationRef,
trackOrderRef,
rowHeightsRef,
rowGeometryRef,
contentOrigin,
onFileDrop,
onAssetDrop,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
Loading
Loading