diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 318c408092..802b5f142a 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -6,7 +6,6 @@ import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElement 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"; @@ -43,8 +42,9 @@ import { useTimelineShiftModifier } from "./useTimelineShiftModifier"; import { useTimelineTicks } from "./useTimelineTicks"; import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; 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, @@ -128,9 +128,8 @@ export const Timeline = memo(function Timeline({ 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( () => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations], @@ -162,7 +161,6 @@ export const Timeline = memo(function Timeline({ containerRef.current = el; }, []); - // Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom). const lastScrollLeftRef = useRef(0); const effectiveDuration = useMemo( @@ -314,28 +312,46 @@ export const Timeline = memo(function Timeline({ toggleSelectedKeyframe, }); - const { - pps, - fitPps, - displayContentWidth, - displayDuration, - clipStateVersion, - zoomModeRef, - manualZoomPercentRef, - } = useTimelineGeometry({ - viewportWidth: viewport.clientWidth, - 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?.element.key ?? draggedClip?.element.id, + resizingElementId: resizingClip?.element.key ?? resizingClip?.element.id, + revealElementId: clipRevealRequest?.elementId, + focusedEaseElementId: focusedEaseSegment?.elementId, + clipContextMenuElementId: clipContextMenu?.element.key ?? clipContextMenu?.element.id, + keyframeContextMenuElementId: kfContextMenu?.element.key ?? kfContextMenu?.element.id, + scrollRef, + elements: expandedElements, + rowGeometry: displayLayout.rowGeometry, + allowHorizontalReveal: zoomMode === "manual", + sessionEpoch, + }); + useTimelineActiveClips({ + scrollRef, + currentTime, + clipStateVersion: renderTimeRange, + elementStateVersion: expandedElements, }); const laneGapStrips = useTimelineGapHighlights({ @@ -370,11 +386,6 @@ export const Timeline = memo(function Timeline({ onSeek, contentOrigin, }); - useTimelineActiveClips({ - scrollRef, - currentTime, - clipStateVersion, - }); const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } = useTimelineRazorInteraction({ active: activeTool === "razor", @@ -416,8 +427,12 @@ export const Timeline = memo(function Timeline({ setRangeSelection(null), ); - const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode); - const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration; + const { major, minor, majorTickInterval } = useTimelineTicks( + displayDuration, + pps, + timeDisplayMode, + rowVirtualizationActive ? renderTimeRange : undefined, + ); const getPreviewElement = useCallback( (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip), @@ -493,6 +508,9 @@ export const Timeline = memo(function Timeline({ rowGeometry={displayLayout.rowGeometry} virtualRows={virtualRows} rowsVirtualized={rowVirtualizationActive} + clipIndex={clipIndex} + renderTimeRange={renderTimeRange} + pinnedClipIdentities={pinnedClipIdentities} trackOrder={trackOrder} tracks={tracks} trackStyles={trackStyles} @@ -506,7 +524,7 @@ export const Timeline = memo(function Timeline({ blockedClipRef={blockedClipRef} suppressClickRef={suppressClickRef} scrollRef={scrollRef} - renderClipContent={renderClipContent} + renderClipContent={viewport.isScrolling ? undefined : renderClipContent} renderClipOverlay={renderClipOverlay} playheadRef={playheadRef} onDrillDown={onDrillDown} diff --git a/packages/studio/src/player/components/Timeline.virtualization.test.tsx b/packages/studio/src/player/components/Timeline.virtualization.test.tsx index 8202758794..25d2455012 100644 --- a/packages/studio/src/player/components/Timeline.virtualization.test.tsx +++ b/packages/studio/src/player/components/Timeline.virtualization.test.tsx @@ -51,6 +51,59 @@ afterAll(() => { }); describe("Timeline row virtualization", () => { + 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 = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + await act(async () => + root.render( + React.createElement(Timeline, { + renderClipContent: () => React.createElement("span", { "data-rich-content": true }), + }), + ), + ); + await act(async () => {}); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 110)); + }); + + const scroller = host.querySelector("[data-timeline-scroll-viewport]"); + const clip = host.querySelector('[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(); + + await act(async () => { + scroller?.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + 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 }] = await Promise.all([ import("./Timeline"), @@ -60,6 +113,8 @@ describe("Timeline row virtualization", () => { usePlayerStore.setState({ duration: 60, timelineReady: true, + // Repeated fixture shape intentionally contrasts row and clip windowing scales. + // fallow-ignore-next-line code-duplication elements: Array.from({ length: 1_000 }, (_, track) => ({ id: `clip-${track}`, tag: "div", @@ -101,4 +156,75 @@ describe("Timeline row virtualization", () => { act(() => root.unmount()); usePlayerStore.getState().reset(); }, 10_000); + + 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 () => {}); + + const initialClips = [...host.querySelectorAll("[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("[data-timeline-scroll-viewport]"); + expect(scroller).not.toBeNull(); + if (scroller) { + scroller.scrollLeft = 8_000; + await act(async () => { + scroller.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + } + + const scrolledClips = [...host.querySelectorAll("[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 act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + await act(async () => {}); + expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); + expect(document.activeElement?.getAttribute("data-el-id")).toBe("clip-300"); + expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull(); + + act(() => root.unmount()); + usePlayerStore.getState().reset(); + }); }); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index c951fae98c..6dc95aa1e8 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -23,8 +23,8 @@ import { TimelineClip } from "./TimelineClip"; import { TimelineLanes } from "./TimelineLanes"; import type { TimelineLaneBaseProps } from "./TimelineLaneTypes"; import { renderClipChildren } from "./timelineClipChildren"; -import { useTimelineRevealClip } from "./useTimelineRevealClip"; import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights"; +import { isTimelineClipActive } from "./useTimelineActiveClips"; interface TimelineCanvasProps extends TimelineLaneBaseProps { major: number[]; @@ -57,8 +57,6 @@ 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 activeDraggedElement = draggedClip?.started === true && draggedElement @@ -121,6 +119,7 @@ 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 @@ -152,7 +151,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) => (
; trackOrder: number[]; tracks: [number, TimelineElement[]][]; trackStyles: Map; diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index ee00598667..0f3042536a 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -20,6 +20,8 @@ import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspec import { renderClipChildren } from "./timelineClipChildren"; import type { TimelineLaneBaseProps } from "./TimelineLaneTypes"; import { TimelineTrackRow } from "./TimelineTrackRow"; +import { isTimelineClipActive } from "./useTimelineActiveClips"; +import { queryTimelineClipIndex } from "../lib/timelineClipIndex"; interface TimelineLanesProps extends TimelineLaneBaseProps { /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ @@ -43,6 +45,9 @@ export function TimelineLanes({ rowGeometry, virtualRows, rowsVirtualized, + clipIndex, + renderTimeRange, + pinnedClipIdentities, trackOrder, tracks, trackStyles, @@ -110,6 +115,29 @@ export function TimelineLanes({ if (trackNum === undefined) return null; const rowHeight = rowGeometry.getRowHeight(row); const els = tracks.find(([t]) => t === trackNum)?.[1] ?? []; + const indexedRenderElements = rowsVirtualized + ? queryTimelineClipIndex(clipIndex, trackNum, renderTimeRange, pinnedClipIdentities) + : els; + const indexedRenderSet = new Set(indexedRenderElements); + const renderElements = rowsVirtualized + ? els.filter((element) => { + if (indexedRenderSet.has(element)) return true; + if ( + !multiDragPreview || + !isMultiDragPassenger(element.key ?? element.id, multiDragPreview) + ) { + return false; + } + const previewStart = + element.start + + multiDragPassengerOffsetPx(element.key ?? element.id, pps, multiDragPreview) / + pps; + const previewEnd = previewStart + Math.max(0, element.duration); + return previewEnd <= previewStart + ? previewStart >= renderTimeRange.start && previewStart < renderTimeRange.end + : previewStart < renderTimeRange.end && previewEnd > renderTimeRange.start; + }) + : els; const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); const isPendingTrack = draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0; @@ -202,6 +230,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. */} @@ -210,6 +239,7 @@ export function TimelineLanes({ beatTimes={beatAnalysis?.beatTimes} beatStrengths={beatAnalysis?.beatStrengths} pps={pps} + renderTimeRange={rowsVirtualized ? renderTimeRange : undefined} /> )} {isPendingTrack && ( @@ -229,7 +259,7 @@ 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; // Only the track's active keyframe clip shows expanded lanes; @@ -276,6 +306,7 @@ export function TimelineLanes({ isSelected={isSelected} isHovered={hoveredClip === clipKey} isDragging={false} + isActive={isTimelineClipActive(previewElement, currentTime)} hasCustomContent={!!renderClipContent} capabilities={capabilities} theme={theme} diff --git a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts index f9432dd1d2..c9fc2b42b0 100644 --- a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts +++ b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts @@ -53,6 +53,8 @@ interface TimelineClipDragGestureLifecycleInput { } 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, diff --git a/packages/studio/src/player/components/useTimelineActiveClips.test.ts b/packages/studio/src/player/components/useTimelineActiveClips.test.ts index a7ab44e5ea..af1fe4adaf 100644 --- a/packages/studio/src/player/components/useTimelineActiveClips.test.ts +++ b/packages/studio/src/player/components/useTimelineActiveClips.test.ts @@ -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 }); -describe("updateTimelineActiveClipClasses", () => { - it("toggles data-active only for clips containing the current time", () => { - 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(); +function clip(id: string, start: number, duration: number, hidden = false): TimelineElement { + return { id, tag: "div", start, duration, track: 1, hidden }; +} - updateTimelineActiveClipClasses(container, previous, 2.25); +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; +} - 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"])); +function Harness({ version, heroStart = 2 }: { version: number; heroStart?: number }) { + const scrollRef = useRef(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), + }), + ); +} - 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(); - - updateTimelineActiveClipClasses(container, previous, 2); - - expect(hidden.hasAttribute("data-active")).toBe(false); - expect(visible.hasAttribute("data-active")).toBe(true); - expect(previous).toEqual(new Set(["visible"])); +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("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("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("keeps a clip active through its inclusive end boundary", () => { + 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 previous = new Set(); - 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); + updateTimelineActiveClipClasses(container, previous, 2.25); expect(intro.hasAttribute("data-active")).toBe(false); - expect(previous).toEqual(new Set()); + expect(hero.hasAttribute("data-active")).toBe(true); + expect(previous).toEqual(new Set(["hero"])); }); - 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(); 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(); + 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 }))); + + const intro = host.querySelector('[data-el-id="intro"]'); + const hero = host.querySelector('[data-el-id="hero"]'); + expect(intro?.hasAttribute("data-active")).toBe(true); + expect(hero?.hasAttribute("data-active")).toBe(false); + + act(() => liveTime.notify(2.5)); + expect(intro?.hasAttribute("data-active")).toBe(false); + expect(hero?.hasAttribute("data-active")).toBe(true); - updateTimelineActiveClipClasses(container, previous, 1); + 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); - expect(missingId.hasAttribute("data-active")).toBe(false); - expect(missingTiming.hasAttribute("data-active")).toBe(false); - expect(previous).toEqual(new Set()); + await act(async () => root.unmount()); + host.remove(); }); }); diff --git a/packages/studio/src/player/components/useTimelineActiveClips.ts b/packages/studio/src/player/components/useTimelineActiveClips.ts index 071ed267e4..e0db293ce8 100644 --- a/packages/studio/src/player/components/useTimelineActiveClips.ts +++ b/packages/studio/src/player/components/useTimelineActiveClips.ts @@ -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 { @@ -14,6 +14,33 @@ interface UseTimelineActiveClipsInput { scrollRef: React.RefObject; currentTime: number; clipStateVersion: unknown; + elementStateVersion: unknown; +} + +function isTimelineIntervalActive( + interval: Pick, + 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 { - const recordsById = new Map(); - 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 { - const next = new Set(); - 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, right: Set): 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, 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, 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([]); - const recordsByIdRef = useRef(new Map()); const previousActiveIdsRef = useRef(new Set()); - 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; - }); + }), + ); } diff --git a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts new file mode 100644 index 0000000000..01522bd49e --- /dev/null +++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts @@ -0,0 +1,87 @@ +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[0]; + viewport: TimelineScrollViewportSnapshot; + pixelsPerSecond: number; + contentOrigin: number; + duration: number; + selectedElementId?: string; + draggedElementId?: string; + resizingElementId?: string; + revealElementId?: string; + focusedEaseElementId?: string; + clipContextMenuElementId?: string; + keyframeContextMenuElementId?: string; + scrollRef: RefObject; + elements: readonly TimelineElement[]; + rowGeometry: TimelineRowGeometry; + allowHorizontalReveal: boolean; + sessionEpoch: number; +} + +export function useTimelineClipRenderWindow({ + tracks, + viewport, + pixelsPerSecond, + contentOrigin, + duration, + selectedElementId, + draggedElementId, + resizingElementId, + revealElementId, + focusedEaseElementId, + clipContextMenuElementId, + keyframeContextMenuElementId, + scrollRef, + elements, + rowGeometry, + allowHorizontalReveal, + 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, + ].filter((identity): identity is string => identity !== undefined), + ), + [ + clipContextMenuElementId, + draggedElementId, + focusedEaseElementId, + keyframeContextMenuElementId, + resizingElementId, + revealElementId, + selectedElementId, + ], + ); + useTimelineRevealClip({ + scrollRef, + elements, + rowGeometry, + pixelsPerSecond, + contentOrigin, + allowHorizontal: allowHorizontalReveal, + viewportVersion: viewport, + sessionEpoch, + }); + return { clipIndex, renderTimeRange, pinnedClipIdentities }; +} diff --git a/packages/studio/src/player/components/useTimelineRevealClip.test.tsx b/packages/studio/src/player/components/useTimelineRevealClip.test.tsx new file mode 100644 index 0000000000..43b812d3be --- /dev/null +++ b/packages/studio/src/player/components/useTimelineRevealClip.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom + +import React, { act, useRef } from "react"; +import { createRoot } 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 Harness({ mounted, version }: { mounted: boolean; version: number }) { + const scrollRef = useRef(null); + useTimelineRevealClip({ + scrollRef, + elements: [element], + rowGeometry: geometry, + pixelsPerSecond: 100, + contentOrigin: 32, + allowHorizontal: true, + viewportVersion: version, + sessionEpoch: 1, + }); + return ( +
{ + scrollRef.current = node; + if (node) { + Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 }); + Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 }); + } + }} + > + {mounted &&
} +
+ ); +} + +afterEach(() => { + usePlayerStore.getState().reset(); + document.body.replaceChildren(); +}); + +describe("useTimelineRevealClip", () => { + it("scrolls from model coordinates, then consumes only after the clip mounts", async () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + usePlayerStore.getState().requestClipReveal("hero"); + + await act(async () => root.render()); + const scroll = host.firstElementChild as HTMLDivElement; + expect(scroll.scrollLeft).toBe(1_944); + expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero"); + + await act(async () => root.render()); + 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 act(async () => root.render()); + 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 host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + usePlayerStore.getState().requestClipReveal("missing"); + await act(async () => root.render()); + expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); + await act(async () => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineRevealClip.ts b/packages/studio/src/player/components/useTimelineRevealClip.ts index 1c67654b24..b507c15047 100644 --- a/packages/studio/src/player/components/useTimelineRevealClip.ts +++ b/packages/studio/src/player/components/useTimelineRevealClip.ts @@ -1,56 +1,144 @@ -/** - * 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 { CLIP_Y, RULER_H, type TimelineRowGeometry } from "./timelineLayout"; import { computeRevealScroll } from "./timelineRevealScroll"; -export function useTimelineRevealClip(scrollRef: React.RefObject): void { - const revealRequest = usePlayerStore((s) => s.clipRevealRequest); +interface UseTimelineRevealClipInput { + scrollRef: React.RefObject; + elements: readonly TimelineElement[]; + rowGeometry: TimelineRowGeometry; + pixelsPerSecond: number; + contentOrigin: number; + allowHorizontal: boolean; + viewportVersion: unknown; + sessionEpoch: number; +} - 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(); +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, +): void { + 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; + if (target.left !== null || target.top !== null) container.dispatchEvent(new Event("scroll")); +} + +function focusRevealedElement(container: HTMLDivElement, elementId: string): boolean { + const clip = container.querySelector(`[data-el-id="${escapeSelectorValue(elementId)}"]`); + if (!(clip instanceof HTMLElement)) return false; + clip.setAttribute("data-reveal-highlight", "true"); + clip.focus({ preventScroll: true }); + if (document.activeElement !== clip) { + clip.removeAttribute("data-reveal-highlight"); + return false; + } + clip.addEventListener("blur", () => clip.removeAttribute("data-reveal-highlight"), { + once: true, + }); + return true; +} +function resolveRevealTarget( + elements: readonly TimelineElement[], + rowGeometry: TimelineRowGeometry, + elementId: string, +): { element: TimelineElement; row: number } | null { + const element = elements.find((candidate) => (candidate.key ?? candidate.id) === 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, + 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) { + 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; - - const containerRect = container.getBoundingClientRect(); - const clipRect = clip.getBoundingClientRect(); - const clipLeft = clipRect.left - containerRect.left + container.scrollLeft; - const clipTop = clipRect.top - containerRect.top + container.scrollTop; - - 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]); + + if (shouldScrollReveal(scrolledRequestRef.current, revealRequest, sessionEpoch)) { + scrolledRequestRef.current = { request: revealRequest, sessionEpoch }; + scrollToTimelineElement( + container, + target.element, + target.row, + rowGeometry, + pixelsPerSecond, + contentOrigin, + allowHorizontal, + ); + } + + if (!focusRevealedElement(container, revealRequest.elementId)) return; + if (usePlayerStore.getState().clipRevealRequest === revealRequest) { + usePlayerStore.getState().clearClipRevealRequest(); + } + }, [ + allowHorizontal, + contentOrigin, + elements, + pixelsPerSecond, + revealRequest, + rowGeometry, + scrollRef, + sessionEpoch, + viewportVersion, + ]); } diff --git a/packages/studio/src/player/components/useTimelineTicks.ts b/packages/studio/src/player/components/useTimelineTicks.ts index 86c1caad56..412a05384c 100644 --- a/packages/studio/src/player/components/useTimelineTicks.ts +++ b/packages/studio/src/player/components/useTimelineTicks.ts @@ -1,16 +1,21 @@ import { useMemo } from "react"; import { STUDIO_PREVIEW_FPS } from "../lib/time"; -import { generateTicks } from "./timelineLayout"; +import { generateTicks, getTimelineMajorTickInterval } from "./timelineLayout"; +import type { TimelineTimeRange } from "../lib/timelineClipIndex"; export function useTimelineTicks( duration: number, pixelsPerSecond: number, timeDisplayMode: "time" | "frame", -): { major: number[]; minor: number[] } { + renderTimeRange?: TimelineTimeRange, +) { const frameRate = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined; - const { major, minor } = useMemo( - () => generateTicks(duration, pixelsPerSecond, frameRate), - [duration, frameRate, pixelsPerSecond], + const ticks = useMemo( + () => generateTicks(duration, pixelsPerSecond, frameRate, renderTimeRange), + [duration, frameRate, pixelsPerSecond, renderTimeRange], ); - return { major, minor }; + return { + ...ticks, + majorTickInterval: getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate), + }; } diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts index 45db5c9fcc..6c90597f6d 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts @@ -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,6 +41,7 @@ function renderTimelinePlayerHarness() { } afterEach(() => { + setTimelinePerformanceFixtureLease(false); document.body.innerHTML = ""; resetPlayerStore(); }); @@ -73,6 +75,8 @@ function attachIframeAdapter( value: { __player: adapter, __timelines: options.timelines, + // Shared iframe contract is repeated here to keep this helper's adapter state local. + // fallow-ignore-next-line code-duplication postMessage: options.postMessage ?? (() => {}), scrollTo: () => {}, addEventListener: () => {}, @@ -129,6 +133,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) => { @@ -364,6 +423,8 @@ describe("useTimelinePlayer RAF loop wrap-around", () => { Object.defineProperty(iframe, "contentWindow", { value: { __player: adapter, + // This playback-specific adapter intentionally repeats the shared iframe contract. + // fallow-ignore-next-line code-duplication postMessage: () => {}, scrollTo: () => {}, addEventListener: () => {}, diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 67d2f2cccf..7f8b587a58 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -70,6 +70,8 @@ export function useTimelinePlayer() { // 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(); @@ -473,6 +475,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) {