From d91f56dce10a742e102db05782cc2ab7da7cc3b1 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 15:32:15 -0700 Subject: [PATCH 01/64] fix(timeline): tighten core editing interactions (cherry picked from commit c022a1567ac136b76b15393312a15b8adce51ab3) --- .../components/timeline-content.test.tsx | 17 +- .../timeline/components/timeline-content.tsx | 65 +++--- ...se-timeline-item-pointer-handlers.test.tsx | 35 +++ .../use-timeline-item-pointer-handlers.ts | 34 ++- .../use-clipboard-shortcuts.test.tsx | 208 ++++++++++++++++++ .../shortcuts/use-clipboard-shortcuts.ts | 173 ++++++++++----- .../shortcuts/use-playback-shortcuts.test.tsx | 160 ++++++++++++++ .../hooks/shortcuts/use-playback-shortcuts.ts | 17 +- 8 files changed, 593 insertions(+), 116 deletions(-) create mode 100644 src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx create mode 100644 src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx diff --git a/src/features/timeline/components/timeline-content.test.tsx b/src/features/timeline/components/timeline-content.test.tsx index e77af0adf..56600548a 100644 --- a/src/features/timeline/components/timeline-content.test.tsx +++ b/src/features/timeline/components/timeline-content.test.tsx @@ -992,34 +992,23 @@ describe('TimelineContent playback selection behavior', () => { expect(usePlaybackStore.getState().previewFrame).toBe(24) }) - it('moves the playhead to the click coordinate and clears the gray hover skimmer', () => { + it('commits the hover preview when the timeline body is clicked', () => { const { container } = render() act(() => { usePlaybackStore.getState().setCurrentFrame(90) usePlaybackStore.getState().setPreviewFrame(24) + usePlaybackStore.getState().play() }) const track = container.querySelector(`[data-track-id="${VIDEO_TRACK.id}"]`) - const scrollContainer = container.querySelector('[data-timeline-scroll-container]') expect(track).toBeTruthy() - expect(scrollContainer).toBeTruthy() - vi.spyOn(scrollContainer!, 'getBoundingClientRect').mockReturnValue({ - x: 0, - y: 0, - left: 0, - top: 0, - right: 400, - bottom: 200, - width: 400, - height: 200, - toJSON: () => ({}), - } as DOMRect) fireEvent.click(track!, { button: 0, clientX: 80, clientY: 100 }) expect(usePlaybackStore.getState().currentFrame).toBe(24) expect(usePlaybackStore.getState().previewFrame).toBeNull() + expect(usePlaybackStore.getState().isPlaying).toBe(false) }) it('seeks from a clip-body click even when the clip stops bubble propagation', () => { diff --git a/src/features/timeline/components/timeline-content.tsx b/src/features/timeline/components/timeline-content.tsx index c21ac4050..a5f969d61 100644 --- a/src/features/timeline/components/timeline-content.tsx +++ b/src/features/timeline/components/timeline-content.tsx @@ -9,6 +9,7 @@ import { useTimelineSettingsStore } from '../stores/timeline-settings-store' import { useTimelineViewportStore } from '../stores/timeline-viewport-store' import { registerZoomTo100, useZoomStore } from '../stores/zoom-store' import { usePlaybackStore } from '@/shared/state/playback' +import { isMicRecordingActive, useMicRecordingStore } from '@/shared/state/mic-recording-store' import { useEditorStore } from '@/shared/state/editor' import { useSelectionStore } from '@/shared/state/selection' @@ -1313,38 +1314,8 @@ export const TimelineContent = memo(function TimelineContent({ } }, []) - const handleTimelineClickCapture = useCallback((e: React.MouseEvent) => { - if (e.button !== 0) return - if (marqueeWasActiveRef.current || dragWasActiveRef.current || scrubWasActiveRef.current) { - return - } - - const target = e.target as HTMLElement - if ( - useSelectionStore.getState().activeTool === 'razor' || - target.closest('button, input, [role="slider"], [role="menuitem"]') - ) { - return - } - - const container = containerRef.current - const rect = container?.getBoundingClientRect() - if (!container || !rect) return - - const frame = Math.max( - 0, - Math.min( - Math.round(pixelsToFrameRef.current(e.clientX - rect.left + container.scrollLeft)), - maxTimelineFrameRef.current, - ), - ) - const playback = usePlaybackStore.getState() - playback.pause() - playback.setCurrentFrame(frame) - playback.setPreviewFrame(null) - }, []) - - // Click empty space to deselect items and markers (but preserve track selection). + // Commit the hover skimmer on a normal timeline click. Ruler clicks own their + // own scrub path, while drag/marquee/razor gestures must not move playback. const handleContainerClick = (e: React.MouseEvent) => { if (marqueeWasActiveRef.current || dragWasActiveRef.current || scrubWasActiveRef.current) { return @@ -1358,9 +1329,37 @@ export const TimelineContent = memo(function TimelineContent({ return } - // Deselect items and markers if NOT clicking on a timeline item const clickedOnItem = target.closest('[data-item-id]') + const clickedOnTrack = target.closest('[data-track-id]') + + if ( + clickedOnTrack && + useSelectionStore.getState().activeTool !== 'razor' && + !isMicRecordingActive(useMicRecordingStore.getState().status) + ) { + const playback = usePlaybackStore.getState() + const container = containerRef.current + const frame = + playback.previewFrame ?? + (container + ? Math.max( + 0, + Math.min( + Math.round( + pixelsToFrameRef.current( + e.clientX - container.getBoundingClientRect().left + container.scrollLeft, + ), + ), + maxTimelineFrameRef.current, + ), + ) + : playback.currentFrame) + playback.pause() + playback.setPreviewFrame(null) + playback.setCurrentFrame(frame) + } + // Deselect items and markers if NOT clicking on a timeline item. if (!clickedOnItem) { clearItemSelection() selectMarker(null) // Also clear marker selection diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx index 0f35021e6..1d82ba306 100644 --- a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx +++ b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import type { CompositionItem, TextItem, VideoItem } from '@/types/timeline' import { useSelectionStore } from '@/shared/state/selection' import { useEditorStore } from '@/shared/state/editor' +import { usePlaybackStore } from '@/shared/state/playback' +import { useMicRecordingStore } from '@/shared/state/mic-recording-store' import { useSourcePlayerStore } from '@/shared/state/source-player' import { useTimelineStore } from '../../stores/timeline-store' import { useCompositionNavigationStore } from '../../stores/composition-navigation-store' @@ -114,6 +116,13 @@ describe('useTimelineItemPointerHandlers', () => { vi.clearAllMocks() // Deterministic single-item selection (linked selection expands target ids) useEditorStore.getState().setLinkedSelectionEnabled(false) + usePlaybackStore.setState({ + currentFrame: 0, + previewFrame: null, + previewItemId: null, + isPlaying: false, + }) + useMicRecordingStore.setState({ status: 'idle' }) }) afterEach(() => { @@ -141,6 +150,32 @@ describe('useTimelineItemPointerHandlers', () => { expect(selectItems).toHaveBeenCalledWith(['item-1']) }) + it('commits the hover preview when selecting a clip', () => { + usePlaybackStore.setState({ currentFrame: 0, previewFrame: 34, isPlaying: true }) + const handlers = renderHandlers(makeInput({ activeTool: 'select' })) + + handlers.handleClick(makeMouseEvent()) + + expect(usePlaybackStore.getState().currentFrame).toBe(34) + expect(usePlaybackStore.getState().previewFrame).toBeNull() + expect(usePlaybackStore.getState().isPlaying).toBe(false) + }) + + it('selects without seeking or pausing while a microphone take is active', () => { + usePlaybackStore.setState({ currentFrame: 12, previewFrame: 34, isPlaying: true }) + useMicRecordingStore.setState({ status: 'recording' }) + const handlers = renderHandlers(makeInput({ activeTool: 'select' })) + + handlers.handleClick(makeMouseEvent()) + + expect(useSelectionStore.getState().selectedItemIds).toEqual(['item-1']) + expect(usePlaybackStore.getState()).toMatchObject({ + currentFrame: 12, + previewFrame: 34, + isPlaying: true, + }) + }) + it('splits the item at the cursor with the razor tool', () => { const splitItem = vi.spyOn(useTimelineStore.getState(), 'splitItem') const handlers = renderHandlers(makeInput({ activeTool: 'razor' })) diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts index 173d19663..3451d820b 100644 --- a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts +++ b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts @@ -1,7 +1,8 @@ import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react' import type { TimelineItem as TimelineItemType } from '@/types/timeline' import type { SelectionState } from '@/shared/state/selection' -import { usePlaybackStore } from '@/shared/state/playback' +import { commitPreviewFrameToCurrentFrame, usePlaybackStore } from '@/shared/state/playback' +import { isMicRecordingActive, useMicRecordingStore } from '@/shared/state/mic-recording-store' import { useEditorStore } from '@/shared/state/editor' import { useSourcePlayerStore } from '@/shared/state/source-player' import { useSelectionStore } from '@/shared/state/selection' @@ -156,6 +157,27 @@ export function useTimelineItemPointerHandlers({ return } + // Clip clicks stop propagation for selection, so they must explicitly + // commit the transient hover skimmer just like a timeline-body click. + if (!isMicRecordingActive(useMicRecordingStore.getState().status)) { + const playback = usePlaybackStore.getState() + playback.pause() + if (playback.previewFrame !== null) { + commitPreviewFrameToCurrentFrame() + } else { + const rect = e.currentTarget.getBoundingClientRect() + const relativeX = Math.max(0, Math.min(e.clientX - rect.left, rect.width)) + const frameOffset = + rect.width > 0 + ? Math.min( + item.durationInFrames - 1, + Math.floor((relativeX / rect.width) * item.durationInFrames), + ) + : 0 + playback.setCurrentFrame(Math.max(0, item.from + frameOffset)) + } + } + if (activeToolRef.current === 'select' || activeToolRef.current === 'trim-edit') { const bridgedHandle = smartTrimIntentToHandle(smartTrimIntentRef.current) if (bridgedHandle) { @@ -193,7 +215,15 @@ export function useTimelineItemPointerHandlers({ selectItems(targetIds) } }, - [activeToolRef, dragWasActiveRef, trackLocked, item.from, item.id, smartTrimIntentRef], + [ + activeToolRef, + dragWasActiveRef, + trackLocked, + item.durationInFrames, + item.from, + item.id, + smartTrimIntentRef, + ], ) // Double-click: open media in source monitor with clip's source range as I/O diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx new file mode 100644 index 000000000..3096f34ed --- /dev/null +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx @@ -0,0 +1,208 @@ +import { act, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { HOTKEYS } from '@/config/hotkeys' +import { useClipboardStore } from '@/shared/state/clipboard' +import { useSelectionStore } from '@/shared/state/selection' +import type { AudioItem, TimelineItem, TimelineTrack, VideoItem } from '@/types/timeline' +import { useCompositionNavigationStore } from '../../stores/composition-navigation-store' +import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' +import { useTimelineStore } from '../../stores/timeline-store' +import { useClipboardShortcuts } from './use-clipboard-shortcuts' + +const { addItemsMock, playbackState, useHotkeysMock } = vi.hoisted(() => ({ + addItemsMock: vi.fn(), + playbackState: { currentFrame: 200 }, + useHotkeysMock: vi.fn(), +})) + +vi.mock('react-hotkeys-hook', () => ({ + useHotkeys: useHotkeysMock, +})) + +vi.mock('@/shared/state/playback', () => ({ + usePlaybackStore: { + getState: () => playbackState, + }, +})) + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + }, +})) + +vi.mock('../../stores/timeline-actions', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + addItems: addItemsMock, + } +}) + +const TARGET_TRACK: TimelineTrack = { + id: 'target-track', + name: 'V1', + kind: 'video', + order: 0, + height: 80, + locked: false, + visible: true, + muted: false, + solo: false, + items: [], +} + +const AUDIO_TRACK: TimelineTrack = { + ...TARGET_TRACK, + id: 'target-audio', + name: 'A1', + kind: 'audio', + order: 1, +} + +function makeVideoItem(overrides: Partial = {}): VideoItem { + return { + id: 'clip-1', + type: 'video', + trackId: TARGET_TRACK.id, + from: 0, + durationInFrames: 10, + label: 'Clip', + src: 'clip.mp4', + ...overrides, + } +} + +function makeAudioItem(overrides: Partial = {}): AudioItem { + return { + id: 'audio-1', + type: 'audio', + trackId: AUDIO_TRACK.id, + from: 0, + durationInFrames: 10, + label: 'Audio', + src: 'clip.mp4', + ...overrides, + } +} + +function ShortcutHarness() { + useClipboardShortcuts() + return null +} + +type HotkeyCallback = (event: { preventDefault: () => void }) => void + +function getPasteCallback(): HotkeyCallback { + const registration = useHotkeysMock.mock.calls.find(([keys]) => keys === HOTKEYS.PASTE) + expect(registration).toBeDefined() + return registration?.[1] as HotkeyCallback +} + +function getPlannedItems(): TimelineItem[] { + expect(addItemsMock).toHaveBeenCalledTimes(1) + return addItemsMock.mock.calls[0]?.[0] as TimelineItem[] +} + +describe('useClipboardShortcuts paste placement', () => { + beforeEach(() => { + addItemsMock.mockClear() + useHotkeysMock.mockClear() + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0) + + useTimelineStore.setState({ + tracks: [TARGET_TRACK], + items: [], + transitions: [], + keyframes: [], + markers: [], + }) + useSelectionStore.setState({ + selectedItemIds: [], + selectedItemIdSet: new Set(), + selectedTransitionId: null, + activeTrackId: TARGET_TRACK.id, + }) + useKeyframeSelectionStore.setState({ + selectedKeyframes: [], + clipboard: null, + isCut: false, + }) + useCompositionNavigationStore.setState({ activeCompositionId: null }) + useClipboardStore.setState({ itemsClipboard: null, transitionClipboard: null }) + playbackState.currentFrame = 200 + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('anchors the earliest copied item at the playhead and preserves relative offsets', () => { + useClipboardStore + .getState() + .copyItems( + [ + makeVideoItem({ id: 'early', label: 'Early', from: 40 }), + makeVideoItem({ id: 'late', label: 'Late', from: 70 }), + ], + 0, + 'copy', + ) + + render() + act(() => getPasteCallback()({ preventDefault: vi.fn() })) + + expect(getPlannedItems().map((item) => ({ label: item.label, from: item.from }))).toEqual([ + { label: 'Early', from: 200 }, + { label: 'Late', from: 230 }, + ]) + }) + + it('checks already-planned pasted items when source tracks map to one target track', () => { + useClipboardStore + .getState() + .copyItems( + [ + makeVideoItem({ id: 'first', label: 'First', trackId: 'missing-v1', from: 40 }), + makeVideoItem({ id: 'second', label: 'Second', trackId: 'missing-v2', from: 45 }), + ], + 0, + 'copy', + ) + + render() + act(() => getPasteCallback()({ preventDefault: vi.fn() })) + + const plannedItems = getPlannedItems() + expect(plannedItems.map((item) => item.trackId)).toEqual([TARGET_TRACK.id, TARGET_TRACK.id]) + expect(plannedItems.map((item) => item.from)).toEqual([200, 210]) + expect(plannedItems[0]!.from + plannedItems[0]!.durationInFrames).toBeLessThanOrEqual( + plannedItems[1]!.from, + ) + }) + + it('moves a linked video/audio pair together when one target track collides', () => { + useTimelineStore.setState({ + tracks: [TARGET_TRACK, AUDIO_TRACK], + items: [makeVideoItem({ id: 'occupied', from: 200 })], + }) + useClipboardStore + .getState() + .copyItems( + [ + makeVideoItem({ id: 'video', from: 40, linkedGroupId: 'linked-source' }), + makeAudioItem({ id: 'audio', from: 40, linkedGroupId: 'linked-source' }), + ], + 0, + 'copy', + ) + + render() + act(() => getPasteCallback()({ preventDefault: vi.fn() })) + + const plannedItems = getPlannedItems() + expect(plannedItems.map((item) => item.from)).toEqual([210, 210]) + expect(plannedItems[0]!.linkedGroupId).toBeTruthy() + expect(plannedItems[1]!.linkedGroupId).toBe(plannedItems[0]!.linkedGroupId) + }) +}) diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts index d0f05df13..59d840b57 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts @@ -22,6 +22,72 @@ import { } from '../../utils/composition-graph' import { handleTranscriptClipboardCopy } from '../../utils/transcript-copy-bridge' +interface PastePlacementPlan { + itemData: Omit + targetTrackId: string + desiredFrom: number + sourceIndex: number +} + +function placementsOverlap( + left: { trackId: string; from: number; durationInFrames: number }, + right: { trackId: string; from: number; durationInFrames: number }, +): boolean { + return ( + left.trackId === right.trackId && + left.from < right.from + right.durationInFrames && + left.from + left.durationInFrames > right.from + ) +} + +function hasInternalPlacementOverlap(plans: PastePlacementPlan[]): boolean { + return plans.some((plan, index) => + plans.slice(index + 1).some((candidate) => + placementsOverlap( + { + trackId: plan.targetTrackId, + from: plan.desiredFrom, + durationInFrames: plan.itemData.durationInFrames, + }, + { + trackId: candidate.targetTrackId, + from: candidate.desiredFrom, + durationInFrames: candidate.itemData.durationInFrames, + }, + ), + ), + ) +} + +function findSharedPlacementShift( + plans: PastePlacementPlan[], + occupiedItems: TimelineItem[], +): number { + let shift = 0 + while (true) { + let requiredShift = 0 + for (const plan of plans) { + const from = plan.desiredFrom + shift + for (const occupied of occupiedItems) { + if ( + placementsOverlap( + { + trackId: plan.targetTrackId, + from, + durationInFrames: plan.itemData.durationInFrames, + }, + occupied, + ) + ) { + requiredShift = Math.max(requiredShift, occupied.from + occupied.durationInFrames - from) + } + } + } + if (requiredShift <= 0) return shift + shift += requiredShift + } +} + function revealPastedItems(itemIds: readonly string[]): void { if (itemIds.length === 0) { return @@ -206,42 +272,7 @@ export function useClipboardShortcuts() { // single-track copy still pastes onto the active track as before. const preserveSourceTracks = new Set(pasteItems.map((item) => item.trackId)).size > 1 - const findNextAvailableSpace = ( - trackId: string, - startFrame: number, - duration: number, - ): number => { - const trackItems = storeItems - .filter((item) => item.trackId === trackId) - .sort((a, b) => a.from - b.from) - - let candidateFrame = startFrame - - for (const item of trackItems) { - const itemEnd = item.from + item.durationInFrames - if (candidateFrame < itemEnd && candidateFrame + duration > item.from) { - candidateFrame = itemEnd - } - } - - return candidateFrame - } - - const hasSpaceAt = (trackId: string, startFrame: number, duration: number): boolean => { - const trackItems = storeItems.filter((item) => item.trackId === trackId) - for (const item of trackItems) { - const itemEnd = item.from + item.durationInFrames - if (startFrame < itemEnd && startFrame + duration > item.from) { - return false - } - } - return true - } - - for (const itemData of pasteItems) { - const newId = crypto.randomUUID() - newItemIds.push(newId) - + const placementPlans = pasteItems.map((itemData, sourceIndex): PastePlacementPlan => { let targetTrackId = preserveSourceTracks ? itemData.trackId : activeTrackId if (!targetTrackId || !tracks.some((t) => t.id === targetTrackId)) { targetTrackId = itemData.trackId @@ -250,33 +281,57 @@ export function useClipboardShortcuts() { if (!trackExists && tracks.length > 0) { targetTrackId = tracks[0]!.id } - - const desiredFrom = currentFrame - const duration = itemData.durationInFrames - - let newFrom: number - if (hasSpaceAt(targetTrackId, desiredFrom, duration)) { - newFrom = desiredFrom - } else { - newFrom = findNextAvailableSpace(targetTrackId, desiredFrom, duration) + return { + itemData, + targetTrackId, + desiredFrom: currentFrame + itemData.from, + sourceIndex, } + }) - const newItem = { - ...itemData, - id: newId, - from: newFrom, - trackId: targetTrackId, - originId: newId, - linkedGroupId: itemData.linkedGroupId - ? (linkedGroupMap.get(itemData.linkedGroupId) ?? - linkedGroupMap - .set(itemData.linkedGroupId, crypto.randomUUID()) - .get(itemData.linkedGroupId)) - : undefined, + // Keep an ordinary multi-item paste as one rigid block. If invalid or + // missing source tracks collapse overlapping items onto one target, + // fall back to linked groups/singletons so placement can still make + // progress without separating a valid linked A/V pair. + let placementGroups: PastePlacementPlan[][] = [placementPlans] + if (hasInternalPlacementOverlap(placementPlans)) { + const grouped = new Map() + for (const plan of placementPlans) { + const key = plan.itemData.linkedGroupId + ? `linked:${plan.itemData.linkedGroupId}` + : `item:${plan.sourceIndex}` + const group = grouped.get(key) ?? [] + group.push(plan) + grouped.set(key, group) } + placementGroups = [...grouped.values()] + } - newItems.push(newItem as TimelineItem) - usedTrackIds.add(targetTrackId) + const occupiedItems = [...storeItems] + for (const group of placementGroups) { + const sharedShift = findSharedPlacementShift(group, occupiedItems) + for (const plan of group) { + const { itemData, targetTrackId, desiredFrom } = plan + const newId = crypto.randomUUID() + newItemIds.push(newId) + const newItem = { + ...itemData, + id: newId, + from: desiredFrom + sharedShift, + trackId: targetTrackId, + originId: newId, + linkedGroupId: itemData.linkedGroupId + ? (linkedGroupMap.get(itemData.linkedGroupId) ?? + linkedGroupMap + .set(itemData.linkedGroupId, crypto.randomUUID()) + .get(itemData.linkedGroupId)) + : undefined, + } as TimelineItem + + newItems.push(newItem) + occupiedItems.push(newItem) + usedTrackIds.add(targetTrackId) + } } // Add every pasted item in a single ADD_ITEMS command so one Ctrl+Z diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx new file mode 100644 index 000000000..87abbe379 --- /dev/null +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx @@ -0,0 +1,160 @@ +import { act, render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { HOTKEYS } from '@/config/hotkeys' +import type { SourcePlayerMethods } from '@/shared/state/source-player/types' +import type { VideoItem } from '@/types/timeline' +import { usePlaybackShortcuts } from './use-playback-shortcuts' + +const { itemsState, playbackState, sourcePlayerState, useHotkeysMock } = vi.hoisted(() => { + const playbackState = { + currentFrame: 0, + isPlaying: false, + togglePlayPause: vi.fn(), + shuttleForward: vi.fn(), + shuttleReverse: vi.fn(), + pause: vi.fn(), + setCurrentFrame: vi.fn((frame: number) => { + playbackState.currentFrame = frame + }), + setPreviewFrame: vi.fn(), + } + + return { + itemsState: { items: [] as Array<{ from: number; durationInFrames: number }> }, + playbackState, + sourcePlayerState: { + hoveredPanel: null as 'source' | null, + playerMethods: null as SourcePlayerMethods | null, + }, + useHotkeysMock: vi.fn(), + } +}) + +vi.mock('react-hotkeys-hook', () => ({ + useHotkeys: useHotkeysMock, +})) + +vi.mock('@/features/timeline/deps/settings', () => ({ + useResolvedHotkeys: () => ({ + PLAY_PAUSE: 'space', + PREVIOUS_FRAME: 'left', + NEXT_FRAME: 'right', + GO_TO_START: 'home', + GO_TO_END: 'end', + NEXT_SNAP_POINT: 'down', + PREVIOUS_SNAP_POINT: 'up', + }), +})) + +vi.mock('@/shared/state/playback', () => ({ + usePlaybackStore: Object.assign( + (selector: (state: typeof playbackState) => unknown) => selector(playbackState), + { getState: () => playbackState }, + ), +})) + +vi.mock('@/shared/state/preview-bridge', () => ({ + usePreviewBridgeStore: (selector: (state: { setDisplayedFrame: () => void }) => unknown) => + selector({ setDisplayedFrame: vi.fn() }), +})) + +vi.mock('@/shared/state/source-player', () => ({ + useSourcePlayerStore: { + getState: () => sourcePlayerState, + }, +})) + +vi.mock('../../stores/items-store', () => ({ + useItemsStore: { + getState: () => itemsState, + }, +})) + +type HotkeyCallback = (event: { preventDefault: () => void }) => void + +function makeVideoItem(overrides: Partial = {}): VideoItem { + return { + id: 'clip-1', + type: 'video', + trackId: 'track-1', + from: 10, + durationInFrames: 5, + label: 'Clip', + src: 'clip.mp4', + ...overrides, + } +} + +function ShortcutHarness() { + usePlaybackShortcuts({}) + return null +} + +function getHotkeyCallback(binding: string): HotkeyCallback { + const registration = useHotkeysMock.mock.calls.find(([keys]) => keys === binding) + expect(registration).toBeDefined() + return registration?.[1] as HotkeyCallback +} + +function trigger(callback: HotkeyCallback) { + act(() => callback({ preventDefault: vi.fn() })) +} + +describe('usePlaybackShortcuts frame boundaries', () => { + beforeEach(() => { + vi.clearAllMocks() + playbackState.currentFrame = 0 + playbackState.isPlaying = false + itemsState.items = [ + makeVideoItem(), + makeVideoItem({ id: 'clip-2', from: 0, durationInFrames: 7 }), + ] + sourcePlayerState.hoveredPanel = null + sourcePlayerState.playerMethods = null + }) + + it('clamps timeline ArrowRight to the final valid frame', () => { + playbackState.currentFrame = 13 + render() + + const nextFrame = getHotkeyCallback(HOTKEYS.NEXT_FRAME) + trigger(nextFrame) + expect(playbackState.currentFrame).toBe(14) + + trigger(nextFrame) + expect(playbackState.currentFrame).toBe(14) + }) + + it('seeks timeline End to the maximum inclusive item frame, or zero when empty', () => { + render() + + const goToEnd = getHotkeyCallback(HOTKEYS.GO_TO_END) + trigger(goToEnd) + expect(playbackState.currentFrame).toBe(14) + + itemsState.items = [] + trigger(goToEnd) + expect(playbackState.currentFrame).toBe(0) + }) + + it('clamps source-player End to a nonnegative frame', () => { + const playerMethods: SourcePlayerMethods = { + toggle: vi.fn(), + pause: vi.fn(), + isPlaying: vi.fn(() => false), + shuttleForward: vi.fn(), + shuttleReverse: vi.fn(), + seek: vi.fn(), + frameBack: vi.fn(), + frameForward: vi.fn(), + getDurationInFrames: vi.fn(() => 0), + } + sourcePlayerState.hoveredPanel = 'source' + sourcePlayerState.playerMethods = playerMethods + render() + + trigger(getHotkeyCallback(HOTKEYS.GO_TO_END)) + + expect(playerMethods.seek).toHaveBeenCalledWith(0) + }) +}) diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts index aaa25969f..5dbccd54b 100644 --- a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts @@ -34,6 +34,12 @@ function getSnapPoints(): number[] { return Array.from(points).sort((a, b) => a - b) } +function getFinalTimelineFrame(): number { + return useItemsStore + .getState() + .items.reduce((maxFrame, item) => Math.max(maxFrame, item.from + item.durationInFrames - 1), 0) +} + export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { const hotkeys = useResolvedHotkeys() const togglePlayPause = usePlaybackStore((s) => s.togglePlayPause) @@ -170,7 +176,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { return } const currentFrame = usePlaybackStore.getState().currentFrame - commitTimelineSeek(currentFrame + 1) + commitTimelineSeek(Math.min(currentFrame + 1, getFinalTimelineFrame())) }, HOTKEY_OPTIONS, [commitTimelineSeek], @@ -199,15 +205,10 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() if (hoveredPanel === 'source' && playerMethods) { - playerMethods.seek(playerMethods.getDurationInFrames() - 1) + playerMethods.seek(Math.max(0, playerMethods.getDurationInFrames() - 1)) return } - const currentItems = useItemsStore.getState().items - const lastFrame = currentItems.reduce((max, item) => { - const itemEnd = item.from + item.durationInFrames - return Math.max(max, itemEnd) - }, 0) - commitTimelineSeek(lastFrame) + commitTimelineSeek(getFinalTimelineFrame()) }, HOTKEY_OPTIONS, [commitTimelineSeek], From 41418e625b18533d3d152ca7f8e68672bf1b0631 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 15:32:18 -0700 Subject: [PATCH 02/64] fix(playback): harden preview and direct export (cherry picked from commit 10f775884012079d72e3b5556ee05362c8732251) --- .../export/components/export-dialog.test.tsx | 81 +++++++++++++++++++ .../export/components/export-dialog.tsx | 9 +-- .../export/hooks/client-render-source.test.ts | 51 ++++++++++++ .../export/hooks/client-render-source.ts | 43 ++++++++++ .../export/hooks/use-client-render.ts | 33 +++++--- .../workers/consume-video-samples.test.ts | 40 +++++++++ .../preview/workers/consume-video-samples.ts | 32 ++++++++ .../preview/workers/decoder-prewarm-worker.ts | 49 +++-------- .../gpu-shapes/shape-render-pipeline.ts | 10 +-- 9 files changed, 290 insertions(+), 58 deletions(-) create mode 100644 src/features/export/hooks/client-render-source.test.ts create mode 100644 src/features/export/hooks/client-render-source.ts create mode 100644 src/features/preview/workers/consume-video-samples.test.ts create mode 100644 src/features/preview/workers/consume-video-samples.ts diff --git a/src/features/export/components/export-dialog.test.tsx b/src/features/export/components/export-dialog.test.tsx index 4b712d11d..406d51a1b 100644 --- a/src/features/export/components/export-dialog.test.tsx +++ b/src/features/export/components/export-dialog.test.tsx @@ -9,6 +9,69 @@ const mockDownloadVideo = vi.fn() const mockResetState = vi.fn() const mockGetSupportedCodecs = vi.fn<(...args: unknown[]) => Promise>() +const { mainSequence, selectedSequence, mockGetExportableSequence } = vi.hoisted(() => { + const sequence = (id: string | null, name: string, itemId: string) => { + const trackId = `track-${itemId}` + const item = { + id: itemId, + trackId, + type: 'text' as const, + from: 0, + durationInFrames: 30, + label: name, + text: name, + color: '#ffffff', + } + return { + id, + name, + tracks: [ + { + id: trackId, + name: 'V1', + kind: 'video' as const, + height: 60, + locked: false, + visible: true, + muted: false, + solo: false, + order: 0, + items: [item], + }, + ], + items: [item], + transitions: [], + keyframes: [], + fps: 30, + width: 1920, + height: 1080, + backgroundColor: '#000000', + masterBusDb: 0, + durationFrames: 30, + inPoint: null, + outPoint: null, + markers: [], + } + } + + const main = sequence(null, 'Main Timeline', 'main-title') + const selected = sequence('agent-cut', 'Agent Cut', 'agent-title') + return { + mainSequence: main, + selectedSequence: selected, + mockGetExportableSequence: vi.fn((id: string | null) => (id === selected.id ? selected : main)), + } +}) + +vi.mock('@/features/export/deps/timeline-compositions', () => ({ + getActiveExportSequenceId: () => null, + getExportableSequence: mockGetExportableSequence, + listExportableSequences: () => [ + { id: null, name: mainSequence.name }, + { id: selectedSequence.id, name: selectedSequence.name }, + ], +})) + vi.mock('../hooks/use-client-render', () => ({ useClientRender: () => ({ isExporting: false, @@ -115,4 +178,22 @@ describe('ExportDialog', () => { const h265Option = await screen.findByRole('option', { name: /H\.265/i }) expect(h265Option).toHaveAttribute('data-disabled') }) + + it('passes the selected sequence snapshot to direct export', async () => { + mockGetSupportedCodecs.mockResolvedValue(['avc']) + mockStartExport.mockResolvedValue(undefined) + + render( {}} />) + + fireEvent.keyDown(screen.getByLabelText('Sequence'), { key: 'ArrowDown' }) + fireEvent.click(await screen.findByRole('option', { name: selectedSequence.name })) + + const exportButton = screen.getByRole('button', { name: 'Export Video' }) + await waitFor(() => expect(exportButton).not.toBeDisabled()) + fireEvent.click(exportButton) + + await waitFor(() => { + expect(mockStartExport).toHaveBeenCalledWith(expect.any(Object), selectedSequence) + }) + }) }) diff --git a/src/features/export/components/export-dialog.tsx b/src/features/export/components/export-dialog.tsx index 1b0332335..e64ce7b7b 100644 --- a/src/features/export/components/export-dialog.tsx +++ b/src/features/export/components/export-dialog.tsx @@ -394,8 +394,7 @@ export function ExportDialog({ open, onClose, onOpenRenderQueue }: ExportDialogP const reversedClipIds = new Set( items .filter( - (item) => - (item.type === 'video' || item.type === 'audio') && item.isReversed === true, + (item) => (item.type === 'video' || item.type === 'audio') && item.isReversed === true, ) .map((item) => item.id), ) @@ -643,8 +642,9 @@ export function ExportDialog({ open, onClose, onOpenRenderQueue }: ExportDialogP // Start export const handleStartExport = async () => { + const seq = captureSelection() setView('progress') - await startExport(buildExtendedSettings()) + await startExport(buildExtendedSettings(), seq) } // The active render range for a sequence (whole timeline unless in/out set). @@ -1574,8 +1574,7 @@ export function ExportDialog({ open, onClose, onOpenRenderQueue }: ExportDialogP
- {status === 'preparing' && - (progressMessage ?? t('export.progress.preparing'))} + {status === 'preparing' && (progressMessage ?? t('export.progress.preparing'))} {status === 'rendering' && t('export.progress.rendering')} {status === 'encoding' && t('export.progress.encoding')} {status === 'finalizing' && t('export.progress.finalizing')} diff --git a/src/features/export/hooks/client-render-source.test.ts b/src/features/export/hooks/client-render-source.test.ts new file mode 100644 index 000000000..0105c64b6 --- /dev/null +++ b/src/features/export/hooks/client-render-source.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vite-plus/test' +import type { ExportableSequence } from '@/features/export/deps/timeline-compositions' +import { resolveClientRenderSource } from './client-render-source' + +function makeSequence(overrides: Partial = {}): ExportableSequence { + return { + id: 'selected', + name: 'Selected', + tracks: [], + items: [], + transitions: [], + keyframes: [], + fps: 24, + width: 1280, + height: 720, + masterBusDb: -3, + durationFrames: 0, + inPoint: null, + outPoint: null, + markers: [], + ...overrides, + } +} + +describe('resolveClientRenderSource', () => { + it('preserves an explicitly unset selected-sequence range and EQ', () => { + const sequence = makeSequence({ busAudioEq: undefined, backgroundColor: undefined }) + const result = resolveClientRenderSource( + sequence, + makeSequence({ id: null, inPoint: 30, outPoint: 90 }), + { + busAudioEq: { enabled: true, lowGainDb: 4, midGainDb: 2, highGainDb: 3 }, + masterBusDb: 6, + }, + { width: 1920, height: 1080, backgroundColor: '#ff0000' }, + ) + + expect(result).toMatchObject({ + fps: 24, + inPoint: null, + outPoint: null, + busAudioEq: undefined, + masterBusDb: -3, + backgroundColor: undefined, + width: 1280, + height: 720, + }) + }) +}) diff --git a/src/features/export/hooks/client-render-source.ts b/src/features/export/hooks/client-render-source.ts new file mode 100644 index 000000000..23a9bde74 --- /dev/null +++ b/src/features/export/hooks/client-render-source.ts @@ -0,0 +1,43 @@ +import type { ExportableSequence } from '@/features/export/deps/timeline-compositions' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' + +type TimelineRenderSource = Pick< + ExportableSequence, + 'tracks' | 'items' | 'transitions' | 'fps' | 'inPoint' | 'outPoint' | 'keyframes' +> + +type PlaybackRenderSource = Pick + +interface ProjectRenderMetadata { + width?: number + height?: number + backgroundColor?: string +} + +/** + * Select one complete render source. Once a sequence snapshot is supplied, + * its nullable/optional values are authoritative too: an unset range or EQ + * must not inherit state from whichever timeline happens to be active. + */ +export function resolveClientRenderSource( + sequence: ExportableSequence | undefined, + timeline: TimelineRenderSource, + playback: PlaybackRenderSource, + projectMetadata: ProjectRenderMetadata | undefined, +) { + const source = sequence ?? timeline + return { + tracks: source.tracks, + items: source.items, + transitions: source.transitions, + fps: source.fps, + inPoint: source.inPoint, + outPoint: source.outPoint, + keyframes: source.keyframes, + busAudioEq: sequence ? sequence.busAudioEq : playback.busAudioEq, + masterBusDb: sequence ? sequence.masterBusDb : playback.masterBusDb, + backgroundColor: sequence ? sequence.backgroundColor : projectMetadata?.backgroundColor, + width: sequence?.width ?? projectMetadata?.width ?? DEFAULT_PROJECT_WIDTH, + height: sequence?.height ?? projectMetadata?.height ?? DEFAULT_PROJECT_HEIGHT, + } +} diff --git a/src/features/export/hooks/use-client-render.ts b/src/features/export/hooks/use-client-render.ts index aa2009645..188a3bf54 100644 --- a/src/features/export/hooks/use-client-render.ts +++ b/src/features/export/hooks/use-client-render.ts @@ -31,11 +31,13 @@ import { buildTranscriptSubtitleCues } from '../utils/embedded-subtitle-export' import { serializeSrt } from '@/shared/utils/subtitles' import { releaseTemporaryExportOutput } from '../utils/export-output-target' import { useTimelineStore } from '@/features/export/deps/timeline' +import type { ExportableSequence } from '@/features/export/deps/timeline-compositions' import { useProjectStore } from '@/features/export/deps/projects' import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import { resolveMediaUrls } from '@/features/export/deps/media-library' import { usePlaybackStore } from '@/shared/state/playback' import { createLogger, createOperationId } from '@/shared/logging/logger' +import { resolveClientRenderSource } from './client-render-source' const log = createLogger('Export') @@ -61,7 +63,10 @@ interface UseClientRenderReturn { result: ClientRenderResult | null // Actions - startExport: (settings: ExportSettings | ExtendedExportSettings) => Promise + startExport: ( + settings: ExportSettings | ExtendedExportSettings, + sequence?: ExportableSequence, + ) => Promise cancelExport: () => void downloadVideo: () => void resetState: () => void @@ -119,7 +124,7 @@ export function useClientRender(): UseClientRenderReturn { * Start client-side export */ const startExport = useCallback( - async (settings: ExportSettings | ExtendedExportSettings) => { + async (settings: ExportSettings | ExtendedExportSettings, sequence?: ExportableSequence) => { const opId = createOperationId() const event = log.startEvent('render', opId) @@ -139,16 +144,22 @@ export function useClientRender(): UseClientRenderReturn { // Read current state from stores const state = useTimelineStore.getState() - const { tracks, items, transitions, fps, inPoint, outPoint, keyframes } = state - - // Get project metadata (background color and native resolution) const currentProject = useProjectStore.getState().currentProject - const busAudioEq = usePlaybackStore.getState().busAudioEq - const masterBusDb = usePlaybackStore.getState().masterBusDb - const backgroundColor = currentProject?.metadata?.backgroundColor - // Use PROJECT resolution for composition (transform calculations match preview) - const projectWidth = currentProject?.metadata?.width ?? DEFAULT_PROJECT_WIDTH - const projectHeight = currentProject?.metadata?.height ?? DEFAULT_PROJECT_HEIGHT + const playback = usePlaybackStore.getState() + const { + tracks, + items, + transitions, + fps, + inPoint, + outPoint, + keyframes, + busAudioEq, + masterBusDb, + backgroundColor, + width: projectWidth, + height: projectHeight, + } = resolveClientRenderSource(sequence, state, playback, currentProject?.metadata) const requested = mapRequestedClientSettings(settings, fps) // When renderWholeProject is true, ignore in/out points. diff --git a/src/features/preview/workers/consume-video-samples.test.ts b/src/features/preview/workers/consume-video-samples.test.ts new file mode 100644 index 000000000..39ff34613 --- /dev/null +++ b/src/features/preview/workers/consume-video-samples.test.ts @@ -0,0 +1,40 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vite-plus/test' +import { consumeVideoSamples } from './consume-video-samples' + +describe('consumeVideoSamples', () => { + it('closes a yielded sample when cancellation wins before consumption', async () => { + const sample = { close: vi.fn() } + let iteratorFinalized = false + async function* samples() { + try { + yield sample + } finally { + iteratorFinalized = true + } + } + const consume = vi.fn() + + await consumeVideoSamples(samples(), [1], () => false, consume) + + expect(consume).not.toHaveBeenCalled() + expect(sample.close).toHaveBeenCalledOnce() + expect(iteratorFinalized).toBe(true) + }) + + it('skips a null sample and keeps consuming later timestamps', async () => { + const sample = { close: vi.fn() } + async function* samples() { + yield null + yield sample + } + const consume = vi.fn() + + await consumeVideoSamples(samples(), [1, 2], () => true, consume) + + expect(consume).toHaveBeenCalledOnce() + expect(consume).toHaveBeenCalledWith(sample, 2) + expect(sample.close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/features/preview/workers/consume-video-samples.ts b/src/features/preview/workers/consume-video-samples.ts new file mode 100644 index 000000000..27b6dcc0a --- /dev/null +++ b/src/features/preview/workers/consume-video-samples.ts @@ -0,0 +1,32 @@ +interface ClosableSample { + close?: () => void +} + +/** + * Consume a timestamp-aligned VideoSample iterator with one explicit ownership + * boundary. A sample that arrives just as cancellation wins is still closed + * before the iterator is torn down. + */ +export async function consumeVideoSamples( + iterator: AsyncGenerator, + timestamps: readonly number[], + shouldContinue: () => boolean, + consume: (sample: T, timestamp: number) => void, +): Promise { + let index = 0 + try { + for await (const sample of iterator) { + try { + if (!shouldContinue()) break + const timestamp = timestamps[index] + index += 1 + if (!sample || timestamp === undefined) continue + consume(sample, timestamp) + } finally { + sample?.close?.() + } + } + } finally { + await iterator.return?.() + } +} diff --git a/src/features/preview/workers/decoder-prewarm-worker.ts b/src/features/preview/workers/decoder-prewarm-worker.ts index 053ade595..a569ba010 100644 --- a/src/features/preview/workers/decoder-prewarm-worker.ts +++ b/src/features/preview/workers/decoder-prewarm-worker.ts @@ -7,6 +7,7 @@ */ import { createMediabunnyInputSource } from '@/infrastructure/browser/mediabunny-input-source' +import { consumeVideoSamples } from './consume-video-samples' import type { ObjectUrlSourceMetadata } from '@/infrastructure/browser/object-url-registry' const TIMESTAMP_EPSILON = 1e-4 @@ -266,11 +267,10 @@ function resetSampleIterator(state: ExtractorState, startTimestamp: number): voi // decoding a range. Starting at the requested presentation timestamp keeps // that necessary GOP decode inside the sink without yielding a keyframe-to- // target runway that this worker would only close and discard. - state.sampleIterator = state.sink.samples(Math.max(0, startTimestamp), Infinity) as AsyncGenerator< - WorkerSample, - void, - unknown - > + state.sampleIterator = state.sink.samples( + Math.max(0, startTimestamp), + Infinity, + ) as AsyncGenerator state.iteratorDone = false state.lastRequestedTimestamp = null } @@ -560,31 +560,12 @@ async function batchPreseek( // samplesAtTimestamps uses an optimized pipeline that shares decoder // state across the batch — each packet decoded at most once. const iterator = state.sink.samplesAtTimestamps(timestamps) - let i = 0 - try { - for await (const sample of iterator) { - if (!shouldContinue()) break - const timestamp = timestamps[i] - i++ - - if (!sample) { - continue - } - - try { - // Defensive: mediabunny should yield at most one sample per requested - // timestamp, but an over-producing iterator must not leak the extra - // VideoSample while the stream is being torn down. - if (timestamp === undefined) continue - const bitmap = renderSampleToBitmap(state, sample, maxDimension) - if (bitmap) results.set(timestamp, bitmap) - } finally { - sample.close?.() - } + await consumeVideoSamples(iterator, timestamps, shouldContinue, (sample, timestamp) => { + if (sample) { + const bitmap = renderSampleToBitmap(state, sample, maxDimension) + if (bitmap) results.set(timestamp, bitmap) } - } finally { - await iterator.return?.() - } + }) } catch { // Batch decode failed — return whatever we got } @@ -624,10 +605,7 @@ self.onmessage = async (event: MessageEvent) => { if (src) { activePreviewGenerationBySrc.set( src, - Math.max( - activePreviewGenerationBySrc.get(src) ?? 0, - Number(msg.generation) || 0, - ), + Math.max(activePreviewGenerationBySrc.get(src) ?? 0, Number(msg.generation) || 0), ) } return @@ -687,10 +665,7 @@ self.onmessage = async (event: MessageEvent) => { if (isActivePreviewRequest) { activePreviewGenerationBySrc.set( msg.src, - Math.max( - activePreviewGenerationBySrc.get(msg.src) ?? 0, - Number(msg.generation) || 0, - ), + Math.max(activePreviewGenerationBySrc.get(msg.src) ?? 0, Number(msg.generation) || 0), ) } diff --git a/src/infrastructure/gpu-shapes/shape-render-pipeline.ts b/src/infrastructure/gpu-shapes/shape-render-pipeline.ts index fa35c9529..b63a4d38a 100644 --- a/src/infrastructure/gpu-shapes/shape-render-pipeline.ts +++ b/src/infrastructure/gpu-shapes/shape-render-pipeline.ts @@ -205,11 +205,11 @@ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f { outlineProgress = fract(outlineProgress - u.trimParams.z + 1.0); let trimStart = u.trimParams.x; let trimEnd = u.trimParams.y; - strokeVisible = select( - outlineProgress >= trimStart || outlineProgress < trimEnd, - outlineProgress >= trimStart && outlineProgress < trimEnd, - trimEnd >= trimStart, - ); + if (trimEnd >= trimStart) { + strokeVisible = outlineProgress >= trimStart && outlineProgress < trimEnd; + } else { + strokeVisible = outlineProgress >= trimStart || outlineProgress < trimEnd; + } let visibleLength = select(1.0 - trimStart + trimEnd, trimEnd - trimStart, trimEnd >= trimStart); taperProgress = clamp(fract(outlineProgress - trimStart + 1.0) / max(visibleLength, 0.001), 0.0, 1.0); } From 1bf50f8b2cf596bdcb25bce47fa6de7a5d8c1850 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 16:47:21 -0700 Subject: [PATCH 03/64] refactor(timeline): simplify click seeking (cherry picked from commit 3058b32ba3b83df0d4dfb3214182a6bcdfe8e049) --- .../timeline/components/timeline-content.tsx | 98 ++++++++++++------- 1 file changed, 62 insertions(+), 36 deletions(-) diff --git a/src/features/timeline/components/timeline-content.tsx b/src/features/timeline/components/timeline-content.tsx index a5f969d61..9f66f6693 100644 --- a/src/features/timeline/components/timeline-content.tsx +++ b/src/features/timeline/components/timeline-content.tsx @@ -91,6 +91,56 @@ const DENSE_TIMELINE_HOVER_PREVIEW_DELAY_MS = 150 type TrackScrollbarSection = 'video' | 'audio' | 'single' +function shouldIgnoreTimelineContainerClick( + target: HTMLElement, + interactionJustFinished: boolean, +): boolean { + return interactionJustFinished || Boolean(target.closest('[role="menu"]')) +} + +function resolveTimelineContainerClickFrame( + clientX: number, + container: HTMLDivElement | null, + pixelsToFrame: (pixels: number) => number, + maxTimelineFrame: number, +): number { + const playback = usePlaybackStore.getState() + if (playback.previewFrame !== null) return playback.previewFrame + if (!container) return playback.currentFrame + + const localX = clientX - container.getBoundingClientRect().left + container.scrollLeft + return Math.max(0, Math.min(Math.round(pixelsToFrame(localX)), maxTimelineFrame)) +} + +function seekTimelineTrackAtPointer({ + target, + clientX, + container, + pixelsToFrame, + maxTimelineFrame, +}: { + target: HTMLElement + clientX: number + container: HTMLDivElement | null + pixelsToFrame: (pixels: number) => number + maxTimelineFrame: number +}): void { + if (!target.closest('[data-track-id]')) return + if (useSelectionStore.getState().activeTool === 'razor') return + if (isMicRecordingActive(useMicRecordingStore.getState().status)) return + + const playback = usePlaybackStore.getState() + const frame = resolveTimelineContainerClickFrame( + clientX, + container, + pixelsToFrame, + maxTimelineFrame, + ) + playback.pause() + playback.setPreviewFrame(null) + playback.setCurrentFrame(frame) +} + function revealTrackInScrollContainer(container: HTMLDivElement | null, trackId: string): boolean { if (!container) { return false @@ -1317,47 +1367,23 @@ export const TimelineContent = memo(function TimelineContent({ // Commit the hover skimmer on a normal timeline click. Ruler clicks own their // own scrub path, while drag/marquee/razor gestures must not move playback. const handleContainerClick = (e: React.MouseEvent) => { - if (marqueeWasActiveRef.current || dragWasActiveRef.current || scrubWasActiveRef.current) { - return - } - - // Don't deselect if clicking inside a context menu portal (Radix renders - // menus in a portal outside the timeline DOM, but React synthetic events - // still bubble through the component tree) const target = e.target as HTMLElement - if (target.closest('[role="menu"]')) { + const interactionJustFinished = + marqueeWasActiveRef.current || dragWasActiveRef.current || scrubWasActiveRef.current + // Radix menus render outside the timeline DOM, but their synthetic events + // still bubble through this component tree. + if (shouldIgnoreTimelineContainerClick(target, interactionJustFinished)) { return } const clickedOnItem = target.closest('[data-item-id]') - const clickedOnTrack = target.closest('[data-track-id]') - - if ( - clickedOnTrack && - useSelectionStore.getState().activeTool !== 'razor' && - !isMicRecordingActive(useMicRecordingStore.getState().status) - ) { - const playback = usePlaybackStore.getState() - const container = containerRef.current - const frame = - playback.previewFrame ?? - (container - ? Math.max( - 0, - Math.min( - Math.round( - pixelsToFrameRef.current( - e.clientX - container.getBoundingClientRect().left + container.scrollLeft, - ), - ), - maxTimelineFrameRef.current, - ), - ) - : playback.currentFrame) - playback.pause() - playback.setPreviewFrame(null) - playback.setCurrentFrame(frame) - } + seekTimelineTrackAtPointer({ + target, + clientX: e.clientX, + container: containerRef.current, + pixelsToFrame: pixelsToFrameRef.current, + maxTimelineFrame: maxTimelineFrameRef.current, + }) // Deselect items and markers if NOT clicking on a timeline item. if (!clickedOnItem) { From 60b0d5b99e9ae96f519b63e96acd4940b2c70931 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 17:58:04 -0700 Subject: [PATCH 04/64] fix(timeline): clear committed hover previews (cherry picked from commit 86bb52682419f6f2d5b8fd82c96b5a42dbca3e65) --- .../components/timeline-content.test.tsx | 37 +++++++++++++++++++ .../timeline/components/timeline-content.tsx | 9 +++++ 2 files changed, 46 insertions(+) diff --git a/src/features/timeline/components/timeline-content.test.tsx b/src/features/timeline/components/timeline-content.test.tsx index 56600548a..cfb9d2eec 100644 --- a/src/features/timeline/components/timeline-content.test.tsx +++ b/src/features/timeline/components/timeline-content.test.tsx @@ -1047,6 +1047,43 @@ describe('TimelineContent playback selection behavior', () => { expect(usePlaybackStore.getState().previewFrame).toBeNull() }) + it('does not restore the marquee release preview after a timeline body click', () => { + const { container } = render() + const frameCallbacks: FrameRequestCallback[] = [] + const animationFrameSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + + act(() => { + usePlaybackStore.getState().setCurrentFrame(90) + usePlaybackStore.getState().setPreviewFrame(24) + }) + + const track = container.querySelector(`[data-track-id="${VIDEO_TRACK.id}"]`) + expect(track).toBeTruthy() + + fireEvent.mouseDown(track!, { button: 0, clientX: 80, clientY: 100 }) + act(() => { + marqueeMocks.onGestureEnd?.( + new MouseEvent('mouseup', { button: 0, clientX: 80, clientY: 100 }), + false, + ) + }) + expect(frameCallbacks).toHaveLength(1) + + fireEvent.click(track!, { button: 0, clientX: 80, clientY: 100 }) + act(() => { + frameCallbacks.splice(0).forEach((callback) => callback(performance.now())) + }) + + expect(usePlaybackStore.getState().currentFrame).toBe(24) + expect(usePlaybackStore.getState().previewFrame).toBeNull() + animationFrameSpy.mockRestore() + }) + it('locks the skim preview from track mousedown until the marquee gesture ends', () => { const { container } = render() diff --git a/src/features/timeline/components/timeline-content.tsx b/src/features/timeline/components/timeline-content.tsx index 9f66f6693..f001f6570 100644 --- a/src/features/timeline/components/timeline-content.tsx +++ b/src/features/timeline/components/timeline-content.tsx @@ -885,6 +885,10 @@ export const TimelineContent = memo(function TimelineContent({ cancelAnimationFrame(previewRafRef.current) previewRafRef.current = null } + if (marqueeReleaseRafRef.current !== null) { + cancelAnimationFrame(marqueeReleaseRafRef.current) + marqueeReleaseRafRef.current = null + } }, []) useTimelineAudioSkimPreview() @@ -1376,6 +1380,11 @@ export const TimelineContent = memo(function TimelineContent({ return } + // A normal background click arrives after the marquee mouseup callback. + // Cancel its queued preview restore before committing the click so the + // program monitor follows currentFrame instead of resurrecting the hover + // frame on the next animation frame. + cancelPendingHoverPreview() const clickedOnItem = target.closest('[data-item-id]') seekTimelineTrackAtPointer({ target, From 0d052122e7da906b8fe564f67aafd490328bd1b1 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 19:31:28 -0700 Subject: [PATCH 05/64] fix(timeline): ignore clicks during voiceover takes (cherry picked from commit 4f64f8e44dd741e1c48fc42023116db9c62747fe) --- .../components/timeline-content.test.tsx | 28 +++++++++++++++++++ .../timeline/components/timeline-content.tsx | 12 ++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/features/timeline/components/timeline-content.test.tsx b/src/features/timeline/components/timeline-content.test.tsx index cfb9d2eec..cb396033e 100644 --- a/src/features/timeline/components/timeline-content.test.tsx +++ b/src/features/timeline/components/timeline-content.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, render, waitFor } from '@testing-library/react' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useEditorStore } from '@/shared/state/editor' +import { useMicRecordingStore } from '@/shared/state/mic-recording-store' import { usePlaybackStore } from '@/shared/state/playback' import { resetPlaybackPreviewState } from '@/shared/state/playback-preview-test-helpers' import { useSelectionStore } from '@/shared/state/selection' @@ -173,6 +174,7 @@ function resetStores() { }) resetPlaybackPreviewState() + useMicRecordingStore.getState().reset() useTimelineStore.setState({ fps: 30, @@ -1047,6 +1049,32 @@ describe('TimelineContent playback selection behavior', () => { expect(usePlaybackStore.getState().previewFrame).toBeNull() }) + it('does not pause or seek when the timeline body is clicked during a microphone take', () => { + const { container } = render() + const pause = vi.spyOn(usePlaybackStore.getState(), 'pause') + + act(() => { + usePlaybackStore.setState({ currentFrame: 90, isPlaying: true }) + useMicRecordingStore.setState({ status: 'recording' }) + }) + act(() => { + usePlaybackStore.setState({ previewFrame: 24 }) + }) + + const track = container.querySelector(`[data-track-id="${VIDEO_TRACK.id}"]`) + expect(track).toBeTruthy() + + fireEvent.mouseDown(track!, { button: 0, clientX: 80, clientY: 100 }) + fireEvent.click(track!, { button: 0, clientX: 80, clientY: 100 }) + + expect(pause).not.toHaveBeenCalled() + expect(usePlaybackStore.getState()).toMatchObject({ + currentFrame: 90, + previewFrame: 24, + isPlaying: true, + }) + }) + it('does not restore the marquee release preview after a timeline body click', () => { const { container } = render() const frameCallbacks: FrameRequestCallback[] = [] diff --git a/src/features/timeline/components/timeline-content.tsx b/src/features/timeline/components/timeline-content.tsx index f001f6570..e81c06b85 100644 --- a/src/features/timeline/components/timeline-content.tsx +++ b/src/features/timeline/components/timeline-content.tsx @@ -95,7 +95,15 @@ function shouldIgnoreTimelineContainerClick( target: HTMLElement, interactionJustFinished: boolean, ): boolean { - return interactionJustFinished || Boolean(target.closest('[role="menu"]')) + return ( + interactionJustFinished || + Boolean(target.closest('[role="menu"]')) || + isMicRecordingActive(useMicRecordingStore.getState().status) + ) +} + +function shouldIgnoreTimelineMouseDownCapture(button: number): boolean { + return button !== 0 || isMicRecordingActive(useMicRecordingStore.getState().status) } function resolveTimelineContainerClickFrame( @@ -1426,7 +1434,7 @@ export const TimelineContent = memo(function TimelineContent({ // Preview scrubber: show ghost playhead on hover const handleTimelineMouseDownCapture = useCallback((e: React.MouseEvent) => { - if (e.button !== 0) return + if (shouldIgnoreTimelineMouseDownCapture(e.button)) return const target = e.target as HTMLElement if ( From e4a2af306796dffd16da28ba49ef4833b1f442ec Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 20:37:37 -0700 Subject: [PATCH 06/64] fix(timeline): source drilled export mixer from active composition (cherry picked from commit c85bf5882695ea0f0bd3fb98be6a12bd15c42679) --- .../stores/actions/export-snapshot.ts | 19 +++++-- .../timeline/stores/export-snapshot.test.ts | 57 +++++++++++++++++++ .../timeline/stores/timeline-persistence.ts | 8 ++- 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/features/timeline/stores/actions/export-snapshot.ts b/src/features/timeline/stores/actions/export-snapshot.ts index e49cf22c8..71f667faa 100644 --- a/src/features/timeline/stores/actions/export-snapshot.ts +++ b/src/features/timeline/stores/actions/export-snapshot.ts @@ -52,6 +52,10 @@ function furthestItemEnd(items: TimelineItem[]): number { return Math.max(...items.map((item) => item.from + item.durationInFrames)) } +function cloneAudioEq(busAudioEq: AudioEqSettings | undefined): AudioEqSettings | undefined { + return busAudioEq ? { ...busAudioEq } : undefined +} + /** The active top-level tab (null = Main) — the picker's default selection. */ export function getActiveExportSequenceId(): string | null { return getActiveTabId(useCompositionNavigationStore.getState().breadcrumbs) @@ -87,7 +91,6 @@ export function getExportableSequence(sequenceId: string | null): ExportableSequ const activeTabId = getActiveTabId(nav.breadcrumbs) const playback = usePlaybackStore.getState() const markersState = useMarkersStore.getState() - const isActiveTab = sequenceId === activeTabId // The markers store holds the range of whatever timeline is *loaded* — the // deepest drill level, which is not the tab root once you drill into a comp. // Keying this on the tab id reported a drilled-into comp's range as Main's, @@ -125,7 +128,8 @@ export function getExportableSequence(sequenceId: string | null): ExportableSequ const root = getRootTimelineSnapshot(current) const metadata = useProjectStore.getState().currentProject?.metadata // Main's audio bus / range are live when Main is active, else held aside. - const busAudioEq = activeTabId === null ? playback.busAudioEq : nav.mainHolder?.busAudioEq + const busAudioEq = + activeTabId === null ? playback.busAudioEq : nav.mainHolder?.busAudioEq return { id: null, name: MAIN_LABEL, @@ -137,7 +141,7 @@ export function getExportableSequence(sequenceId: string | null): ExportableSequ width: metadata?.width ?? DEFAULT_PROJECT_WIDTH, height: metadata?.height ?? DEFAULT_PROJECT_HEIGHT, backgroundColor: metadata?.backgroundColor, - busAudioEq, + busAudioEq: cloneAudioEq(busAudioEq), masterBusDb: playback.masterBusDb, durationFrames: furthestItemEnd(root.items), ...range(nav.mainHolder), @@ -160,9 +164,12 @@ export function getExportableSequence(sequenceId: string | null): ExportableSequ width: comp.width, height: comp.height, backgroundColor: comp.backgroundColor, - // Live mixer edits live in the playback store for the active sequence; the - // registry entry is only up to date once we've switched away from it. - busAudioEq: isActiveTab ? playback.busAudioEq : comp.busAudioEq, + // Live mixer edits belong to the deepest composition being edited, not the + // top-level tab. A drilled child therefore owns playback.busAudioEq while + // its tab root must continue using the registry snapshot. + busAudioEq: cloneAudioEq( + sequenceId === nav.activeCompositionId ? playback.busAudioEq : comp.busAudioEq, + ), masterBusDb: playback.masterBusDb, durationFrames: comp.durationInFrames || furthestItemEnd(comp.items), ...range(comp), diff --git a/src/features/timeline/stores/export-snapshot.test.ts b/src/features/timeline/stores/export-snapshot.test.ts index c2479e148..782e86ce4 100644 --- a/src/features/timeline/stores/export-snapshot.test.ts +++ b/src/features/timeline/stores/export-snapshot.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' +import type { CompositionItem } from '@/types/timeline' import { makeTimelineTrack as makeTrack, makeTimelineVideoItem as makeVideoItem, @@ -9,6 +10,7 @@ import { useItemsStore } from './items-store' import { useCompositionsStore } from './compositions-store' import { useSequencesStore } from './sequences-store' import { useCompositionNavigationStore } from './composition-navigation-store' +import { usePlaybackStore } from '@/shared/state/playback' import { getActiveExportSequenceId, getExportableSequence, @@ -31,6 +33,35 @@ function seedSequence(id: string, itemId: string, width = 1280, height = 720): v useSequencesStore.getState().addTopLevelSequence(id) } +function seedNestedSequence(): void { + seedSequence('seq-a', 'a-clip') + useCompositionsStore.getState().addComposition({ + id: 'child', + name: 'child', + tracks: [makeTrack({ id: 'child-v1', name: 'V1', kind: 'video', order: 0 })], + items: [makeVideoItem({ id: 'child-clip', trackId: 'child-v1', durationInFrames: 30 })], + transitions: [], + keyframes: [], + fps: 24, + width: 640, + height: 360, + durationInFrames: 30, + busAudioEq: { enabled: true, lowGainDb: 2 }, + }) + useCompositionsStore.getState().updateComposition('seq-a', { + items: [ + { + ...makeVideoItem({ id: 'child-entry', trackId: 'seq-a-v1', durationInFrames: 30 }), + type: 'composition', + compositionId: 'child', + compositionWidth: 640, + compositionHeight: 360, + } as unknown as CompositionItem, + ], + busAudioEq: { enabled: true, lowGainDb: 4 }, + }) +} + describe('export-snapshot sourcing', () => { beforeEach(() => { resetTimelineCompositionTestState() @@ -111,4 +142,30 @@ describe('export-snapshot sourcing', () => { const seq = getExportableSequence('seq-a') expect(seq.items.map((i) => i.id)).toEqual(['a-clip']) }) + + it('binds a drilled child export to the live child mixer without contaminating Main or its tab root', () => { + const mainEq = { enabled: true, lowGainDb: 1 } + const sequenceEq = { enabled: true, lowGainDb: 4 } + const childEq = { enabled: true, lowGainDb: 9 } + seedNestedSequence() + usePlaybackStore.getState().setBusAudioEq(mainEq) + useCompositionsStore.getState().updateComposition('seq-a', { busAudioEq: sequenceEq }) + useCompositionNavigationStore.getState().switchToSequence('seq-a') + useCompositionNavigationStore.getState().enterComposition('child', 'child', 'child-entry') + usePlaybackStore.getState().setBusAudioEq(childEq) + + const child = getExportableSequence('child') + const sequence = getExportableSequence('seq-a') + const main = getExportableSequence(null) + + expect(child.busAudioEq).toEqual(childEq) + expect(sequence.busAudioEq).toEqual(sequenceEq) + expect(main.busAudioEq).toEqual(mainEq) + + // Returned EQ data is a snapshot: later live edits do not rewrite prior exports. + usePlaybackStore.getState().setBusAudioEq({ enabled: true, lowGainDb: 12 }) + expect(child.busAudioEq).toEqual(childEq) + expect(sequence.busAudioEq).toEqual(sequenceEq) + expect(main.busAudioEq).toEqual(mainEq) + }) }) diff --git a/src/features/timeline/stores/timeline-persistence.ts b/src/features/timeline/stores/timeline-persistence.ts index b94717301..1122d9fa8 100644 --- a/src/features/timeline/stores/timeline-persistence.ts +++ b/src/features/timeline/stores/timeline-persistence.ts @@ -727,6 +727,10 @@ interface TimelinePersistenceSnapshot { isRootTimelineLive: boolean } +function cloneAudioEq(busAudioEq: AudioEqSettings | undefined): AudioEqSettings | undefined { + return busAudioEq ? { ...busAudioEq } : undefined +} + /** * Capture a Main-rooted project snapshot without navigating the live editor. * @@ -773,7 +777,7 @@ function captureTimelinePersistenceSnapshot(): TimelinePersistenceSnapshot { return { ...composition, durationInFrames, - busAudioEq: playback.busAudioEq, + busAudioEq: cloneAudioEq(playback.busAudioEq), markers: markers.markers, inPoint: markers.inPoint, outPoint: markers.outPoint, @@ -787,7 +791,7 @@ function captureTimelinePersistenceSnapshot(): TimelinePersistenceSnapshot { zoomLevel: heldRoot?.zoomLevel ?? rootView?.zoomLevel ?? zoom.level, scrollPosition: heldRoot?.scrollPosition ?? rootView?.scrollPosition ?? settings.scrollPosition, - busAudioEq: heldRoot ? heldRoot.busAudioEq : playback.busAudioEq, + busAudioEq: cloneAudioEq(heldRoot ? heldRoot.busAudioEq : playback.busAudioEq), masterBusDb: playback.masterBusDb, markers: heldRoot ? heldRoot.markers : markers.markers, inPoint: heldRoot ? heldRoot.inPoint : markers.inPoint, From a331e435ba292ce6e67397dc7a07d886224ed176 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 20:38:10 -0700 Subject: [PATCH 07/64] Fix cross-sequence linked clipboard track mapping (cherry picked from commit f5adb6917a60acf4d1497a3375af1e9de4e96c11) --- .../use-clipboard-shortcuts.test.tsx | 82 ++++++++++++++- .../shortcuts/use-clipboard-shortcuts.ts | 99 ++++++++++++++----- 2 files changed, 158 insertions(+), 23 deletions(-) diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx index 3096f34ed..89c875bc2 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { HOTKEYS } from '@/config/hotkeys' import { useClipboardStore } from '@/shared/state/clipboard' import { useSelectionStore } from '@/shared/state/selection' -import type { AudioItem, TimelineItem, TimelineTrack, VideoItem } from '@/types/timeline' +import type { AudioItem, TextItem, TimelineItem, TimelineTrack, VideoItem } from '@/types/timeline' import { useCompositionNavigationStore } from '../../stores/composition-navigation-store' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' import { useTimelineStore } from '../../stores/timeline-store' @@ -60,6 +60,9 @@ const AUDIO_TRACK: TimelineTrack = { order: 1, } +const SECOND_VIDEO_TRACK: TimelineTrack = { ...TARGET_TRACK, id: 'target-video-2', name: 'V2', order: 1 } +const SECOND_AUDIO_TRACK: TimelineTrack = { ...AUDIO_TRACK, id: 'target-audio-2', name: 'A2', order: 3 } + function makeVideoItem(overrides: Partial = {}): VideoItem { return { id: 'clip-1', @@ -86,6 +89,21 @@ function makeAudioItem(overrides: Partial = {}): AudioItem { } } +function makeCaptionItem(overrides: Partial = {}): TextItem { + return { + id: 'caption-1', + type: 'text', + trackId: 'missing-caption-track', + from: 0, + durationInFrames: 10, + label: 'Caption', + text: 'Caption', + color: '#fff', + textRole: 'caption', + ...overrides, + } +} + function ShortcutHarness() { useClipboardShortcuts() return null @@ -205,4 +223,66 @@ describe('useClipboardShortcuts paste placement', () => { expect(plannedItems[0]!.linkedGroupId).toBeTruthy() expect(plannedItems[1]!.linkedGroupId).toBe(plannedItems[0]!.linkedGroupId) }) + + it('maps linked A/V items with absent source IDs to separate compatible lanes', () => { + useTimelineStore.setState({ tracks: [TARGET_TRACK, AUDIO_TRACK] }) + useClipboardStore.getState().copyItems( + [ + makeVideoItem({ id: 'missing-video', trackId: 'source-v', linkedGroupId: 'pair' }), + makeAudioItem({ id: 'missing-audio', trackId: 'source-a', linkedGroupId: 'pair' }), + ], + 0, + 'copy', + ) + + render() + act(() => getPasteCallback()({ preventDefault: vi.fn() })) + + expect(getPlannedItems().map((item) => item.trackId)).toEqual([TARGET_TRACK.id, AUDIO_TRACK.id]) + }) + + it('preserves lane ordinals for multiple linked pairs and keeps captions on video lanes', () => { + useTimelineStore.setState({ tracks: [TARGET_TRACK, SECOND_VIDEO_TRACK, AUDIO_TRACK, SECOND_AUDIO_TRACK] }) + useClipboardStore.getState().copyItems( + [ + makeVideoItem({ id: 'v1', trackId: 'source-v1', linkedGroupId: 'pair-1' }), + makeAudioItem({ id: 'a1', trackId: 'source-a1', linkedGroupId: 'pair-1' }), + makeVideoItem({ id: 'v2', trackId: 'source-v2', from: 20, linkedGroupId: 'pair-2' }), + makeAudioItem({ id: 'a2', trackId: 'source-a2', from: 20, linkedGroupId: 'pair-2' }), + makeCaptionItem({ id: 'caption', trackId: 'source-caption', from: 20 }), + ], + 0, + 'copy', + ) + + render() + act(() => getPasteCallback()({ preventDefault: vi.fn() })) + + const plannedItems = getPlannedItems() + expect(plannedItems.map((item) => item.trackId)).toEqual([ + TARGET_TRACK.id, + AUDIO_TRACK.id, + SECOND_VIDEO_TRACK.id, + SECOND_AUDIO_TRACK.id, + TARGET_TRACK.id, + ]) + expect(plannedItems.filter((item) => item.type === 'text')[0]?.trackId).toBe(TARGET_TRACK.id) + }) + + it('uses surviving IDs while resolving missing linked members by kind', () => { + useTimelineStore.setState({ tracks: [TARGET_TRACK, AUDIO_TRACK] }) + useClipboardStore.getState().copyItems( + [ + makeVideoItem({ id: 'surviving-video', trackId: TARGET_TRACK.id, linkedGroupId: 'pair' }), + makeAudioItem({ id: 'missing-audio', trackId: 'source-a', linkedGroupId: 'pair' }), + ], + 0, + 'copy', + ) + + render() + act(() => getPasteCallback()({ preventDefault: vi.fn() })) + + expect(getPlannedItems().map((item) => item.trackId)).toEqual([TARGET_TRACK.id, AUDIO_TRACK.id]) + }) }) diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts index 59d840b57..ff8ce3528 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts @@ -14,13 +14,14 @@ import { useCompositionsStore } from '../../stores/compositions-store' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { Transition } from '@/types/transition' -import type { TimelineItem } from '@/types/timeline' +import type { TimelineItem, TimelineTrack } from '@/types/timeline' import { useResolvedHotkeys } from '@/features/timeline/deps/settings' import { isCompositionWrapperItem, wouldCreateCompositionCycle, } from '../../utils/composition-graph' import { handleTranscriptClipboardCopy } from '../../utils/transcript-copy-bridge' +import { getTrackKind } from '../../utils/classic-tracks' interface PastePlacementPlan { itemData: Omit @@ -59,6 +60,66 @@ function hasInternalPlacementOverlap(plans: PastePlacementPlan[]): boolean { ) } +type PasteTrackKind = 'video' | 'audio' + +function getPasteTrackKind(item: Omit): PasteTrackKind { + return item.type === 'audio' ? 'audio' : 'video' +} + +function isCompatiblePasteTrack(track: TimelineTrack, kind: PasteTrackKind): boolean { + const trackKind = getTrackKind(track) + return kind === 'audio' ? trackKind === 'audio' : trackKind !== 'audio' +} + +function getPasteDestinationTrackIds(tracks: TimelineTrack[], kind: PasteTrackKind): string[] { + return tracks + .filter((track) => !track.isGroup && isCompatiblePasteTrack(track, kind)) + .sort((left, right) => left.order - right.order) + .map((track) => track.id) +} + +function buildPasteTrackPlan( + pasteItems: Array>, + tracks: TimelineTrack[], + activeTrackId: string | null, +): Map { + const sourceTrackIndexes = new Map() + const nextSourceIndex: Record = { video: 0, audio: 0 } + const destinationTrackIds: Record = { + video: getPasteDestinationTrackIds(tracks, 'video'), + audio: getPasteDestinationTrackIds(tracks, 'audio'), + } + const preserveSourceTracks = new Set(pasteItems.map((item) => item.trackId)).size > 1 + const plan = new Map() + + for (const [sourceIndex, itemData] of pasteItems.entries()) { + const kind = getPasteTrackKind(itemData) + const sourceTrackId = itemData.trackId + let sourceLane = sourceTrackIndexes.get(sourceTrackId) + if (sourceLane === undefined) { + sourceLane = nextSourceIndex[kind]++ + sourceTrackIndexes.set(sourceTrackId, sourceLane) + } + + const exactTrack = tracks.find( + (track) => track.id === sourceTrackId && isCompatiblePasteTrack(track, kind), + ) + const activeTrack = tracks.find( + (track) => track.id === activeTrackId && isCompatiblePasteTrack(track, kind), + ) + const candidates = destinationTrackIds[kind] + const targetTrackId = preserveSourceTracks + ? (exactTrack?.id ?? candidates[sourceLane] ?? candidates[0] ?? activeTrack?.id) + : (activeTrack?.id ?? exactTrack?.id ?? candidates[sourceLane] ?? candidates[0]) + + if (targetTrackId) { + plan.set(sourceIndex, targetTrackId) + } + } + + return plan +} + function findSharedPlacementShift( plans: PastePlacementPlan[], occupiedItems: TimelineItem[], @@ -266,27 +327,21 @@ export function useClipboardShortcuts() { ) if (pasteItems.length === 0) return - // When the clipboard spans more than one source track (e.g. a linked - // video+audio pair copied from the transcript), preserve each item's own - // track so the pair lands on video/audio tracks separately. A - // single-track copy still pastes onto the active track as before. - const preserveSourceTracks = new Set(pasteItems.map((item) => item.trackId)).size > 1 - - const placementPlans = pasteItems.map((itemData, sourceIndex): PastePlacementPlan => { - let targetTrackId = preserveSourceTracks ? itemData.trackId : activeTrackId - if (!targetTrackId || !tracks.some((t) => t.id === targetTrackId)) { - targetTrackId = itemData.trackId - } - const trackExists = tracks.some((t) => t.id === targetTrackId) - if (!trackExists && tracks.length > 0) { - targetTrackId = tracks[0]!.id - } - return { - itemData, - targetTrackId, - desiredFrom: currentFrame + itemData.from, - sourceIndex, - } + // Resolve every source lane by media section/kind. Exact IDs win when + // they survive in this sequence; otherwise the source lane ordinal is + // mapped to the corresponding destination lane. This keeps linked A/V + // members in separate sections even when both source IDs are absent. + const trackPlan = buildPasteTrackPlan(pasteItems, tracks, activeTrackId) + const placementPlans = pasteItems.flatMap((itemData, sourceIndex) => { + const targetTrackId = trackPlan.get(sourceIndex) + return targetTrackId + ? [{ + itemData, + targetTrackId, + desiredFrom: currentFrame + itemData.from, + sourceIndex, + }] + : [] }) // Keep an ordinary multi-item paste as one rigid block. If invalid or From 4f4247183adfad669553eb8d4c6ff6bcf217ec17 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 20:37:40 -0700 Subject: [PATCH 08/64] fix export render cancellation ownership (cherry picked from commit 1e3535be344fbff984e83ac3994c6e3e69399ec2) --- .../export/hooks/use-client-render.ts | 59 +++++++++++++++---- .../export/hooks/use-render-queue-runner.ts | 5 +- .../media-library/utils/media-resolver.ts | 37 ++++++++++-- .../preview/utils/media-resolver.test.ts | 46 +++++++++++++++ 4 files changed, 130 insertions(+), 17 deletions(-) diff --git a/src/features/export/hooks/use-client-render.ts b/src/features/export/hooks/use-client-render.ts index 188a3bf54..c80fd7eab 100644 --- a/src/features/export/hooks/use-client-render.ts +++ b/src/features/export/hooks/use-client-render.ts @@ -93,6 +93,7 @@ export function useClientRender(): UseClientRenderReturn { // AbortController for cancellation const abortControllerRef = useRef(null) + const renderGenerationRef = useRef(0) /** * Handle progress updates from the render engine @@ -127,11 +128,28 @@ export function useClientRender(): UseClientRenderReturn { async (settings: ExportSettings | ExtendedExportSettings, sequence?: ExportableSequence) => { const opId = createOperationId() const event = log.startEvent('render', opId) + const previousController = abortControllerRef.current + previousController?.abort() + const generation = ++renderGenerationRef.current + const controller = new AbortController() + abortControllerRef.current = controller + let temporaryResult: ClientRenderResult | null = null + + const releaseResult = (ownedResult: ClientRenderResult | null | undefined) => { + if (!ownedResult) return + if (resultRef.current === ownedResult) resultRef.current = null + void releaseTemporaryExportOutput(ownedResult) + } + const ensureActive = () => { + if (generation !== renderGenerationRef.current || controller.signal.aborted) { + throw new DOMException('Render cancelled', 'AbortError') + } + } try { const previousResult = resultRef.current resultRef.current = null - void releaseTemporaryExportOutput(previousResult) + releaseResult(previousResult) setIsExporting(true) setProgress(0) setProgressMessage(undefined) @@ -139,9 +157,6 @@ export function useClientRender(): UseClientRenderReturn { setResult(null) setStatus('preparing') - // Create abort controller for cancellation - abortControllerRef.current = new AbortController() - // Read current state from stores const state = useTimelineStore.getState() const currentProject = useProjectStore.getState().currentProject @@ -166,7 +181,7 @@ export function useClientRender(): UseClientRenderReturn { const { exportMode, renderWholeProject } = requested const effectiveInPoint = renderWholeProject ? null : inPoint const effectiveOutPoint = renderWholeProject ? null : outPoint - const signal = abortControllerRef.current.signal + const signal = controller.signal const smartCopy = await trySmartCopyExport( { @@ -186,10 +201,13 @@ export function useClientRender(): UseClientRenderReturn { signal, handleProgress, ) + ensureActive() if (smartCopy.result) { - resultRef.current = smartCopy.result - setResult(smartCopy.result) + temporaryResult = smartCopy.result + resultRef.current = temporaryResult + setResult(temporaryResult) + temporaryResult = null setStatus('completed') setProgress(100) event.set('renderPath', 'smart-copy') @@ -203,6 +221,7 @@ export function useClientRender(): UseClientRenderReturn { // Resolve settings + codec fallback only when an encoder is required. const { clientSettings, codecFallback } = await resolveClientSettings(settings, fps) + ensureActive() if (codecFallback) event.set('codecFallback', codecFallback) const extended = isExtendedSettings(settings) @@ -257,7 +276,11 @@ export function useClientRender(): UseClientRenderReturn { // Resolve media URLs (convert mediaIds to blob URLs) // Export always uses full-res source, never proxies - const resolvedTracks = await resolveMediaUrls(composition.tracks, { useProxy: false }) + const resolvedTracks = await resolveMediaUrls(composition.tracks, { + useProxy: false, + signal, + }) + ensureActive() composition.tracks = resolvedTracks // Count resolved items for diagnostics @@ -304,6 +327,8 @@ export function useClientRender(): UseClientRenderReturn { signal, onProgress: handleProgress, }) + ensureActive() + temporaryResult = renderResult if (fallbackReason) event.set('workerFallbackReason', fallbackReason) // Sidecar mode: the video is muxed clean; build the .srt from the same @@ -321,8 +346,10 @@ export function useClientRender(): UseClientRenderReturn { } } + if (finalResult !== renderResult) temporaryResult = finalResult resultRef.current = finalResult setResult(finalResult) + temporaryResult = null setStatus('completed') setProgress(100) @@ -333,6 +360,9 @@ export function useClientRender(): UseClientRenderReturn { duration: renderResult.duration, }) } catch (err) { + releaseResult(temporaryResult) + temporaryResult = null + if (generation !== renderGenerationRef.current) return if (err instanceof DOMException && err.name === 'AbortError') { event.set('outcome', 'cancelled') event.set('duration_ms', Date.now()) @@ -345,8 +375,10 @@ export function useClientRender(): UseClientRenderReturn { setStatus('failed') } } finally { - setIsExporting(false) - abortControllerRef.current = null + if (generation === renderGenerationRef.current) { + setIsExporting(false) + if (abortControllerRef.current === controller) abortControllerRef.current = null + } } }, [handleProgress], @@ -413,6 +445,7 @@ export function useClientRender(): UseClientRenderReturn { * Reset state */ const resetState = useCallback(() => { + renderGenerationRef.current++ abortControllerRef.current?.abort() abortControllerRef.current = null setIsExporting(false) @@ -430,8 +463,12 @@ export function useClientRender(): UseClientRenderReturn { useEffect( () => () => { - void releaseTemporaryExportOutput(resultRef.current) + renderGenerationRef.current++ + abortControllerRef.current?.abort() + abortControllerRef.current = null + const ownedResult = resultRef.current resultRef.current = null + void releaseTemporaryExportOutput(ownedResult) }, [], ) diff --git a/src/features/export/hooks/use-render-queue-runner.ts b/src/features/export/hooks/use-render-queue-runner.ts index 1c76aadb8..e280068a7 100644 --- a/src/features/export/hooks/use-render-queue-runner.ts +++ b/src/features/export/hooks/use-render-queue-runner.ts @@ -110,7 +110,10 @@ async function renderQueuedJob(job: RenderJob): Promise { ) // Resolve mediaIds → blob URLs fresh at render time (export never proxies). - composition.tracks = await resolveMediaUrls(composition.tracks, { useProxy: false }) + composition.tracks = await resolveMediaUrls(composition.tracks, { + useProxy: false, + signal: controller.signal, + }) const { result, renderPath, fallbackReason } = await runRender({ clientSettings: job.clientSettings, diff --git a/src/features/media-library/utils/media-resolver.ts b/src/features/media-library/utils/media-resolver.ts index a90c96756..e7f95269f 100644 --- a/src/features/media-library/utils/media-resolver.ts +++ b/src/features/media-library/utils/media-resolver.ts @@ -190,6 +190,11 @@ export async function resolveMediaUrls( const useProxy = options?.useProxy ?? true const signal = options?.signal + const throwIfAborted = () => { + if (signal?.aborted) throw new DOMException('Media resolution aborted', 'AbortError') + } + throwIfAborted() + // Deep clone tracks to avoid mutating original const resolvedTracks: TimelineTrack[] = structuredClone(tracks) @@ -206,7 +211,8 @@ export async function resolveMediaUrls( item.type === 'image' || item.type === 'lottie') ) { - const promise = resolveMediaUrl(item.mediaId).then((blobUrl) => { + const resolution = resolveMediaUrl(item.mediaId).then((blobUrl) => { + throwIfAborted() // For video items in preview mode, prefer proxy URL if available if (useProxy && item.type === 'video') { const proxyUrl = resolveProxyUrl(item.mediaId!) @@ -219,7 +225,30 @@ export async function resolveMediaUrls( } } }) - resolutionPromises.push(promise) + if (signal) { + resolutionPromises.push( + new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener('abort', onAbort) + reject(new DOMException('Media resolution aborted', 'AbortError')) + } + signal.addEventListener('abort', onAbort, { once: true }) + resolution.then( + () => { + signal.removeEventListener('abort', onAbort) + resolve() + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + }, + ) + if (signal.aborted) onAbort() + }), + ) + } else { + resolutionPromises.push(resolution) + } } } } @@ -228,9 +257,7 @@ export async function resolveMediaUrls( await Promise.all(resolutionPromises) // Check if aborted after resolution - if (signal?.aborted) { - throw new DOMException('Media resolution aborted', 'AbortError') - } + throwIfAborted() return resolvedTracks } diff --git a/src/features/preview/utils/media-resolver.test.ts b/src/features/preview/utils/media-resolver.test.ts index 1f5b70755..1b09f3ca4 100644 --- a/src/features/preview/utils/media-resolver.test.ts +++ b/src/features/preview/utils/media-resolver.test.ts @@ -479,3 +479,49 @@ describe('relinking regression', () => { expect(relinkedUrl).not.toBe(originalUrl) }) }) + +describe('abortable bulk resolution', () => { + it('rejects promptly when its signal aborts while media is pending', async () => { + let resolveFile!: (file: Blob) => void + ;(mediaLibraryService.getMedia as Mock).mockResolvedValue({ + id: 'media-1', + fileName: 'video.mp4', + }) + ;(mediaLibraryService.getMediaFile as Mock).mockReturnValue( + new Promise((resolve) => { + resolveFile = resolve + }), + ) + + const controller = new AbortController() + const tracks = [ + { + id: 'track-1', + name: 'Track 1', + height: 40, + locked: false, + visible: true, + muted: false, + solo: false, + order: 0, + items: [ + { + id: 'item-1', + type: 'video' as const, + trackId: 'track-1', + from: 0, + durationInFrames: 30, + mediaId: 'media-1', + src: '', + label: 'clip', + }, + ], + }, + ] + + const pending = resolveMediaUrls(tracks, { useProxy: false, signal: controller.signal }) + controller.abort() + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + resolveFile(new Blob(['late'])) + }) +}) From 7112c685d47c49803ca85b046ef72d442fc958ca Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 20:40:29 -0700 Subject: [PATCH 09/64] fix(preview): invalidate stale source presentations (cherry picked from commit 9bd1f37d616bbeb2014a1c9500a9cda9948a702b) --- .../preview/components/source-composition.tsx | 76 ++++++++++++++++--- .../preview/components/video-preview.tsx | 31 ++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/features/preview/components/source-composition.tsx b/src/features/preview/components/source-composition.tsx index 8fcc5cfb7..ca7d14813 100644 --- a/src/features/preview/components/source-composition.tsx +++ b/src/features/preview/components/source-composition.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useState, useMemo, useCallback } from 'react' +import { useRef, useEffect, useLayoutEffect, useState, useMemo, useCallback } from 'react' import { AbsoluteFill } from '@/features/preview/deps/player-core' import { useClock, @@ -175,6 +175,7 @@ function VideoSource({ const canvasRef = useRef(null) const contextRef = useRef(null) const mountedRef = useRef(true) + const sourceGenerationRef = useRef(0) const decoderReadyRef = useRef(false) const renderInFlightRef = useRef(false) const pendingTimeRef = useRef(null) @@ -246,7 +247,23 @@ function VideoSource({ } }, []) - useEffect(() => { + useLayoutEffect(() => { + const generation = ++sourceGenerationRef.current + mountedRef.current = true + decoderReadyRef.current = false + extractorRef.current = null + pendingTimeRef.current = null + contextRef.current = null + prewarmInFlightRef.current = false + queuedPrewarmTimesRef.current = [] + prewarmAnchorFrameRef.current = null + for (const bitmap of frameCacheRef.current.values()) bitmap.close() + frameCacheRef.current.clear() + frameCacheOrderRef.current = [] + const canvas = canvasRef.current + const context = canvas?.getContext('2d') + if (canvas && context) context.clearRect(0, 0, canvas.width, canvas.height) + const resetCanvas = canvas setUseLegacyPausedSeek(false) setHasDecodedFrame(false) setDecodedFrameKey(null) @@ -254,6 +271,16 @@ function VideoSource({ prewarmInFlightRef.current = false queuedPrewarmTimesRef.current = [] prewarmAnchorFrameRef.current = null + return () => { + if (sourceGenerationRef.current === generation) { + sourceGenerationRef.current += 1 + const currentCanvas = resetCanvas + const currentContext = currentCanvas?.getContext('2d') + if (currentCanvas && currentContext) { + currentContext.clearRect(0, 0, currentCanvas.width, currentCanvas.height) + } + } + } }, [activeSrc, mediaId]) const pumpDirectionalPrewarm = useCallback(() => { @@ -365,6 +392,11 @@ function VideoSource({ const extractor = extractorRef.current const canvas = canvasRef.current if (!extractor || !canvas) return false + const generation = sourceGenerationRef.current + const isCurrent = () => + mountedRef.current && + sourceGenerationRef.current === generation && + extractorRef.current === extractor let ctx = contextRef.current if (!ctx) { @@ -384,8 +416,10 @@ function VideoSource({ const cacheKey = quantizeSourceMonitorTime(targetTime) const markDecodedFrame = () => { + if (!isCurrent()) return false setHasDecodedFrame(true) setDecodedFrameKey((prev) => (prev === cacheKey ? prev : cacheKey)) + return true } const cache = frameCacheRef.current const cacheOrder = frameCacheOrderRef.current @@ -398,8 +432,7 @@ function VideoSource({ cacheOrder.splice(cacheIndex, 1) cacheOrder.push(cacheKey) } - markDecodedFrame() - return true + return markDecodedFrame() } const drawSharedBitmap = (bitmap: ImageBitmap): boolean => { @@ -425,12 +458,13 @@ function VideoSource({ SOURCE_MONITOR_CACHE_TIME_QUANTUM, SOURCE_MONITOR_SHARED_CACHE_WAIT_MS, ).catch(() => null) - if (inflightBitmap && drawSharedBitmap(inflightBitmap)) { - markDecodedFrame() - return true + if (inflightBitmap && isCurrent() && drawSharedBitmap(inflightBitmap)) { + return markDecodedFrame() } } + if (!isCurrent()) return false + const didDraw = await extractor.drawFrame( ctx, Math.max(0, targetTime), @@ -439,10 +473,18 @@ function VideoSource({ canvas.width, canvas.height, ) + if (!isCurrent()) { + ctx.clearRect(0, 0, canvas.width, canvas.height) + return false + } if (!didDraw) return false try { const bitmap = await createImageBitmap(canvas) + if (!isCurrent()) { + bitmap.close() + return false + } cache.set(cacheKey, bitmap) cacheOrder.push(cacheKey) while (cacheOrder.length > SOURCE_MONITOR_FRAME_CACHE_MAX) { @@ -457,8 +499,7 @@ function VideoSource({ // Cache population is best-effort only. } - markDecodedFrame() - return true + return markDecodedFrame() }, [activeSrc], ) @@ -579,12 +620,19 @@ function VideoSource({ const pool = decoderPoolRef.current const extractor = pool.getOrCreateItemExtractor(decoderItemId, activeSrc) extractorRef.current = extractor + const generation = sourceGenerationRef.current let cancelled = false void extractor .init() .then((ready) => { - if (cancelled || !mountedRef.current) return + if ( + cancelled || + !mountedRef.current || + sourceGenerationRef.current !== generation || + extractorRef.current !== extractor + ) + return if (!ready) { setUseLegacyPausedSeek((prev) => (prev ? prev : true)) return @@ -597,7 +645,13 @@ function VideoSource({ } }) .catch(() => { - if (cancelled || !mountedRef.current) return + if ( + cancelled || + !mountedRef.current || + sourceGenerationRef.current !== generation || + extractorRef.current !== extractor + ) + return setUseLegacyPausedSeek((prev) => (prev ? prev : true)) }) diff --git a/src/features/preview/components/video-preview.tsx b/src/features/preview/components/video-preview.tsx index 0405cabb2..5e2fdb216 100644 --- a/src/features/preview/components/video-preview.tsx +++ b/src/features/preview/components/video-preview.tsx @@ -703,6 +703,37 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ setDisplayedFrame, ...previewRuntimeRefs.rendererControllerRefs, }) + + // The renderer replacement is asynchronous, so retire the old front buffer + // in the layout phase. This covers same-ID source swaps and topology changes + // before a stale scrub frame can remain visible for one paint. + useLayoutEffect(() => { + const canvas = scrubCanvasRef.current + if (canvas) { + const context = canvas.getContext('2d') + context?.clearRect(0, 0, canvas.width, canvas.height) + } + const hadFastScrubOverlay = showFastScrubOverlayRef.current + const hadTransitionOverlay = showPlaybackTransitionOverlayRef.current + hideFastScrubOverlay() + hidePlaybackTransitionOverlay() + // Keep the active routing owner alive so its replacement render can be + // scheduled in the same commit; the cleared canvas is the synchronous + // stale-pixel barrier. + if (hadFastScrubOverlay) showFastScrubOverlayForFrame() + else if (hadTransitionOverlay) showPlaybackTransitionOverlayForFrame() + }, [ + fastScrubRendererStructureKey, + hideFastScrubOverlay, + hidePlaybackTransitionOverlay, + mediaDependencyVersion, + scrubCanvasRef, + showFastScrubOverlayForFrame, + showFastScrubOverlayRef, + showPlaybackTransitionOverlayForFrame, + showPlaybackTransitionOverlayRef, + ]) + useEffect(() => { if (!shouldWarmGpuEffectsRenderer || isResolving) return From 2824a65086200af98fd76f2de5b2026e58e64168 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 20:42:37 -0700 Subject: [PATCH 10/64] test direct export render lifecycle (cherry picked from commit b0111243be1c68516c0635808301c4685ade251a) --- .../export/hooks/use-client-render.test.tsx | 148 ++++++++++++++++++ .../export/hooks/use-client-render.ts | 6 +- 2 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 src/features/export/hooks/use-client-render.test.tsx diff --git a/src/features/export/hooks/use-client-render.test.tsx b/src/features/export/hooks/use-client-render.test.tsx new file mode 100644 index 000000000..0a5c4c06a --- /dev/null +++ b/src/features/export/hooks/use-client-render.test.tsx @@ -0,0 +1,148 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' + +const mocks = vi.hoisted(() => ({ + resolveMediaUrls: vi.fn(), + runRender: vi.fn(), + resolveClientSettings: vi.fn(), + mapRequestedClientSettings: vi.fn(), + trySmartCopyExport: vi.fn(), + convertTimelineToComposition: vi.fn(), + buildTranscriptSubtitleCues: vi.fn(), + releaseTemporaryExportOutput: vi.fn(), + setResult: vi.fn(), +})) + +vi.mock('@/features/export/deps/media-library', () => ({ resolveMediaUrls: mocks.resolveMediaUrls })) +vi.mock('../utils/smart-copy', () => ({ trySmartCopyExport: mocks.trySmartCopyExport })) +vi.mock('../utils/render-pipeline', () => ({ + isExtendedSettings: (settings: unknown) => typeof settings === 'object' && settings !== null && 'mode' in settings, + mapRequestedClientSettings: mocks.mapRequestedClientSettings, + resolveClientSettings: mocks.resolveClientSettings, + runRender: mocks.runRender, +})) +vi.mock('../utils/timeline-to-composition', () => ({ + convertTimelineToComposition: mocks.convertTimelineToComposition, +})) +vi.mock('../utils/embedded-subtitle-export', () => ({ + buildTranscriptSubtitleCues: mocks.buildTranscriptSubtitleCues, +})) +vi.mock('@/shared/utils/subtitles', () => ({ serializeSrt: vi.fn(() => '') })) +vi.mock('../utils/export-output-target', () => ({ + releaseTemporaryExportOutput: mocks.releaseTemporaryExportOutput, +})) +vi.mock('../utils/client-renderer', () => ({ + formatBytes: (bytes: number) => `${bytes} bytes`, + estimateFileSize: vi.fn(() => 1), + getSupportedCodecs: vi.fn(async () => []), + getVideoBitrateForQuality: vi.fn(() => 1), + mapToClientSettings: vi.fn(() => ({})), +})) +vi.mock('@/features/export/deps/timeline', () => ({ + useTimelineStore: { getState: () => ({ tracks: [], items: [], transitions: [], fps: 30, inPoint: null, outPoint: null, keyframes: [], busAudioEq: [], masterBusDb: 0, backgroundColor: '#000', width: 1920, height: 1080 }) }, +})) +vi.mock('@/features/export/deps/projects', () => ({ + useProjectStore: { getState: () => ({ currentProject: null }) }, +})) +vi.mock('@/shared/state/playback', () => ({ + usePlaybackStore: { getState: () => ({}) }, +})) +vi.mock('./client-render-source', () => ({ + resolveClientRenderSource: (_sequence: unknown, state: unknown) => state, +})) +vi.mock('@/shared/projects/defaults', () => ({ DEFAULT_PROJECT_WIDTH: 1920, DEFAULT_PROJECT_HEIGHT: 1080 })) +vi.mock('@/shared/logging/logger', () => ({ + createLogger: () => ({ + startEvent: () => ({ set: vi.fn(), merge: vi.fn(), success: vi.fn(), failure: vi.fn() }), + warn: vi.fn(), + event: vi.fn(), + }), + createOperationId: () => 'test-op', +})) + +import { useClientRender } from './use-client-render' + +const settings = { quality: 'medium', resolution: { width: 640, height: 360 } } as Record +const renderedResult = { + blob: new Blob(['encoded']), fileSize: 7, duration: 1, mimeType: 'video/mp4', +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.trySmartCopyExport.mockResolvedValue({ result: null }) + const clientSettings = { resolution: { width: 640, height: 360 }, subtitleMode: 'burn', codec: 'avc', container: 'mp4' } + mocks.mapRequestedClientSettings.mockReturnValue({ clientSettings, exportMode: 'video', renderWholeProject: false }) + mocks.resolveClientSettings.mockImplementation(async (settings: { subtitleMode?: string }) => ({ + clientSettings: { ...clientSettings, subtitleMode: settings.subtitleMode ?? 'burn' }, + exportMode: 'video', renderWholeProject: false, + })) + mocks.convertTimelineToComposition.mockReturnValue({ tracks: [], durationInFrames: 30 }) + mocks.resolveMediaUrls.mockImplementation(async (tracks: unknown) => tracks) + mocks.buildTranscriptSubtitleCues.mockReturnValue([]) + mocks.releaseTemporaryExportOutput.mockResolvedValue(undefined) +}) + +afterEach(() => vi.restoreAllMocks()) + +describe('useClientRender lifecycle ownership', () => { + it('aborts the active render on unmount and propagates its signal through media resolution', async () => { + const render = deferred() + mocks.runRender.mockReturnValue(render.promise.then((result) => ({ result, renderPath: 'worker' }))) + const hook = renderHook(() => useClientRender()) + + let exportPromise!: Promise + await act(async () => { exportPromise = hook.result.current.startExport(settings as never); await Promise.resolve() }) + expect(mocks.resolveMediaUrls).toHaveBeenCalledWith([], expect.objectContaining({ useProxy: false, signal: expect.any(AbortSignal) })) + const signal = mocks.resolveMediaUrls.mock.calls[0]![1].signal as AbortSignal + hook.unmount() + expect(signal.aborted).toBe(true) + render.resolve(renderedResult) + await act(async () => { await exportPromise }) + }) + + it('aborts replacement renders and releases a result that becomes stale', async () => { + const first = deferred() + const second = deferred() + mocks.runRender.mockReturnValueOnce(first.promise.then((result) => ({ result, renderPath: 'worker' }))) + .mockReturnValueOnce(second.promise.then((result) => ({ result, renderPath: 'worker' }))) + const hook = renderHook(() => useClientRender()) + let firstExport!: Promise + await act(async () => { firstExport = hook.result.current.startExport(settings as never); await Promise.resolve() }) + const firstSignal = mocks.runRender.mock.calls[0]![0].signal as AbortSignal + let secondExport!: Promise + await act(async () => { secondExport = hook.result.current.startExport(settings as never); await Promise.resolve() }) + expect(firstSignal.aborted).toBe(true) + first.resolve(renderedResult) + second.resolve({ ...renderedResult, blob: new Blob(['second']) }) + await act(async () => { await Promise.all([firstExport, secondExport]) }) + expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledWith(renderedResult) + }) + + it('releases a rendered output when finalization/update work throws before ownership transfer', async () => { + const output = { ...renderedResult, temporaryOutput: { directory: 'scratch', fileName: 'out.mp4' } } + mocks.runRender.mockResolvedValue({ result: output, renderPath: 'worker' }) + mocks.buildTranscriptSubtitleCues.mockImplementation(() => { throw new Error('state/update failed') }) + const hook = renderHook(() => useClientRender()) + await act(async () => { await hook.result.current.startExport({ ...settings, subtitleMode: 'sidecar' } as never) }) + expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledTimes(1) + expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledWith(output) + }) + + it('releases a successfully owned result once on unmount, with reset/unmount causing no double release', async () => { + mocks.runRender.mockResolvedValue({ result: renderedResult, renderPath: 'worker' }) + const hook = renderHook(() => useClientRender()) + await act(async () => { await hook.result.current.startExport(settings as never) }) + expect(hook.result.current.result).toBe(renderedResult) + act(() => hook.result.current.resetState()) + hook.unmount() + expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledTimes(1) + expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledWith(renderedResult) + }) +}) diff --git a/src/features/export/hooks/use-client-render.ts b/src/features/export/hooks/use-client-render.ts index c80fd7eab..f42265745 100644 --- a/src/features/export/hooks/use-client-render.ts +++ b/src/features/export/hooks/use-client-render.ts @@ -327,8 +327,8 @@ export function useClientRender(): UseClientRenderReturn { signal, onProgress: handleProgress, }) - ensureActive() temporaryResult = renderResult + ensureActive() if (fallbackReason) event.set('workerFallbackReason', fallbackReason) // Sidecar mode: the video is muxed clean; build the .srt from the same @@ -457,7 +457,7 @@ export function useClientRender(): UseClientRenderReturn { setError(null) const previousResult = resultRef.current resultRef.current = null - void releaseTemporaryExportOutput(previousResult) + if (previousResult) void releaseTemporaryExportOutput(previousResult) setResult(null) }, []) @@ -468,7 +468,7 @@ export function useClientRender(): UseClientRenderReturn { abortControllerRef.current = null const ownedResult = resultRef.current resultRef.current = null - void releaseTemporaryExportOutput(ownedResult) + if (ownedResult) void releaseTemporaryExportOutput(ownedResult) }, [], ) From 237b707cb709cfe21e25e81ef2786733362f51c9 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 20:43:25 -0700 Subject: [PATCH 11/64] Complete atomic clipboard lane recovery (cherry picked from commit 8e882ebbadb5e5a9ad920126998ff54728465cef) --- .../use-clipboard-shortcuts.test.tsx | 60 +++++++++++++++++-- .../shortcuts/use-clipboard-shortcuts.ts | 42 ++++++++++--- .../timeline/stores/timeline-store-facade.ts | 1 + src/features/timeline/types.ts | 1 + 4 files changed, 90 insertions(+), 14 deletions(-) diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx index 89c875bc2..01270125a 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx @@ -7,11 +7,17 @@ import type { AudioItem, TextItem, TimelineItem, TimelineTrack, VideoItem } from import { useCompositionNavigationStore } from '../../stores/composition-navigation-store' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' import { useTimelineStore } from '../../stores/timeline-store' +import { useTimelineCommandStore } from '../../stores/timeline-command-store' import { useClipboardShortcuts } from './use-clipboard-shortcuts' const { addItemsMock, playbackState, useHotkeysMock } = vi.hoisted(() => ({ addItemsMock: vi.fn(), - playbackState: { currentFrame: 200 }, + playbackState: { + currentFrame: 200, + setCurrentFrame: vi.fn(), + setBusAudioEq: vi.fn(), + setMasterBusDb: vi.fn(), + }, useHotkeysMock: vi.fn(), })) @@ -149,6 +155,7 @@ describe('useClipboardShortcuts paste placement', () => { useCompositionNavigationStore.setState({ activeCompositionId: null }) useClipboardStore.setState({ itemsClipboard: null, transitionClipboard: null }) playbackState.currentFrame = 200 + useTimelineCommandStore.getState().clearHistory() }) afterEach(() => { @@ -182,7 +189,7 @@ describe('useClipboardShortcuts paste placement', () => { .copyItems( [ makeVideoItem({ id: 'first', label: 'First', trackId: 'missing-v1', from: 40 }), - makeVideoItem({ id: 'second', label: 'Second', trackId: 'missing-v2', from: 45 }), + makeVideoItem({ id: 'second', label: 'Second', trackId: 'missing-v1', from: 45 }), ], 0, 'copy', @@ -249,7 +256,7 @@ describe('useClipboardShortcuts paste placement', () => { makeAudioItem({ id: 'a1', trackId: 'source-a1', linkedGroupId: 'pair-1' }), makeVideoItem({ id: 'v2', trackId: 'source-v2', from: 20, linkedGroupId: 'pair-2' }), makeAudioItem({ id: 'a2', trackId: 'source-a2', from: 20, linkedGroupId: 'pair-2' }), - makeCaptionItem({ id: 'caption', trackId: 'source-caption', from: 20 }), + makeCaptionItem({ id: 'caption', trackId: 'source-v2', from: 20 }), ], 0, 'copy', @@ -264,9 +271,9 @@ describe('useClipboardShortcuts paste placement', () => { AUDIO_TRACK.id, SECOND_VIDEO_TRACK.id, SECOND_AUDIO_TRACK.id, - TARGET_TRACK.id, + SECOND_VIDEO_TRACK.id, ]) - expect(plannedItems.filter((item) => item.type === 'text')[0]?.trackId).toBe(TARGET_TRACK.id) + expect(plannedItems.filter((item) => item.type === 'text')[0]?.trackId).toBe(SECOND_VIDEO_TRACK.id) }) it('uses surviving IDs while resolving missing linked members by kind', () => { @@ -285,4 +292,47 @@ describe('useClipboardShortcuts paste placement', () => { expect(getPlannedItems().map((item) => item.trackId)).toEqual([TARGET_TRACK.id, AUDIO_TRACK.id]) }) + + it('splits malformed overlapping members of one linked group safely', () => { + useClipboardStore.getState().copyItems( + [ + makeVideoItem({ id: 'overlap-1', trackId: 'same-source', linkedGroupId: 'bad-group' }), + makeVideoItem({ id: 'overlap-2', trackId: 'same-source', linkedGroupId: 'bad-group' }), + ], + 0, + 'copy', + ) + + render() + act(() => getPasteCallback()({ preventDefault: vi.fn() })) + + const plannedItems = getPlannedItems() + expect(plannedItems.map((item) => item.from)).toEqual([200, 210]) + expect(plannedItems[0]!.from + plannedItems[0]!.durationInFrames).toBeLessThanOrEqual( + plannedItems[1]!.from, + ) + }) + + it('creates deterministic compatible lanes and undoes tracks and items together', () => { + useClipboardStore.getState().copyItems( + [ + makeVideoItem({ id: 'lane-1', trackId: 'missing-v1', from: 0 }), + makeVideoItem({ id: 'lane-2', trackId: 'missing-v2', from: 0 }), + ], + 0, + 'copy', + ) + + render() + act(() => getPasteCallback()({ preventDefault: vi.fn() })) + + expect(useTimelineStore.getState().tracks.map((track) => track.kind)).toEqual(['video', 'video']) + expect(useTimelineStore.getState().items).toHaveLength(2) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + + act(() => useTimelineCommandStore.getState().undo()) + expect(useTimelineStore.getState().tracks).toHaveLength(1) + expect(useTimelineStore.getState().tracks[0]?.id).toBe(TARGET_TRACK.id) + expect(useTimelineStore.getState().items).toEqual([]) + }) }) diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts index ff8ce3528..afa41464a 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts @@ -21,7 +21,7 @@ import { wouldCreateCompositionCycle, } from '../../utils/composition-graph' import { handleTranscriptClipboardCopy } from '../../utils/transcript-copy-bridge' -import { getTrackKind } from '../../utils/classic-tracks' +import { createClassicTrack, getTrackKind } from '../../utils/classic-tracks' interface PastePlacementPlan { itemData: Omit @@ -82,12 +82,13 @@ function buildPasteTrackPlan( pasteItems: Array>, tracks: TimelineTrack[], activeTrackId: string | null, -): Map { +): { plan: Map; tracks: TimelineTrack[] } { + let plannedTracks = tracks const sourceTrackIndexes = new Map() const nextSourceIndex: Record = { video: 0, audio: 0 } - const destinationTrackIds: Record = { - video: getPasteDestinationTrackIds(tracks, 'video'), - audio: getPasteDestinationTrackIds(tracks, 'audio'), + const destinationTrackIds: Record = { video: [], audio: [] } + for (const kind of ['video', 'audio'] as const) { + destinationTrackIds[kind] = getPasteDestinationTrackIds(plannedTracks, kind) } const preserveSourceTracks = new Set(pasteItems.map((item) => item.trackId)).size > 1 const plan = new Map() @@ -108,6 +109,17 @@ function buildPasteTrackPlan( (track) => track.id === activeTrackId && isCompatiblePasteTrack(track, kind), ) const candidates = destinationTrackIds[kind] + while (preserveSourceTracks && sourceLane >= candidates.length) { + const minOrder = Math.min(...plannedTracks.map((track) => track.order), 0) + const maxOrder = Math.max(...plannedTracks.map((track) => track.order), 0) + const newTrack = createClassicTrack({ + tracks: plannedTracks, + kind, + order: kind === 'video' ? minOrder - 1 : maxOrder + 1, + }) + plannedTracks = [...plannedTracks, newTrack] + candidates.push(newTrack.id) + } const targetTrackId = preserveSourceTracks ? (exactTrack?.id ?? candidates[sourceLane] ?? candidates[0] ?? activeTrack?.id) : (activeTrack?.id ?? exactTrack?.id ?? candidates[sourceLane] ?? candidates[0]) @@ -117,7 +129,7 @@ function buildPasteTrackPlan( } } - return plan + return { plan, tracks: plannedTracks } } function findSharedPlacementShift( @@ -202,6 +214,7 @@ export function useClipboardShortcuts() { const transitions = useTimelineStore((s) => s.transitions) const tracks = useTimelineStore((s) => s.tracks) const addItems = useTimelineStore((s) => s.addItems) + const addItemsOnNewTracks = useTimelineStore((s) => s.addItemsOnNewTracks) const removeItems = useTimelineStore((s) => s.removeItems) const updateTransition = useTimelineStore((s) => s.updateTransition) const copyTransition = useClipboardStore((s) => s.copyTransition) @@ -331,7 +344,11 @@ export function useClipboardShortcuts() { // they survive in this sequence; otherwise the source lane ordinal is // mapped to the corresponding destination lane. This keeps linked A/V // members in separate sections even when both source IDs are absent. - const trackPlan = buildPasteTrackPlan(pasteItems, tracks, activeTrackId) + const { plan: trackPlan, tracks: plannedTracks } = buildPasteTrackPlan( + pasteItems, + tracks, + activeTrackId, + ) const placementPlans = pasteItems.flatMap((itemData, sourceIndex) => { const targetTrackId = trackPlan.get(sourceIndex) return targetTrackId @@ -359,7 +376,9 @@ export function useClipboardShortcuts() { group.push(plan) grouped.set(key, group) } - placementGroups = [...grouped.values()] + placementGroups = [...grouped.values()].flatMap((group) => + hasInternalPlacementOverlap(group) ? group.map((plan) => [plan]) : [group], + ) } const occupiedItems = [...storeItems] @@ -392,7 +411,11 @@ export function useClipboardShortcuts() { // Add every pasted item in a single ADD_ITEMS command so one Ctrl+Z // undoes the whole paste (including a linked A/V pair), not item-by-item. if (newItems.length > 0) { - addItems(newItems) + if (plannedTracks.length > tracks.length) { + addItemsOnNewTracks(newItems, plannedTracks) + } else { + addItems(newItems) + } } if (newItemIds.length > 0) { @@ -431,6 +454,7 @@ export function useClipboardShortcuts() { itemsClipboard, tracks, addItems, + addItemsOnNewTracks, selectItems, activeTrackId, selectedKeyframes.length, diff --git a/src/features/timeline/stores/timeline-store-facade.ts b/src/features/timeline/stores/timeline-store-facade.ts index c3cab19b8..4379c0459 100644 --- a/src/features/timeline/stores/timeline-store-facade.ts +++ b/src/features/timeline/stores/timeline-store-facade.ts @@ -113,6 +113,7 @@ function getSnapshot(): TimelineState & TimelineActions { addItems: timelineActions.addItems, addItemWithLinkedAudio: timelineActions.addItemWithLinkedAudio, addItemOnNewTrack: timelineActions.addItemOnNewTrack, + addItemsOnNewTracks: timelineActions.addItemsOnNewTracks, updateItem: timelineActions.updateItem, removeItems: timelineActions.removeItems, rippleDeleteItems: timelineActions.rippleDeleteItems, diff --git a/src/features/timeline/types.ts b/src/features/timeline/types.ts index cec02666f..e4cc36f90 100644 --- a/src/features/timeline/types.ts +++ b/src/features/timeline/types.ts @@ -68,6 +68,7 @@ export interface TimelineActions { addItems: (items: TimelineItem[]) => void addItemWithLinkedAudio: (video: VideoItem) => void addItemOnNewTrack: (item: TimelineItem, tracks: TimelineTrack[]) => void + addItemsOnNewTracks: (items: TimelineItem[], tracks: TimelineTrack[]) => void updateItem: (id: string, updates: Partial) => void removeItems: (ids: string[]) => void rippleDeleteItems: (ids: string[]) => void From 92d550d9156ace2cd73a887e37eabf4965904f4c Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 20:43:08 -0700 Subject: [PATCH 12/64] test(preview): guard deferred source replacement work (cherry picked from commit 74027387c0227be8c95df2f5410acf04d98572d6) --- .../preview/components/source-composition.tsx | 20 +++++++++++++++---- .../preview/components/video-preview.tsx | 6 +++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/features/preview/components/source-composition.tsx b/src/features/preview/components/source-composition.tsx index ca7d14813..f10173ed9 100644 --- a/src/features/preview/components/source-composition.tsx +++ b/src/features/preview/components/source-composition.tsx @@ -414,6 +414,15 @@ function VideoSource({ canvas.height = targetHeight } + // Keep deferred extractor work away from the visible presentation. A + // decoder can paint before its promise settles, so only commit staged + // pixels after validating the source generation. + const stagingCanvas = document.createElement('canvas') + stagingCanvas.width = targetWidth + stagingCanvas.height = targetHeight + const stagingContext = stagingCanvas.getContext('2d') + if (!stagingContext) return false + const cacheKey = quantizeSourceMonitorTime(targetTime) const markDecodedFrame = () => { if (!isCurrent()) return false @@ -466,12 +475,12 @@ function VideoSource({ if (!isCurrent()) return false const didDraw = await extractor.drawFrame( - ctx, + stagingContext, Math.max(0, targetTime), 0, 0, - canvas.width, - canvas.height, + stagingCanvas.width, + stagingCanvas.height, ) if (!isCurrent()) { ctx.clearRect(0, 0, canvas.width, canvas.height) @@ -479,8 +488,11 @@ function VideoSource({ } if (!didDraw) return false + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(stagingCanvas, 0, 0, canvas.width, canvas.height) + try { - const bitmap = await createImageBitmap(canvas) + const bitmap = await createImageBitmap(stagingCanvas) if (!isCurrent()) { bitmap.close() return false diff --git a/src/features/preview/components/video-preview.tsx b/src/features/preview/components/video-preview.tsx index 5e2fdb216..92d05c534 100644 --- a/src/features/preview/components/video-preview.tsx +++ b/src/features/preview/components/video-preview.tsx @@ -708,6 +708,10 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ // in the layout phase. This covers same-ID source swaps and topology changes // before a stale scrub frame can remain visible for one paint. useLayoutEffect(() => { + // Advance the render generation before replacement work can start. The + // controller's async pump checks this generation and cannot publish the + // disposed renderer's result afterward. + disposeFastScrubRenderer() const canvas = scrubCanvasRef.current if (canvas) { const context = canvas.getContext('2d') @@ -723,10 +727,10 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ if (hadFastScrubOverlay) showFastScrubOverlayForFrame() else if (hadTransitionOverlay) showPlaybackTransitionOverlayForFrame() }, [ + disposeFastScrubRenderer, fastScrubRendererStructureKey, hideFastScrubOverlay, hidePlaybackTransitionOverlay, - mediaDependencyVersion, scrubCanvasRef, showFastScrubOverlayForFrame, showFastScrubOverlayRef, From 8a6deac1a7c68ce0445421041af5a51c760ec11e Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 20:54:09 -0700 Subject: [PATCH 13/64] fix(editor): close hardening review gaps (cherry picked from commit fadb8a6e8ebc5a88dbac1cc66d85fbd9b87f3479) --- package.json | 1 + .../export/hooks/use-client-render.test.tsx | 112 +++++-- .../source-composition.generation.test.tsx | 309 ++++++++++++++++++ .../preview/components/source-composition.tsx | 17 +- .../components/video-preview.sync.test.tsx | 106 ++++++ .../use-clipboard-shortcuts.test.tsx | 171 +++++++--- .../shortcuts/use-clipboard-shortcuts.ts | 193 ++++++++--- .../stores/actions/export-snapshot.ts | 3 +- .../timeline/stores/timeline-persistence.ts | 13 +- 9 files changed, 794 insertions(+), 131 deletions(-) create mode 100644 src/features/preview/components/source-composition.generation.test.tsx diff --git a/package.json b/package.json index 0c4883e4e..7f9495514 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "routes": "tsr generate", "test": "vp test", "test:run": "vp test run", + "test:editor-hardening": "vp test run src/features/timeline/components/timeline-content.test.tsx src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx src/features/timeline/stores/export-snapshot.test.ts src/features/export/components/export-dialog.test.tsx src/features/export/hooks/client-render-source.test.ts src/features/export/hooks/use-client-render.test.tsx src/features/preview/workers/consume-video-samples.test.ts src/features/preview/utils/media-resolver.test.ts src/features/preview/components/source-composition.generation.test.tsx src/features/preview/components/video-preview.sync.test.tsx", "test:preview-sync": "vp test run src/features/preview/components/video-preview.sync.test.tsx", "test:preview-sync:stress": "node scripts/preview-sync-stress.mjs --runs 20", "test:coverage": "vp test run --coverage", diff --git a/src/features/export/hooks/use-client-render.test.tsx b/src/features/export/hooks/use-client-render.test.tsx index 0a5c4c06a..9d3fec18c 100644 --- a/src/features/export/hooks/use-client-render.test.tsx +++ b/src/features/export/hooks/use-client-render.test.tsx @@ -13,10 +13,13 @@ const mocks = vi.hoisted(() => ({ setResult: vi.fn(), })) -vi.mock('@/features/export/deps/media-library', () => ({ resolveMediaUrls: mocks.resolveMediaUrls })) +vi.mock('@/features/export/deps/media-library', () => ({ + resolveMediaUrls: mocks.resolveMediaUrls, +})) vi.mock('../utils/smart-copy', () => ({ trySmartCopyExport: mocks.trySmartCopyExport })) vi.mock('../utils/render-pipeline', () => ({ - isExtendedSettings: (settings: unknown) => typeof settings === 'object' && settings !== null && 'mode' in settings, + isExtendedSettings: (settings: unknown) => + typeof settings === 'object' && settings !== null && 'mode' in settings, mapRequestedClientSettings: mocks.mapRequestedClientSettings, resolveClientSettings: mocks.resolveClientSettings, runRender: mocks.runRender, @@ -39,7 +42,22 @@ vi.mock('../utils/client-renderer', () => ({ mapToClientSettings: vi.fn(() => ({})), })) vi.mock('@/features/export/deps/timeline', () => ({ - useTimelineStore: { getState: () => ({ tracks: [], items: [], transitions: [], fps: 30, inPoint: null, outPoint: null, keyframes: [], busAudioEq: [], masterBusDb: 0, backgroundColor: '#000', width: 1920, height: 1080 }) }, + useTimelineStore: { + getState: () => ({ + tracks: [], + items: [], + transitions: [], + fps: 30, + inPoint: null, + outPoint: null, + keyframes: [], + busAudioEq: [], + masterBusDb: 0, + backgroundColor: '#000', + width: 1920, + height: 1080, + }), + }, })) vi.mock('@/features/export/deps/projects', () => ({ useProjectStore: { getState: () => ({ currentProject: null }) }, @@ -50,7 +68,10 @@ vi.mock('@/shared/state/playback', () => ({ vi.mock('./client-render-source', () => ({ resolveClientRenderSource: (_sequence: unknown, state: unknown) => state, })) -vi.mock('@/shared/projects/defaults', () => ({ DEFAULT_PROJECT_WIDTH: 1920, DEFAULT_PROJECT_HEIGHT: 1080 })) +vi.mock('@/shared/projects/defaults', () => ({ + DEFAULT_PROJECT_WIDTH: 1920, + DEFAULT_PROJECT_HEIGHT: 1080, +})) vi.mock('@/shared/logging/logger', () => ({ createLogger: () => ({ startEvent: () => ({ set: vi.fn(), merge: vi.fn(), success: vi.fn(), failure: vi.fn() }), @@ -62,26 +83,45 @@ vi.mock('@/shared/logging/logger', () => ({ import { useClientRender } from './use-client-render' -const settings = { quality: 'medium', resolution: { width: 640, height: 360 } } as Record +const settings = { quality: 'medium', resolution: { width: 640, height: 360 } } as Record< + string, + unknown +> const renderedResult = { - blob: new Blob(['encoded']), fileSize: 7, duration: 1, mimeType: 'video/mp4', + blob: new Blob(['encoded']), + fileSize: 7, + duration: 1, + mimeType: 'video/mp4', } function deferred() { let resolve!: (value: T) => void let reject!: (error: unknown) => void - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) return { promise, resolve, reject } } beforeEach(() => { vi.clearAllMocks() mocks.trySmartCopyExport.mockResolvedValue({ result: null }) - const clientSettings = { resolution: { width: 640, height: 360 }, subtitleMode: 'burn', codec: 'avc', container: 'mp4' } - mocks.mapRequestedClientSettings.mockReturnValue({ clientSettings, exportMode: 'video', renderWholeProject: false }) + const clientSettings = { + resolution: { width: 640, height: 360 }, + subtitleMode: 'burn', + codec: 'avc', + container: 'mp4', + } + mocks.mapRequestedClientSettings.mockReturnValue({ + clientSettings, + exportMode: 'video', + renderWholeProject: false, + }) mocks.resolveClientSettings.mockImplementation(async (settings: { subtitleMode?: string }) => ({ clientSettings: { ...clientSettings, subtitleMode: settings.subtitleMode ?? 'burn' }, - exportMode: 'video', renderWholeProject: false, + exportMode: 'video', + renderWholeProject: false, })) mocks.convertTimelineToComposition.mockReturnValue({ tracks: [], durationInFrames: 30 }) mocks.resolveMediaUrls.mockImplementation(async (tracks: unknown) => tracks) @@ -94,43 +134,69 @@ afterEach(() => vi.restoreAllMocks()) describe('useClientRender lifecycle ownership', () => { it('aborts the active render on unmount and propagates its signal through media resolution', async () => { const render = deferred() - mocks.runRender.mockReturnValue(render.promise.then((result) => ({ result, renderPath: 'worker' }))) + mocks.runRender.mockReturnValue( + render.promise.then((result) => ({ result, renderPath: 'worker' })), + ) const hook = renderHook(() => useClientRender()) let exportPromise!: Promise - await act(async () => { exportPromise = hook.result.current.startExport(settings as never); await Promise.resolve() }) - expect(mocks.resolveMediaUrls).toHaveBeenCalledWith([], expect.objectContaining({ useProxy: false, signal: expect.any(AbortSignal) })) + await act(async () => { + exportPromise = hook.result.current.startExport(settings as never) + await Promise.resolve() + }) + expect(mocks.resolveMediaUrls).toHaveBeenCalledWith( + [], + expect.objectContaining({ useProxy: false, signal: expect.any(AbortSignal) }), + ) const signal = mocks.resolveMediaUrls.mock.calls[0]![1].signal as AbortSignal hook.unmount() expect(signal.aborted).toBe(true) render.resolve(renderedResult) - await act(async () => { await exportPromise }) + await act(async () => { + await exportPromise + }) }) it('aborts replacement renders and releases a result that becomes stale', async () => { const first = deferred() const second = deferred() - mocks.runRender.mockReturnValueOnce(first.promise.then((result) => ({ result, renderPath: 'worker' }))) + mocks.runRender + .mockReturnValueOnce(first.promise.then((result) => ({ result, renderPath: 'worker' }))) .mockReturnValueOnce(second.promise.then((result) => ({ result, renderPath: 'worker' }))) const hook = renderHook(() => useClientRender()) let firstExport!: Promise - await act(async () => { firstExport = hook.result.current.startExport(settings as never); await Promise.resolve() }) + await act(async () => { + firstExport = hook.result.current.startExport(settings as never) + await Promise.resolve() + }) const firstSignal = mocks.runRender.mock.calls[0]![0].signal as AbortSignal let secondExport!: Promise - await act(async () => { secondExport = hook.result.current.startExport(settings as never); await Promise.resolve() }) + await act(async () => { + secondExport = hook.result.current.startExport(settings as never) + await Promise.resolve() + }) expect(firstSignal.aborted).toBe(true) first.resolve(renderedResult) second.resolve({ ...renderedResult, blob: new Blob(['second']) }) - await act(async () => { await Promise.all([firstExport, secondExport]) }) + await act(async () => { + await Promise.all([firstExport, secondExport]) + }) expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledWith(renderedResult) }) it('releases a rendered output when finalization/update work throws before ownership transfer', async () => { - const output = { ...renderedResult, temporaryOutput: { directory: 'scratch', fileName: 'out.mp4' } } + const output = { + ...renderedResult, + temporaryOutput: { directory: 'scratch', fileName: 'out.mp4' }, + } mocks.runRender.mockResolvedValue({ result: output, renderPath: 'worker' }) - mocks.buildTranscriptSubtitleCues.mockImplementation(() => { throw new Error('state/update failed') }) + mocks.buildTranscriptSubtitleCues.mockImplementation(() => { + throw new Error('state/update failed') + }) const hook = renderHook(() => useClientRender()) - await act(async () => { await hook.result.current.startExport({ ...settings, subtitleMode: 'sidecar' } as never) }) + await act(async () => { + await hook.result.current.startExport({ ...settings, subtitleMode: 'sidecar' } as never) + }) expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledTimes(1) expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledWith(output) }) @@ -138,7 +204,9 @@ describe('useClientRender lifecycle ownership', () => { it('releases a successfully owned result once on unmount, with reset/unmount causing no double release', async () => { mocks.runRender.mockResolvedValue({ result: renderedResult, renderPath: 'worker' }) const hook = renderHook(() => useClientRender()) - await act(async () => { await hook.result.current.startExport(settings as never) }) + await act(async () => { + await hook.result.current.startExport(settings as never) + }) expect(hook.result.current.result).toBe(renderedResult) act(() => hook.result.current.resetState()) hook.unmount() diff --git a/src/features/preview/components/source-composition.generation.test.tsx b/src/features/preview/components/source-composition.generation.test.tsx new file mode 100644 index 000000000..aa1458de9 --- /dev/null +++ b/src/features/preview/components/source-composition.generation.test.tsx @@ -0,0 +1,309 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +const decoderHarness = vi.hoisted(() => ({ + extractors: new Map< + string, + { + init: ReturnType + drawFrame: ReturnType + getDimensions: ReturnType + getDuration: ReturnType + getLastFailureKind: ReturnType + } + >(), + waitForInflightPredecodedBitmap: vi.fn(), + createImageBitmap: vi.fn(), +})) + +const clockHarness = vi.hoisted(() => ({ + clock: { + currentFrame: 0, + onFrameChange: vi.fn(() => () => {}), + }, +})) + +vi.mock('@/features/preview/deps/player-core', () => ({ + AbsoluteFill: ({ children }: { children?: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/features/preview/deps/player-context', () => ({ + useClock: () => clockHarness.clock, + useClockIsPlaying: () => false, + useClockPlaybackRate: () => 1, + usePlayer: () => ({ seek: vi.fn() }), + useVideoConfig: () => ({ fps: 30, durationInFrames: 30 }), +})) + +vi.mock('@/features/preview/deps/player-pool', () => ({ + getGlobalVideoSourcePool: () => ({ + preloadSource: vi.fn(async () => {}), + acquireForClip: vi.fn(() => null), + releaseClip: vi.fn(), + seekClip: vi.fn(), + }), +})) + +vi.mock('@/features/preview/deps/export', () => ({ + SharedVideoExtractorPool: class SharedVideoExtractorPool { + getOrCreateItemExtractor(_itemId: string, src: string) { + const extractor = decoderHarness.extractors.get(src) + if (!extractor) throw new Error(`Missing extractor for ${src}`) + return extractor + } + + releaseItem() {} + }, +})) + +vi.mock('../utils/media-resolver', () => ({ resolveProxyUrl: () => null })) +vi.mock('../utils/decoder-prewarm', () => ({ + backgroundBatchPreseek: vi.fn(async () => {}), + getCachedPredecodedBitmap: vi.fn(() => null), + waitForInflightPredecodedBitmap: decoderHarness.waitForInflightPredecodedBitmap, +})) +vi.mock('../utils/fast-scrub-prewarm', () => ({ getDirectionalPrewarmOffsets: () => [] })) +vi.mock('../utils/source-media-sync', () => ({ shouldSeekPlayingMedia: () => false })) +vi.mock('./source-audio-waveform', () => ({ SourceAudioWaveform: () => null })) +vi.mock('@/infrastructure/lottie/lottie-frame-provider', () => ({ LottieRenderer: class {} })) + +vi.mock('@/shared/state/playback', () => { + const state = { useProxy: false } + const usePlaybackStore = Object.assign( + (selector: (value: typeof state) => unknown) => selector(state), + { getState: () => state }, + ) + return { usePlaybackStore } +}) + +vi.mock('@/shared/state/source-player', () => { + const state = { + currentSourceFrame: 0, + previewSourceFrame: null as number | null, + setCurrentSourceFrame: vi.fn(), + } + const useSourcePlayerStore = Object.assign( + (selector: (value: typeof state) => unknown) => selector(state), + { + getState: () => state, + subscribe: () => () => {}, + }, + ) + return { useSourcePlayerStore } +}) + +vi.mock('@/features/preview/deps/media-library', () => ({ + useMediaLibraryStore: (selector: (state: { proxyStatus: Map }) => unknown) => + selector({ proxyStatus: new Map() }), +})) + +import { SourceComposition } from './source-composition' + +type MockCanvasContext = CanvasRenderingContext2D & { + clearRect: ReturnType + drawImage: ReturnType +} + +const canvasContexts = new WeakMap() + +function getCanvasContext(canvas: HTMLCanvasElement): MockCanvasContext { + const existing = canvasContexts.get(canvas) + if (existing) return existing + const context = { + canvas, + clearRect: vi.fn(), + drawImage: vi.fn(), + } as unknown as MockCanvasContext + canvasContexts.set(canvas, context) + return context +} + +function makeExtractor(init: Promise = Promise.resolve(true)) { + return { + init: vi.fn(() => init), + drawFrame: vi.fn(async () => true), + getDimensions: vi.fn(() => ({ width: 4, height: 4 })), + getDuration: vi.fn(() => 1), + getLastFailureKind: vi.fn(() => null), + } +} + +async function flushDeferredWork(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('SourceComposition source generations', () => { + beforeEach(() => { + decoderHarness.extractors.clear() + decoderHarness.waitForInflightPredecodedBitmap.mockReset() + decoderHarness.waitForInflightPredecodedBitmap.mockResolvedValue(null) + decoderHarness.createImageBitmap.mockReset() + decoderHarness.createImageBitmap.mockResolvedValue({ close: vi.fn() }) + vi.stubGlobal('createImageBitmap', decoderHarness.createImageBitmap) + const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext') + ;( + getContextSpy as unknown as { + mockImplementation: ( + implementation: ( + this: HTMLCanvasElement, + contextId: string, + ) => CanvasRenderingContext2D | null, + ) => void + } + ).mockImplementation(function (this: HTMLCanvasElement, contextId) { + return contextId === '2d' ? getCanvasContext(this) : null + }) + vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}) + vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('keeps a deferred old extractor draw off the visible canvas after a same-id src change', async () => { + const oldDraw = deferred() + const oldExtractor = makeExtractor() + oldExtractor.drawFrame.mockReturnValue(oldDraw.promise) + decoderHarness.extractors.set('blob:old', oldExtractor) + decoderHarness.extractors.set('blob:new', makeExtractor(Promise.resolve(false))) + + const rendered = render( + , + ) + await waitFor(() => expect(oldExtractor.drawFrame).toHaveBeenCalledOnce()) + const visibleCanvas = rendered.container.querySelector('canvas')! + const visibleContext = getCanvasContext(visibleCanvas) + visibleContext.clearRect.mockClear() + visibleContext.drawImage.mockClear() + + rendered.rerender() + expect(visibleContext.clearRect).toHaveBeenCalled() + + oldDraw.resolve(true) + await flushDeferredWork() + + expect(visibleContext.drawImage).not.toHaveBeenCalled() + expect(visibleCanvas.style.display).toBe('none') + expect(decoderHarness.createImageBitmap).not.toHaveBeenCalled() + }) + + it('hands the decode pump to the replacement generation after an old draw drains', async () => { + const oldDraw = deferred() + const oldExtractor = makeExtractor() + oldExtractor.drawFrame.mockReturnValue(oldDraw.promise) + const newExtractor = makeExtractor() + decoderHarness.extractors.set('blob:old', oldExtractor) + decoderHarness.extractors.set('blob:new', newExtractor) + + const rendered = render( + , + ) + await waitFor(() => expect(oldExtractor.drawFrame).toHaveBeenCalledOnce()) + const visibleCanvas = rendered.container.querySelector('canvas')! + const visibleContext = getCanvasContext(visibleCanvas) + visibleContext.drawImage.mockClear() + + rendered.rerender() + await waitFor(() => expect(newExtractor.init).toHaveBeenCalledOnce()) + expect(newExtractor.drawFrame).not.toHaveBeenCalled() + + oldDraw.resolve(true) + await flushDeferredWork() + + await waitFor(() => expect(newExtractor.drawFrame).toHaveBeenCalled()) + await waitFor(() => expect(visibleCanvas.style.display).toBe('block')) + expect(visibleContext.drawImage).toHaveBeenCalled() + expect(decoderHarness.createImageBitmap).toHaveBeenCalled() + }) + + it('closes a stale bitmap completion without marking the replacement decoded', async () => { + const staleBitmap = { close: vi.fn() } + const bitmapCompletion = deferred() + decoderHarness.createImageBitmap.mockReturnValueOnce(bitmapCompletion.promise) + decoderHarness.extractors.set('blob:old', makeExtractor()) + decoderHarness.extractors.set('blob:new', makeExtractor(Promise.resolve(false))) + + const rendered = render( + , + ) + await waitFor(() => expect(decoderHarness.createImageBitmap).toHaveBeenCalledOnce()) + const visibleCanvas = rendered.container.querySelector('canvas')! + const visibleContext = getCanvasContext(visibleCanvas) + const drawCountBeforeReset = visibleContext.drawImage.mock.calls.length + + rendered.rerender() + bitmapCompletion.resolve(staleBitmap) + await flushDeferredWork() + + expect(staleBitmap.close).toHaveBeenCalledOnce() + expect(visibleContext.drawImage).toHaveBeenCalledTimes(drawCountBeforeReset) + expect(visibleCanvas.style.display).toBe('none') + }) + + it('ignores an in-flight shared-cache completion from the old source', async () => { + const sharedCompletion = deferred<{ close: ReturnType } | null>() + decoderHarness.waitForInflightPredecodedBitmap.mockReturnValueOnce(sharedCompletion.promise) + const oldExtractor = makeExtractor() + decoderHarness.extractors.set('blob:old', oldExtractor) + decoderHarness.extractors.set('blob:new', makeExtractor(Promise.resolve(false))) + + const rendered = render( + , + ) + await waitFor(() => + expect(decoderHarness.waitForInflightPredecodedBitmap).toHaveBeenCalledOnce(), + ) + const visibleCanvas = rendered.container.querySelector('canvas')! + const visibleContext = getCanvasContext(visibleCanvas) + visibleContext.drawImage.mockClear() + + rendered.rerender() + sharedCompletion.resolve({ close: vi.fn() }) + await flushDeferredWork() + + expect(oldExtractor.drawFrame).not.toHaveBeenCalled() + expect(visibleContext.drawImage).not.toHaveBeenCalled() + expect(visibleCanvas.style.display).toBe('none') + }) + + it('ignores old extractor initialization after unmount and remount', async () => { + const oldInit = deferred() + const oldExtractor = makeExtractor(oldInit.promise) + decoderHarness.extractors.set('blob:old', oldExtractor) + decoderHarness.extractors.set('blob:new', makeExtractor(Promise.resolve(false))) + + const oldRender = render( + , + ) + await waitFor(() => expect(oldExtractor.init).toHaveBeenCalledOnce()) + oldRender.unmount() + + const newRender = render( + , + ) + oldInit.resolve(true) + await flushDeferredWork() + + expect(oldExtractor.drawFrame).not.toHaveBeenCalled() + expect(newRender.container.querySelector('canvas')?.style.display).toBe('none') + }) +}) diff --git a/src/features/preview/components/source-composition.tsx b/src/features/preview/components/source-composition.tsx index f10173ed9..f5d8f9098 100644 --- a/src/features/preview/components/source-composition.tsx +++ b/src/features/preview/components/source-composition.tsx @@ -177,7 +177,8 @@ function VideoSource({ const mountedRef = useRef(true) const sourceGenerationRef = useRef(0) const decoderReadyRef = useRef(false) - const renderInFlightRef = useRef(false) + const renderInFlightGenerationRef = useRef(null) + const pumpLatestDecodedFrameRef = useRef<() => void>(() => {}) const pendingTimeRef = useRef(null) const latestTargetTimeRef = useRef(0) const consecutiveDecodeFailuresRef = useRef(0) @@ -517,12 +518,14 @@ function VideoSource({ ) const pumpLatestDecodedFrame = useCallback(() => { - if (renderInFlightRef.current) return - renderInFlightRef.current = true + if (renderInFlightGenerationRef.current !== null) return + const generation = sourceGenerationRef.current + renderInFlightGenerationRef.current = generation const run = async () => { try { while ( + sourceGenerationRef.current === generation && decoderReadyRef.current && pendingTimeRef.current !== null && mountedRef.current && @@ -552,8 +555,11 @@ function VideoSource({ } } } finally { - renderInFlightRef.current = false + if (renderInFlightGenerationRef.current === generation) { + renderInFlightGenerationRef.current = null + } if ( + renderInFlightGenerationRef.current === null && decoderReadyRef.current && pendingTimeRef.current !== null && mountedRef.current && @@ -561,7 +567,7 @@ function VideoSource({ ) { queueMicrotask(() => { if (!mountedRef.current) return - pumpLatestDecodedFrame() + pumpLatestDecodedFrameRef.current() }) } } @@ -569,6 +575,7 @@ function VideoSource({ void run() }, [drawDecodedFrame, queueDirectionalPrewarm]) + pumpLatestDecodedFrameRef.current = pumpLatestDecodedFrame // Acquire/release pooled element when source changes. useEffect(() => { diff --git a/src/features/preview/components/video-preview.sync.test.tsx b/src/features/preview/components/video-preview.sync.test.tsx index c47e53631..1e6d4b2cb 100644 --- a/src/features/preview/components/video-preview.sync.test.tsx +++ b/src/features/preview/components/video-preview.sync.test.tsx @@ -534,6 +534,24 @@ function getCanvasDrawImageCallCount() { ) } +function getCanvasClearRectCallCount(canvas: HTMLCanvasElement) { + const results = canvasGetContextSpy?.mock.results as + | Array<{ type: string; value: unknown }> + | undefined + return ( + results?.reduce((total: number, result) => { + if (result.type !== 'return' || !result.value) return total + const context = result.value as { + canvas?: HTMLCanvasElement + clearRect?: unknown + } + if (context.canvas !== canvas || typeof context.clearRect !== 'function') return total + if (!('mock' in context.clearRect)) return total + return total + (context.clearRect as { mock: { calls: unknown[] } }).mock.calls.length + }, 0) ?? 0 + ) +} + function resetStores() { usePlaybackStore.setState({ currentFrame: 0, @@ -1464,6 +1482,94 @@ describe('VideoPreview sync behavior', () => { }) }) + it('clears and invalidates an in-flight same-item source render before replacement starts', async () => { + canvasPixelReadbackEnabled = true + const makeItem = (src: string) => ({ + id: 'same-source-item', + label: 'Same source item', + src, + effects: [ + { + id: 'effect-source-generation', + enabled: true, + effect: { type: 'gpu-effect', gpuEffectType: 'gpu-sepia', params: { amount: 0.5 } }, + }, + ], + }) + setSingleVideoItemAtFrame(makeItem('blob:old-source')) + const { renderer, scrubCanvas } = await renderReadySingleRendererPreview(24, { + expectedDisplayedFrame: 24, + }) + setMockCanvasBlank(scrubCanvas, false) + + let resolveOldRender: (() => void) | null = null + renderer.renderFrame.mockImplementation(async (frame: number) => { + if (frame !== 25) return + await new Promise((resolve) => { + resolveOldRender = resolve + }) + }) + act(() => { + usePlaybackStore.getState().setPreviewFrame(25) + }) + await waitFor(() => { + expect(renderer.renderFrame).toHaveBeenCalledWith(25) + expect(resolveOldRender).not.toBeNull() + }) + + const clearCountBeforeReplacement = getCanvasClearRectCallCount(scrubCanvas) + const rendererCalls = createCompositionRendererMock.mock.calls as unknown as Array< + [unknown, HTMLCanvasElement] + > + const oldOffscreen = rendererCalls[0]![1] + const replacementRenderer = createRendererDouble() + let resolveReplacementInit: (() => void) | null = null + let oldGenerationRetiredBeforeReplacement = false + createCompositionRendererMock.mockImplementationOnce( + () => + new Promise((resolve) => { + oldGenerationRetiredBeforeReplacement = + renderer.dispose.mock.calls.length > 0 && + getCanvasClearRectCallCount(scrubCanvas) > clearCountBeforeReplacement && + blankCanvasState.has(scrubCanvas) + resolveReplacementInit = () => { + rendererMockState.instances.push(replacementRenderer) + resolve(replacementRenderer) + } + }), + ) + + act(() => { + useItemsStore.getState().setItems([ + { + type: 'video', + trackId: 'track-video', + from: 0, + durationInFrames: 120, + ...makeItem('blob:new-source'), + } as unknown as TimelineItem, + ]) + }) + + await waitFor(() => expect(createCompositionRendererMock).toHaveBeenCalledTimes(2)) + expect(oldGenerationRetiredBeforeReplacement).toBe(true) + + setMockCanvasBlank(oldOffscreen, false) + await act(async () => { + resolveOldRender?.() + await Promise.resolve() + await Promise.resolve() + }) + expect(blankCanvasState.has(scrubCanvas)).toBe(true) + expect(getDisplayedFrame()).not.toBe(25) + + await act(async () => { + resolveReplacementInit?.() + await Promise.resolve() + }) + await waitFor(() => expect(replacementRenderer.renderFrame).toHaveBeenCalledWith(25)) + }) + it('prepares the initial playback lookahead without replacing the visible paused frame', async () => { setSingleVideoItemAtFrame({ id: 'item-initial-lookahead', diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx index 01270125a..dadbc47a5 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx @@ -66,8 +66,18 @@ const AUDIO_TRACK: TimelineTrack = { order: 1, } -const SECOND_VIDEO_TRACK: TimelineTrack = { ...TARGET_TRACK, id: 'target-video-2', name: 'V2', order: 1 } -const SECOND_AUDIO_TRACK: TimelineTrack = { ...AUDIO_TRACK, id: 'target-audio-2', name: 'A2', order: 3 } +const SECOND_VIDEO_TRACK: TimelineTrack = { + ...TARGET_TRACK, + id: 'target-video-2', + name: 'V2', + order: 1, +} +const SECOND_AUDIO_TRACK: TimelineTrack = { + ...AUDIO_TRACK, + id: 'target-audio-2', + name: 'A2', + order: 3, +} function makeVideoItem(overrides: Partial = {}): VideoItem { return { @@ -233,14 +243,16 @@ describe('useClipboardShortcuts paste placement', () => { it('maps linked A/V items with absent source IDs to separate compatible lanes', () => { useTimelineStore.setState({ tracks: [TARGET_TRACK, AUDIO_TRACK] }) - useClipboardStore.getState().copyItems( - [ - makeVideoItem({ id: 'missing-video', trackId: 'source-v', linkedGroupId: 'pair' }), - makeAudioItem({ id: 'missing-audio', trackId: 'source-a', linkedGroupId: 'pair' }), - ], - 0, - 'copy', - ) + useClipboardStore + .getState() + .copyItems( + [ + makeVideoItem({ id: 'missing-video', trackId: 'source-v', linkedGroupId: 'pair' }), + makeAudioItem({ id: 'missing-audio', trackId: 'source-a', linkedGroupId: 'pair' }), + ], + 0, + 'copy', + ) render() act(() => getPasteCallback()({ preventDefault: vi.fn() })) @@ -249,18 +261,22 @@ describe('useClipboardShortcuts paste placement', () => { }) it('preserves lane ordinals for multiple linked pairs and keeps captions on video lanes', () => { - useTimelineStore.setState({ tracks: [TARGET_TRACK, SECOND_VIDEO_TRACK, AUDIO_TRACK, SECOND_AUDIO_TRACK] }) - useClipboardStore.getState().copyItems( - [ - makeVideoItem({ id: 'v1', trackId: 'source-v1', linkedGroupId: 'pair-1' }), - makeAudioItem({ id: 'a1', trackId: 'source-a1', linkedGroupId: 'pair-1' }), - makeVideoItem({ id: 'v2', trackId: 'source-v2', from: 20, linkedGroupId: 'pair-2' }), - makeAudioItem({ id: 'a2', trackId: 'source-a2', from: 20, linkedGroupId: 'pair-2' }), - makeCaptionItem({ id: 'caption', trackId: 'source-v2', from: 20 }), - ], - 0, - 'copy', - ) + useTimelineStore.setState({ + tracks: [TARGET_TRACK, SECOND_VIDEO_TRACK, AUDIO_TRACK, SECOND_AUDIO_TRACK], + }) + useClipboardStore + .getState() + .copyItems( + [ + makeVideoItem({ id: 'v1', trackId: 'source-v1', linkedGroupId: 'pair-1' }), + makeAudioItem({ id: 'a1', trackId: 'source-a1', linkedGroupId: 'pair-1' }), + makeVideoItem({ id: 'v2', trackId: 'source-v2', from: 20, linkedGroupId: 'pair-2' }), + makeAudioItem({ id: 'a2', trackId: 'source-a2', from: 20, linkedGroupId: 'pair-2' }), + makeCaptionItem({ id: 'caption', trackId: 'source-v2', from: 20 }), + ], + 0, + 'copy', + ) render() act(() => getPasteCallback()({ preventDefault: vi.fn() })) @@ -273,19 +289,23 @@ describe('useClipboardShortcuts paste placement', () => { SECOND_AUDIO_TRACK.id, SECOND_VIDEO_TRACK.id, ]) - expect(plannedItems.filter((item) => item.type === 'text')[0]?.trackId).toBe(SECOND_VIDEO_TRACK.id) + expect(plannedItems.filter((item) => item.type === 'text')[0]?.trackId).toBe( + SECOND_VIDEO_TRACK.id, + ) }) it('uses surviving IDs while resolving missing linked members by kind', () => { useTimelineStore.setState({ tracks: [TARGET_TRACK, AUDIO_TRACK] }) - useClipboardStore.getState().copyItems( - [ - makeVideoItem({ id: 'surviving-video', trackId: TARGET_TRACK.id, linkedGroupId: 'pair' }), - makeAudioItem({ id: 'missing-audio', trackId: 'source-a', linkedGroupId: 'pair' }), - ], - 0, - 'copy', - ) + useClipboardStore + .getState() + .copyItems( + [ + makeVideoItem({ id: 'surviving-video', trackId: TARGET_TRACK.id, linkedGroupId: 'pair' }), + makeAudioItem({ id: 'missing-audio', trackId: 'source-a', linkedGroupId: 'pair' }), + ], + 0, + 'copy', + ) render() act(() => getPasteCallback()({ preventDefault: vi.fn() })) @@ -293,15 +313,65 @@ describe('useClipboardShortcuts paste placement', () => { expect(getPlannedItems().map((item) => item.trackId)).toEqual([TARGET_TRACK.id, AUDIO_TRACK.id]) }) + it('keeps source ordinals separate when malformed A/V lanes reuse an id', () => { + useTimelineStore.setState({ + tracks: [TARGET_TRACK, AUDIO_TRACK, SECOND_AUDIO_TRACK], + }) + useClipboardStore + .getState() + .copyItems( + [ + makeVideoItem({ id: 'shared-video', trackId: 'shared-missing', linkedGroupId: 'pair' }), + makeAudioItem({ id: 'shared-audio', trackId: 'shared-missing', linkedGroupId: 'pair' }), + makeAudioItem({ id: 'second-audio', trackId: 'second-missing', from: 20 }), + ], + 0, + 'copy', + ) + + render() + act(() => getPasteCallback()({ preventDefault: vi.fn() })) + + expect(getPlannedItems().map((item) => item.trackId)).toEqual([ + TARGET_TRACK.id, + AUDIO_TRACK.id, + SECOND_AUDIO_TRACK.id, + ]) + }) + + it('reserves a later surviving lane before assigning an earlier missing source', () => { + useTimelineStore.setState({ tracks: [TARGET_TRACK] }) + useClipboardStore + .getState() + .copyItems( + [ + makeVideoItem({ id: 'missing-first', label: 'Missing', trackId: 'missing-video' }), + makeVideoItem({ id: 'surviving-second', label: 'Surviving', trackId: TARGET_TRACK.id }), + ], + 0, + 'copy', + ) + + render() + act(() => getPasteCallback()({ preventDefault: vi.fn() })) + + const pastedItems = useTimelineStore.getState().items + expect(pastedItems.find((item) => item.label === 'Surviving')?.trackId).toBe(TARGET_TRACK.id) + expect(new Set(pastedItems.map((item) => item.trackId)).size).toBe(2) + expect(useTimelineStore.getState().tracks).toHaveLength(2) + }) + it('splits malformed overlapping members of one linked group safely', () => { - useClipboardStore.getState().copyItems( - [ - makeVideoItem({ id: 'overlap-1', trackId: 'same-source', linkedGroupId: 'bad-group' }), - makeVideoItem({ id: 'overlap-2', trackId: 'same-source', linkedGroupId: 'bad-group' }), - ], - 0, - 'copy', - ) + useClipboardStore + .getState() + .copyItems( + [ + makeVideoItem({ id: 'overlap-1', trackId: 'same-source', linkedGroupId: 'bad-group' }), + makeVideoItem({ id: 'overlap-2', trackId: 'same-source', linkedGroupId: 'bad-group' }), + ], + 0, + 'copy', + ) render() act(() => getPasteCallback()({ preventDefault: vi.fn() })) @@ -314,19 +384,24 @@ describe('useClipboardShortcuts paste placement', () => { }) it('creates deterministic compatible lanes and undoes tracks and items together', () => { - useClipboardStore.getState().copyItems( - [ - makeVideoItem({ id: 'lane-1', trackId: 'missing-v1', from: 0 }), - makeVideoItem({ id: 'lane-2', trackId: 'missing-v2', from: 0 }), - ], - 0, - 'copy', - ) + useClipboardStore + .getState() + .copyItems( + [ + makeVideoItem({ id: 'lane-1', trackId: 'missing-v1', from: 0 }), + makeVideoItem({ id: 'lane-2', trackId: 'missing-v2', from: 0 }), + ], + 0, + 'copy', + ) render() act(() => getPasteCallback()({ preventDefault: vi.fn() })) - expect(useTimelineStore.getState().tracks.map((track) => track.kind)).toEqual(['video', 'video']) + expect(useTimelineStore.getState().tracks.map((track) => track.kind)).toEqual([ + 'video', + 'video', + ]) expect(useTimelineStore.getState().items).toHaveLength(2) expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts index afa41464a..4c3742bfd 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts @@ -62,6 +62,13 @@ function hasInternalPlacementOverlap(plans: PastePlacementPlan[]): boolean { type PasteTrackKind = 'video' | 'audio' +interface PasteSourceLane { + key: string + kind: PasteTrackKind + sourceTrackId: string + ordinal: number +} + function getPasteTrackKind(item: Omit): PasteTrackKind { return item.type === 'audio' ? 'audio' : 'video' } @@ -78,55 +85,151 @@ function getPasteDestinationTrackIds(tracks: TimelineTrack[], kind: PasteTrackKi .map((track) => track.id) } +function collectPasteSourceLanes(pasteItems: Array>) { + const lanesByKind: Record = { video: [], audio: [] } + const laneByKey = new Map() + const laneKeyByItemIndex = new Map() + + for (const [sourceIndex, itemData] of pasteItems.entries()) { + const kind = getPasteTrackKind(itemData) + const key = `${kind}:${itemData.trackId}` + let lane = laneByKey.get(key) + if (!lane) { + lane = { + key, + kind, + sourceTrackId: itemData.trackId, + ordinal: lanesByKind[kind].length, + } + laneByKey.set(key, lane) + lanesByKind[kind].push(lane) + } + laneKeyByItemIndex.set(sourceIndex, key) + } + + return { lanesByKind, laneKeyByItemIndex } +} + +function findExactPasteTrack( + lane: PasteSourceLane, + tracks: TimelineTrack[], +): TimelineTrack | undefined { + return tracks.find( + (track) => track.id === lane.sourceTrackId && isCompatiblePasteTrack(track, lane.kind), + ) +} + +function appendPasteDestinationTrack( + tracks: TimelineTrack[], + kind: PasteTrackKind, +): { trackId: string; tracks: TimelineTrack[] } { + const minOrder = Math.min(...tracks.map((track) => track.order), 0) + const maxOrder = Math.max(...tracks.map((track) => track.order), 0) + const newTrack = createClassicTrack({ + tracks, + kind, + order: kind === 'video' ? minOrder - 1 : maxOrder + 1, + }) + return { trackId: newTrack.id, tracks: [...tracks, newTrack] } +} + +function planSingleSourceSection(params: { + activeTrackId: string | null + kind: PasteTrackKind + lanes: PasteSourceLane[] + tracks: TimelineTrack[] +}): { assignments: Map; tracks: TimelineTrack[] } { + const { activeTrackId, kind, lanes } = params + let plannedTracks = params.tracks + const assignments = new Map() + const candidates = getPasteDestinationTrackIds(plannedTracks, kind) + const activeTrack = plannedTracks.find( + (track) => track.id === activeTrackId && isCompatiblePasteTrack(track, kind), + ) + + for (const lane of lanes) { + let targetTrackId = + activeTrack?.id ?? findExactPasteTrack(lane, plannedTracks)?.id ?? candidates[0] + if (!targetTrackId) { + const created = appendPasteDestinationTrack(plannedTracks, kind) + plannedTracks = created.tracks + targetTrackId = created.trackId + candidates.push(created.trackId) + } + assignments.set(lane.key, targetTrackId) + } + + return { assignments, tracks: plannedTracks } +} + +function planPreservedSourceSection(params: { + kind: PasteTrackKind + lanes: PasteSourceLane[] + tracks: TimelineTrack[] +}): { assignments: Map; tracks: TimelineTrack[] } { + const { kind, lanes } = params + let plannedTracks = params.tracks + const assignments = new Map() + const candidates = getPasteDestinationTrackIds(plannedTracks, kind) + const usedTrackIds = new Set() + + for (const lane of lanes) { + const exactTrack = findExactPasteTrack(lane, plannedTracks) + if (!exactTrack) continue + assignments.set(lane.key, exactTrack.id) + usedTrackIds.add(exactTrack.id) + } + + for (const lane of lanes) { + if (assignments.has(lane.key)) continue + const ordinalCandidate = candidates[lane.ordinal] + const availableCandidate = + ordinalCandidate && !usedTrackIds.has(ordinalCandidate) + ? ordinalCandidate + : candidates.find((candidate) => !usedTrackIds.has(candidate)) + let targetTrackId = availableCandidate + if (!targetTrackId) { + const created = appendPasteDestinationTrack(plannedTracks, kind) + plannedTracks = created.tracks + targetTrackId = created.trackId + candidates.push(created.trackId) + } + assignments.set(lane.key, targetTrackId) + usedTrackIds.add(targetTrackId) + } + + return { assignments, tracks: plannedTracks } +} + function buildPasteTrackPlan( pasteItems: Array>, tracks: TimelineTrack[], activeTrackId: string | null, ): { plan: Map; tracks: TimelineTrack[] } { let plannedTracks = tracks - const sourceTrackIndexes = new Map() - const nextSourceIndex: Record = { video: 0, audio: 0 } - const destinationTrackIds: Record = { video: [], audio: [] } - for (const kind of ['video', 'audio'] as const) { - destinationTrackIds[kind] = getPasteDestinationTrackIds(plannedTracks, kind) - } + const { lanesByKind, laneKeyByItemIndex } = collectPasteSourceLanes(pasteItems) const preserveSourceTracks = new Set(pasteItems.map((item) => item.trackId)).size > 1 const plan = new Map() + const assignedTrackBySourceLane = new Map() - for (const [sourceIndex, itemData] of pasteItems.entries()) { - const kind = getPasteTrackKind(itemData) - const sourceTrackId = itemData.trackId - let sourceLane = sourceTrackIndexes.get(sourceTrackId) - if (sourceLane === undefined) { - sourceLane = nextSourceIndex[kind]++ - sourceTrackIndexes.set(sourceTrackId, sourceLane) - } - - const exactTrack = tracks.find( - (track) => track.id === sourceTrackId && isCompatiblePasteTrack(track, kind), - ) - const activeTrack = tracks.find( - (track) => track.id === activeTrackId && isCompatiblePasteTrack(track, kind), - ) - const candidates = destinationTrackIds[kind] - while (preserveSourceTracks && sourceLane >= candidates.length) { - const minOrder = Math.min(...plannedTracks.map((track) => track.order), 0) - const maxOrder = Math.max(...plannedTracks.map((track) => track.order), 0) - const newTrack = createClassicTrack({ - tracks: plannedTracks, - kind, - order: kind === 'video' ? minOrder - 1 : maxOrder + 1, - }) - plannedTracks = [...plannedTracks, newTrack] - candidates.push(newTrack.id) + for (const kind of ['video', 'audio'] as const) { + const sectionPlan = preserveSourceTracks + ? planPreservedSourceSection({ kind, lanes: lanesByKind[kind], tracks: plannedTracks }) + : planSingleSourceSection({ + activeTrackId, + kind, + lanes: lanesByKind[kind], + tracks: plannedTracks, + }) + plannedTracks = sectionPlan.tracks + for (const [laneKey, trackId] of sectionPlan.assignments) { + assignedTrackBySourceLane.set(laneKey, trackId) } - const targetTrackId = preserveSourceTracks - ? (exactTrack?.id ?? candidates[sourceLane] ?? candidates[0] ?? activeTrack?.id) - : (activeTrack?.id ?? exactTrack?.id ?? candidates[sourceLane] ?? candidates[0]) + } - if (targetTrackId) { - plan.set(sourceIndex, targetTrackId) - } + for (const [sourceIndex, laneKey] of laneKeyByItemIndex) { + const targetTrackId = assignedTrackBySourceLane.get(laneKey) + if (targetTrackId) plan.set(sourceIndex, targetTrackId) } return { plan, tracks: plannedTracks } @@ -352,12 +455,14 @@ export function useClipboardShortcuts() { const placementPlans = pasteItems.flatMap((itemData, sourceIndex) => { const targetTrackId = trackPlan.get(sourceIndex) return targetTrackId - ? [{ - itemData, - targetTrackId, - desiredFrom: currentFrame + itemData.from, - sourceIndex, - }] + ? [ + { + itemData, + targetTrackId, + desiredFrom: currentFrame + itemData.from, + sourceIndex, + }, + ] : [] }) diff --git a/src/features/timeline/stores/actions/export-snapshot.ts b/src/features/timeline/stores/actions/export-snapshot.ts index 71f667faa..631f14f57 100644 --- a/src/features/timeline/stores/actions/export-snapshot.ts +++ b/src/features/timeline/stores/actions/export-snapshot.ts @@ -128,8 +128,7 @@ export function getExportableSequence(sequenceId: string | null): ExportableSequ const root = getRootTimelineSnapshot(current) const metadata = useProjectStore.getState().currentProject?.metadata // Main's audio bus / range are live when Main is active, else held aside. - const busAudioEq = - activeTabId === null ? playback.busAudioEq : nav.mainHolder?.busAudioEq + const busAudioEq = activeTabId === null ? playback.busAudioEq : nav.mainHolder?.busAudioEq return { id: null, name: MAIN_LABEL, diff --git a/src/features/timeline/stores/timeline-persistence.ts b/src/features/timeline/stores/timeline-persistence.ts index 1122d9fa8..c0313cbf4 100644 --- a/src/features/timeline/stores/timeline-persistence.ts +++ b/src/features/timeline/stores/timeline-persistence.ts @@ -38,10 +38,7 @@ import { useMarkersStore } from './markers-store' import { useTimelineSettingsStore } from './timeline-settings-store' import { ROOT_HISTORY_CONTEXT, useTimelineCommandStore } from './timeline-command-store' import { useCompositionsStore, type SubComposition } from './compositions-store' -import { - getActiveTabId, - useCompositionNavigationStore, -} from './composition-navigation-store' +import { getActiveTabId, useCompositionNavigationStore } from './composition-navigation-store' import { useSequencesStore } from './sequences-store' import { getProject, updateProject, saveProjectThumbnail } from '@/infrastructure/storage' import { @@ -789,8 +786,7 @@ function captureTimelinePersistenceSnapshot(): TimelinePersistenceSnapshot { compositions, currentFrame: heldRoot ? heldRoot.currentFrame : playback.currentFrame, zoomLevel: heldRoot?.zoomLevel ?? rootView?.zoomLevel ?? zoom.level, - scrollPosition: - heldRoot?.scrollPosition ?? rootView?.scrollPosition ?? settings.scrollPosition, + scrollPosition: heldRoot?.scrollPosition ?? rootView?.scrollPosition ?? settings.scrollPosition, busAudioEq: cloneAudioEq(heldRoot ? heldRoot.busAudioEq : playback.busAudioEq), masterBusDb: playback.masterBusDb, markers: heldRoot ? heldRoot.markers : markers.markers, @@ -1234,10 +1230,7 @@ function getTimelineLoadKey(projectId: string, options: LoadTimelineOptions): st return JSON.stringify([projectId, options.allowProjectUpgrade === true]) } -export function loadTimeline( - projectId: string, - options: LoadTimelineOptions = {}, -): Promise { +export function loadTimeline(projectId: string, options: LoadTimelineOptions = {}): Promise { const loadKey = getTimelineLoadKey(projectId, options) const inFlightLoad = inFlightTimelineLoads.get(loadKey) if (inFlightLoad) return inFlightLoad From 9bdb4e4b47255ec69b4a3106af200cc75cfa5bd0 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:48:42 -0700 Subject: [PATCH 14/64] fix(editor): fence export and preview ownership races (cherry picked from commit 7837f217249199bcb4debe785ba2180e553c3e82) --- package.json | 2 +- .../export/hooks/use-client-render.test.tsx | 119 ++++++++++- .../export/hooks/use-client-render.ts | 120 +++++++---- .../stores/media-delete-actions.test.ts | 8 +- .../stores/media-delete-actions.ts | 5 +- .../media-library/utils/media-resolver.ts | 196 +++++++++++------- .../components/inline-source-preview.tsx | 12 +- .../source-composition.generation.test.tsx | 35 +++- .../preview/components/source-composition.tsx | 68 ++++-- .../components/source-monitor.test.tsx | 4 + .../preview/components/source-monitor.tsx | 16 +- .../components/video-preview.sync.test.tsx | 109 ++++++++++ .../preview/components/video-preview.tsx | 96 ++++++--- .../use-preview-media-resolution.test.tsx | 74 ++++++- .../hooks/use-preview-media-resolution.ts | 23 +- .../preview/utils/media-resolver.test.ts | 38 ++++ .../use-clipboard-shortcuts.test.tsx | 45 ++++ .../stores/actions/export-snapshot.ts | 14 +- .../timeline/stores/export-snapshot.test.ts | 82 ++++++++ .../browser/blob-url-manager.test.ts | 14 ++ .../browser/blob-url-manager.ts | 27 ++- 21 files changed, 911 insertions(+), 196 deletions(-) diff --git a/package.json b/package.json index 7f9495514..7c4f05810 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "routes": "tsr generate", "test": "vp test", "test:run": "vp test run", - "test:editor-hardening": "vp test run src/features/timeline/components/timeline-content.test.tsx src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx src/features/timeline/stores/export-snapshot.test.ts src/features/export/components/export-dialog.test.tsx src/features/export/hooks/client-render-source.test.ts src/features/export/hooks/use-client-render.test.tsx src/features/preview/workers/consume-video-samples.test.ts src/features/preview/utils/media-resolver.test.ts src/features/preview/components/source-composition.generation.test.tsx src/features/preview/components/video-preview.sync.test.tsx", + "test:editor-hardening": "vp test run src/features/timeline/components/timeline-content.test.tsx src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx src/features/timeline/stores/export-snapshot.test.ts src/features/export/components/export-dialog.test.tsx src/features/export/hooks/client-render-source.test.ts src/features/export/hooks/use-client-render.test.tsx src/features/preview/workers/consume-video-samples.test.ts src/features/preview/utils/media-resolver.test.ts src/features/preview/hooks/use-preview-media-resolution.test.tsx src/features/preview/components/source-composition.generation.test.tsx src/features/preview/components/video-preview.sync.test.tsx src/infrastructure/browser/blob-url-manager.test.ts", "test:preview-sync": "vp test run src/features/preview/components/video-preview.sync.test.tsx", "test:preview-sync:stress": "node scripts/preview-sync-stress.mjs --runs 20", "test:coverage": "vp test run --coverage", diff --git a/src/features/export/hooks/use-client-render.test.tsx b/src/features/export/hooks/use-client-render.test.tsx index 9d3fec18c..df351144d 100644 --- a/src/features/export/hooks/use-client-render.test.tsx +++ b/src/features/export/hooks/use-client-render.test.tsx @@ -1,5 +1,6 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { StrictMode, type PropsWithChildren } from 'react' const mocks = vi.hoisted(() => ({ resolveMediaUrls: vi.fn(), @@ -137,7 +138,9 @@ describe('useClientRender lifecycle ownership', () => { mocks.runRender.mockReturnValue( render.promise.then((result) => ({ result, renderPath: 'worker' })), ) - const hook = renderHook(() => useClientRender()) + const hook = renderHook(() => useClientRender(), { + wrapper: ({ children }: PropsWithChildren) => {children}, + }) let exportPromise!: Promise await act(async () => { @@ -184,6 +187,120 @@ describe('useClientRender lifecycle ownership', () => { expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledWith(renderedResult) }) + it('ignores progress and result ownership from an aborted run after a restart', async () => { + const first = deferred() + const secondResult = { ...renderedResult, blob: new Blob(['second']) } + const second = deferred() + mocks.runRender + .mockReturnValueOnce(first.promise.then((result) => ({ result, renderPath: 'worker' }))) + .mockReturnValueOnce(second.promise.then((result) => ({ result, renderPath: 'worker' }))) + const hook = renderHook(() => useClientRender()) + + let firstExport!: Promise + await act(async () => { + firstExport = hook.result.current.startExport(settings as never) + await Promise.resolve() + }) + const firstProgress = mocks.runRender.mock.calls[0]![0].onProgress + + let secondExport!: Promise + await act(async () => { + secondExport = hook.result.current.startExport(settings as never) + await Promise.resolve() + }) + const secondProgress = mocks.runRender.mock.calls[1]![0].onProgress + + act(() => { + secondProgress({ + phase: 'rendering', + progress: 25, + message: 'new run', + currentFrame: 5, + totalFrames: 20, + }) + }) + expect(hook.result.current).toMatchObject({ + progress: 25, + progressMessage: 'new run', + status: 'rendering', + }) + + act(() => { + firstProgress({ + phase: 'encoding', + progress: 90, + message: 'stale run', + currentFrame: 18, + totalFrames: 20, + }) + }) + expect(hook.result.current).toMatchObject({ + progress: 25, + progressMessage: 'new run', + status: 'rendering', + }) + + second.resolve(secondResult) + await act(async () => { + await secondExport + }) + expect(hook.result.current).toMatchObject({ + progress: 100, + status: 'completed', + result: secondResult, + }) + + act(() => { + firstProgress({ + phase: 'finalizing', + progress: 99, + message: 'late stale run', + currentFrame: 20, + totalFrames: 20, + }) + }) + expect(hook.result.current).toMatchObject({ + progress: 100, + status: 'completed', + result: secondResult, + }) + + first.resolve(renderedResult) + await act(async () => { + await firstExport + }) + expect(hook.result.current.result).toBe(secondResult) + expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledWith(renderedResult) + }) + + it('aborts a cancelled run exactly once when unmount races its completion', async () => { + const abortSpy = vi.spyOn(AbortController.prototype, 'abort') + const render = deferred() + mocks.runRender.mockReturnValue( + render.promise.then((result) => ({ result, renderPath: 'worker' })), + ) + const hook = renderHook(() => useClientRender(), { + wrapper: ({ children }: PropsWithChildren) => {children}, + }) + + let exportPromise!: Promise + await act(async () => { + exportPromise = hook.result.current.startExport(settings as never) + await Promise.resolve() + }) + act(() => hook.result.current.cancelExport()) + hook.unmount() + + expect(abortSpy).toHaveBeenCalledTimes(1) + render.resolve(renderedResult) + await act(async () => { + await exportPromise + }) + expect(abortSpy).toHaveBeenCalledTimes(1) + expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledTimes(1) + expect(mocks.releaseTemporaryExportOutput).toHaveBeenCalledWith(renderedResult) + }) + it('releases a rendered output when finalization/update work throws before ownership transfer', async () => { const output = { ...renderedResult, diff --git a/src/features/export/hooks/use-client-render.ts b/src/features/export/hooks/use-client-render.ts index f42265745..70c866b7a 100644 --- a/src/features/export/hooks/use-client-render.ts +++ b/src/features/export/hooks/use-client-render.ts @@ -89,16 +89,40 @@ export function useClientRender(): UseClientRenderReturn { const [status, setStatus] = useState('idle') const [error, setError] = useState(null) const [result, setResult] = useState(null) - const resultRef = useRef(null) + const resultOwnerRef = useRef<{ + runToken: number + result: ClientRenderResult + released: boolean + } | null>(null) + + const activeRunRef = useRef<{ + token: number + controller: AbortController + } | null>(null) + const latestRunTokenRef = useRef(0) + + const abortActiveRun = useCallback(() => { + const run = activeRunRef.current + if (!run) return + activeRunRef.current = null + if (!run.controller.signal.aborted) run.controller.abort() + }, []) - // AbortController for cancellation - const abortControllerRef = useRef(null) - const renderGenerationRef = useRef(0) + const releaseOwnedResult = useCallback( + ( + owner: { runToken: number; result: ClientRenderResult; released: boolean } | null | undefined, + ) => { + if (!owner || owner.released) return + owner.released = true + void releaseTemporaryExportOutput(owner.result) + }, + [], + ) /** * Handle progress updates from the render engine */ - const handleProgress = useCallback((progressData: RenderProgress) => { + const applyProgress = useCallback((progressData: RenderProgress) => { setProgress(progressData.progress) setProgressMessage(progressData.message) setRenderedFrames(progressData.currentFrame) @@ -128,28 +152,35 @@ export function useClientRender(): UseClientRenderReturn { async (settings: ExportSettings | ExtendedExportSettings, sequence?: ExportableSequence) => { const opId = createOperationId() const event = log.startEvent('render', opId) - const previousController = abortControllerRef.current - previousController?.abort() - const generation = ++renderGenerationRef.current + const runToken = ++latestRunTokenRef.current + abortActiveRun() const controller = new AbortController() - abortControllerRef.current = controller + const run = { token: runToken, controller } + activeRunRef.current = run let temporaryResult: ClientRenderResult | null = null - const releaseResult = (ownedResult: ClientRenderResult | null | undefined) => { - if (!ownedResult) return - if (resultRef.current === ownedResult) resultRef.current = null - void releaseTemporaryExportOutput(ownedResult) + const releaseTemporaryResult = () => { + const ownedResult = temporaryResult + temporaryResult = null + if (ownedResult) void releaseTemporaryExportOutput(ownedResult) } + const isActive = () => + activeRunRef.current === run && + latestRunTokenRef.current === runToken && + !controller.signal.aborted const ensureActive = () => { - if (generation !== renderGenerationRef.current || controller.signal.aborted) { + if (!isActive()) { throw new DOMException('Render cancelled', 'AbortError') } } + const handleRunProgress = (progressData: RenderProgress) => { + if (isActive()) applyProgress(progressData) + } try { - const previousResult = resultRef.current - resultRef.current = null - releaseResult(previousResult) + const previousResultOwner = resultOwnerRef.current + resultOwnerRef.current = null + releaseOwnedResult(previousResultOwner) setIsExporting(true) setProgress(0) setProgressMessage(undefined) @@ -199,14 +230,18 @@ export function useClientRender(): UseClientRenderReturn { masterBusDb, }, signal, - handleProgress, + handleRunProgress, ) ensureActive() if (smartCopy.result) { temporaryResult = smartCopy.result - resultRef.current = temporaryResult setResult(temporaryResult) + resultOwnerRef.current = { + runToken, + result: temporaryResult, + released: false, + } temporaryResult = null setStatus('completed') setProgress(100) @@ -325,7 +360,7 @@ export function useClientRender(): UseClientRenderReturn { exportMode, composition, signal, - onProgress: handleProgress, + onProgress: handleRunProgress, }) temporaryResult = renderResult ensureActive() @@ -347,8 +382,8 @@ export function useClientRender(): UseClientRenderReturn { } if (finalResult !== renderResult) temporaryResult = finalResult - resultRef.current = finalResult setResult(finalResult) + resultOwnerRef.current = { runToken, result: finalResult, released: false } temporaryResult = null setStatus('completed') setProgress(100) @@ -360,9 +395,8 @@ export function useClientRender(): UseClientRenderReturn { duration: renderResult.duration, }) } catch (err) { - releaseResult(temporaryResult) - temporaryResult = null - if (generation !== renderGenerationRef.current) return + releaseTemporaryResult() + if (runToken !== latestRunTokenRef.current) return if (err instanceof DOMException && err.name === 'AbortError') { event.set('outcome', 'cancelled') event.set('duration_ms', Date.now()) @@ -375,13 +409,13 @@ export function useClientRender(): UseClientRenderReturn { setStatus('failed') } } finally { - if (generation === renderGenerationRef.current) { + if (activeRunRef.current === run) { + activeRunRef.current = null setIsExporting(false) - if (abortControllerRef.current === controller) abortControllerRef.current = null } } }, - [handleProgress], + [abortActiveRun, applyProgress, releaseOwnedResult], ) /** @@ -389,12 +423,12 @@ export function useClientRender(): UseClientRenderReturn { * which posts the cancel to its worker and terminates it. */ const cancelExport = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort() + if (activeRunRef.current) { + abortActiveRun() setStatus('cancelled') setIsExporting(false) } - }, []) + }, [abortActiveRun]) /** * Download the rendered video/audio @@ -445,9 +479,8 @@ export function useClientRender(): UseClientRenderReturn { * Reset state */ const resetState = useCallback(() => { - renderGenerationRef.current++ - abortControllerRef.current?.abort() - abortControllerRef.current = null + latestRunTokenRef.current++ + abortActiveRun() setIsExporting(false) setProgress(0) setProgressMessage(undefined) @@ -455,22 +488,21 @@ export function useClientRender(): UseClientRenderReturn { setTotalFrames(undefined) setStatus('idle') setError(null) - const previousResult = resultRef.current - resultRef.current = null - if (previousResult) void releaseTemporaryExportOutput(previousResult) + const previousResultOwner = resultOwnerRef.current + resultOwnerRef.current = null + releaseOwnedResult(previousResultOwner) setResult(null) - }, []) + }, [abortActiveRun, releaseOwnedResult]) useEffect( () => () => { - renderGenerationRef.current++ - abortControllerRef.current?.abort() - abortControllerRef.current = null - const ownedResult = resultRef.current - resultRef.current = null - if (ownedResult) void releaseTemporaryExportOutput(ownedResult) + latestRunTokenRef.current++ + abortActiveRun() + const ownedResult = resultOwnerRef.current + resultOwnerRef.current = null + releaseOwnedResult(ownedResult) }, - [], + [abortActiveRun, releaseOwnedResult], ) /** diff --git a/src/features/media-library/stores/media-delete-actions.test.ts b/src/features/media-library/stores/media-delete-actions.test.ts index cef929c77..5e2465433 100644 --- a/src/features/media-library/stores/media-delete-actions.test.ts +++ b/src/features/media-library/stores/media-delete-actions.test.ts @@ -17,7 +17,7 @@ const proxyServiceMocks = vi.hoisted(() => ({ })) const blobUrlManagerMocks = vi.hoisted(() => ({ - release: vi.fn(), + invalidate: vi.fn(), })) vi.mock('../services/media-library-service', () => ({ @@ -139,7 +139,7 @@ describe('createDeleteActions', () => { ) expect(currentState.mediaItems.map((item) => item.id)).toEqual(['media-2']) expect(currentState.selectedMediaIds).toEqual([]) - expect(blobUrlManagerMocks.release).toHaveBeenCalledWith('media-1') + expect(blobUrlManagerMocks.invalidate).toHaveBeenCalledWith('media-1') expect(proxyServiceMocks.clearProxyKey).toHaveBeenCalledWith('media-1') }) @@ -161,7 +161,7 @@ describe('createDeleteActions', () => { expect(currentState.mediaItems.map((item) => item.id)).toEqual(['media-1', 'media-2']) expect(currentState.selectedMediaIds).toEqual(['media-1']) expect(currentState.error).toBe('Delete failed hard') - expect(blobUrlManagerMocks.release).not.toHaveBeenCalled() + expect(blobUrlManagerMocks.invalidate).not.toHaveBeenCalled() }) it('uses the legacy batch delete path when no project is selected', async () => { @@ -183,7 +183,7 @@ describe('createDeleteActions', () => { expect(mediaLibraryServiceMocks.deleteMediaBatch).toHaveBeenCalledWith(['media-1', 'media-2']) expect(currentState.mediaItems).toEqual([]) expect(currentState.selectedMediaIds).toEqual([]) - expect(blobUrlManagerMocks.release).toHaveBeenCalledTimes(2) + expect(blobUrlManagerMocks.invalidate).toHaveBeenCalledTimes(2) expect(proxyServiceMocks.clearProxyKey).toHaveBeenCalledTimes(2) }) }) diff --git a/src/features/media-library/stores/media-delete-actions.ts b/src/features/media-library/stores/media-delete-actions.ts index 53bffdfa9..caf487875 100644 --- a/src/features/media-library/stores/media-delete-actions.ts +++ b/src/features/media-library/stores/media-delete-actions.ts @@ -31,7 +31,10 @@ function releaseDeletedMediaResources( const previousMediaById = new Map(previousItems.map((item) => [item.id, item])) for (const id of ids) { - blobUrlManager.release(id) + // Deletion is a source retirement, not a consumer release. Advance the + // media epoch even when no URL has settled yet so a pending storage read + // cannot resurrect the deleted source. + blobUrlManager.invalidate(id) proxyService.clearProxyKey(id) // Drop every Scene Browser cache tied to this media — thumbnail blob // URLs (which otherwise pin the JPEG in memory forever), lazy-thumb diff --git a/src/features/media-library/utils/media-resolver.ts b/src/features/media-library/utils/media-resolver.ts index e7f95269f..8e1a1a54e 100644 --- a/src/features/media-library/utils/media-resolver.ts +++ b/src/features/media-library/utils/media-resolver.ts @@ -12,7 +12,113 @@ const logger = createLogger('MediaResolver') * Pending requests to prevent concurrent OPFS access to the same file * This prevents multiple sync access handle creation for the same OPFS file */ -const pendingRequests = new Map>() +interface PendingMediaRequest { + epoch: string + promise: Promise +} + +const pendingRequests = new Map() + +type MediaLibraryServiceModule = + typeof import('@/features/media-library/services/media-library-service') +type MediaLibraryService = MediaLibraryServiceModule['mediaLibraryService'] +type ResolvedMedia = NonNullable>> + +function isCurrentMediaEpoch(mediaId: string, epoch: string): boolean { + return blobUrlManager.getEpoch(mediaId) === epoch +} + +function acquireResolvedMediaUrl(mediaId: string, blob: Blob, media: ResolvedMedia): string { + const blobUrl = blobUrlManager.acquire(mediaId, blob, { + mediaId, + storageType: media.storageType, + fileHandle: media.storageType === 'handle' ? media.fileHandle : undefined, + opfsPath: media.storageType === 'opfs' ? media.opfsPath : undefined, + fileSize: media.fileSize, + }) + + if (media.keyframeTimestamps && media.keyframeTimestamps.length > 0) { + registerKeyframeIndex(blobUrl, media.keyframeTimestamps) + } + return blobUrl +} + +async function resolveCurrentMediaUrl( + mediaId: string, + requestEpoch: string, + mediaLibraryService: MediaLibraryService, +): Promise { + const media = await mediaLibraryService.getMedia(mediaId) + if (!isCurrentMediaEpoch(mediaId, requestEpoch)) return '' + + if (!media) { + logger.warn(`Media not found: ${mediaId}`) + return '' + } + + // Get the source blob without an extra validation pass; getMediaFile + // surfaces permission/missing-file errors with the same relink UI. + const blob = await mediaLibraryService.getMediaFile(media) + if (!isCurrentMediaEpoch(mediaId, requestEpoch)) return '' + + if (!blob) { + // The media record exists but its bytes can't be resolved (no valid + // storage path — e.g. opened on an origin whose OPFS lacks it and the + // workspace folder has no copy). getMediaFile returns null WITHOUT a + // FileAccessError, so surface it into the broken-media system here so + // the clip shows a relink state and the missing-media dialog lights up. + logger.warn(`Media blob not found: ${mediaId}`) + useMediaLibraryStore.getState().markMediaBroken(mediaId, { + mediaId, + fileName: media.fileName ?? 'Unknown file', + errorType: 'file_missing', + }) + return '' + } + + const blobUrl = acquireResolvedMediaUrl(mediaId, blob, media) + useMediaLibraryStore.getState().markMediaHealthy(mediaId) + return blobUrl +} + +async function markMediaBrokenFromAccessError( + mediaId: string, + requestEpoch: string, + error: unknown, + mediaLibraryService: MediaLibraryService, + FileAccessError: MediaLibraryServiceModule['FileAccessError'], +): Promise { + if (!(error instanceof FileAccessError)) return + + const media = await mediaLibraryService.getMedia(mediaId) + if (!isCurrentMediaEpoch(mediaId, requestEpoch)) return + useMediaLibraryStore.getState().markMediaBroken(mediaId, { + mediaId, + fileName: media?.fileName ?? 'Unknown file', + errorType: error.type === 'permission_denied' ? 'permission_denied' : 'file_missing', + }) +} + +async function loadMediaRequest(mediaId: string, requestEpoch: string): Promise { + const { mediaLibraryService, FileAccessError } = + await import('@/features/media-library/services/media-library-service') + if (!isCurrentMediaEpoch(mediaId, requestEpoch)) return '' + + try { + return await resolveCurrentMediaUrl(mediaId, requestEpoch, mediaLibraryService) + } catch (error) { + if (!isCurrentMediaEpoch(mediaId, requestEpoch)) return '' + logger.error(`Failed to resolve media ${mediaId}:`, error) + await markMediaBrokenFromAccessError( + mediaId, + requestEpoch, + error, + mediaLibraryService, + FileAccessError, + ) + return '' + } +} type RuntimeMediaResolver = (mediaId: string) => Promise | string | null @@ -71,86 +177,28 @@ export async function resolveMediaUrl(mediaId: string): Promise { return cached } - // Check if there's already a pending request for this media - if (pendingRequests.has(mediaId)) { - return pendingRequests.get(mediaId)! + const requestEpoch = blobUrlManager.getEpoch(mediaId) + + // Deduplicate only within the currently valid source generation. Relinking + // can invalidate an ID while an old storage read is still pending; that old + // promise must not block or populate the replacement generation. + const pendingRequest = pendingRequests.get(mediaId) + if (pendingRequest?.epoch === requestEpoch) { + return pendingRequest.promise } // Create the request promise - const requestPromise = (async () => { - const { mediaLibraryService, FileAccessError } = - await import('@/features/media-library/services/media-library-service') - - try { - // Get media metadata from library - const media = await mediaLibraryService.getMedia(mediaId) - - if (!media) { - logger.warn(`Media not found: ${mediaId}`) - return '' // Fallback: empty string (Composition will skip) - } - - // Get the source blob without an extra validation pass; getMediaFile - // surfaces permission/missing-file errors with the same relink UI. - const blob = await mediaLibraryService.getMediaFile(media) - - if (!blob) { - // The media record exists but its bytes can't be resolved (no valid - // storage path — e.g. opened on an origin whose OPFS lacks it and the - // workspace folder has no copy). getMediaFile returns null WITHOUT a - // FileAccessError, so surface it into the broken-media system here so - // the clip shows a relink state and the missing-media dialog lights up. - logger.warn(`Media blob not found: ${mediaId}`) - useMediaLibraryStore.getState().markMediaBroken(mediaId, { - mediaId, - fileName: media.fileName ?? 'Unknown file', - errorType: 'file_missing', - }) - return '' - } - - // Acquire blob URL through centralized manager (handles caching + ref counting) - const blobUrl = blobUrlManager.acquire(mediaId, blob, { - mediaId, - storageType: media.storageType, - fileHandle: media.storageType === 'handle' ? media.fileHandle : undefined, - opfsPath: media.storageType === 'opfs' ? media.opfsPath : undefined, - fileSize: media.fileSize, - }) - - // Register keyframe index for adaptive seek backtracking - if (media.keyframeTimestamps && media.keyframeTimestamps.length > 0) { - registerKeyframeIndex(blobUrl, media.keyframeTimestamps) - } - - // Resolved successfully — clear any stale broken flag (e.g. the repair - // sweep just restored the workspace copy) so the clip stops showing the - // offline state without needing a reload. - useMediaLibraryStore.getState().markMediaHealthy(mediaId) - - return blobUrl - } catch (error) { - logger.error(`Failed to resolve media ${mediaId}:`, error) - - // Mark media as broken if it's a file access error - if (error instanceof FileAccessError) { - const media = await mediaLibraryService.getMedia(mediaId) - useMediaLibraryStore.getState().markMediaBroken(mediaId, { - mediaId, - fileName: media?.fileName ?? 'Unknown file', - errorType: error.type === 'permission_denied' ? 'permission_denied' : 'file_missing', - }) - } - - return '' // Fallback: empty string - } finally { - // Clean up pending request + let requestPromise!: Promise + requestPromise = loadMediaRequest(mediaId, requestEpoch).finally(() => { + // A later source generation may already own this mediaId's slot. Only + // the exact request that installed an entry may remove it. + if (pendingRequests.get(mediaId)?.promise === requestPromise) { pendingRequests.delete(mediaId) } - })() + }) // Store the pending request - pendingRequests.set(mediaId, requestPromise) + pendingRequests.set(mediaId, { epoch: requestEpoch, promise: requestPromise }) return requestPromise } diff --git a/src/features/preview/components/inline-source-preview.tsx b/src/features/preview/components/inline-source-preview.tsx index be9e1e115..7de19fafc 100644 --- a/src/features/preview/components/inline-source-preview.tsx +++ b/src/features/preview/components/inline-source-preview.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useState } from 'react' +import { memo, useEffect, useLayoutEffect, useMemo, useState } from 'react' import { PlayerEmitterProvider, ClockBridgeProvider, @@ -11,6 +11,7 @@ import { SourceComposition } from './source-composition' import { usePlaybackStore } from '@/shared/state/playback' import { EDITOR_LAYOUT_CSS_VALUES } from '@/config/editor-layout' import { getPreviewNeedsOverflow, getPreviewPlayerSize } from '../utils/preview-pixel-snap' +import { useBlobUrlVersion } from '@/infrastructure/browser/blob-url-manager' interface InlineSourcePreviewProps { mediaId: string @@ -53,14 +54,17 @@ const InlineSourcePreviewContent = memo(function InlineSourcePreviewContent({ }: InlineSourcePreviewProps) { const [blobUrl, setBlobUrl] = useState('') const media = useMediaLibraryStore((s) => s.mediaById[mediaId]) + const blobUrlVersion = useBlobUrlVersion() const zoom = usePlaybackStore((s) => s.zoom) const mediaWidth = media?.width || 640 const mediaHeight = media?.height || 360 - useEffect(() => { - let cancelled = false + useLayoutEffect(() => { setBlobUrl('') + }, [blobUrlVersion, mediaId]) + useEffect(() => { + let cancelled = false resolveMediaUrl(mediaId) .then((url) => { if (!cancelled) { @@ -74,7 +78,7 @@ const InlineSourcePreviewContent = memo(function InlineSourcePreviewContent({ return () => { cancelled = true } - }, [mediaId]) + }, [blobUrlVersion, mediaId]) const containerWidth = containerSize.width const containerHeight = containerSize.height diff --git a/src/features/preview/components/source-composition.generation.test.tsx b/src/features/preview/components/source-composition.generation.test.tsx index aa1458de9..80ede9aec 100644 --- a/src/features/preview/components/source-composition.generation.test.tsx +++ b/src/features/preview/components/source-composition.generation.test.tsx @@ -238,9 +238,12 @@ describe('SourceComposition source generations', () => { it('closes a stale bitmap completion without marking the replacement decoded', async () => { const staleBitmap = { close: vi.fn() } const bitmapCompletion = deferred() + const replacementDraw = deferred() decoderHarness.createImageBitmap.mockReturnValueOnce(bitmapCompletion.promise) decoderHarness.extractors.set('blob:old', makeExtractor()) - decoderHarness.extractors.set('blob:new', makeExtractor(Promise.resolve(false))) + const replacementExtractor = makeExtractor() + replacementExtractor.drawFrame.mockReturnValue(replacementDraw.promise) + decoderHarness.extractors.set('blob:new', replacementExtractor) const rendered = render( , @@ -257,6 +260,36 @@ describe('SourceComposition source generations', () => { expect(staleBitmap.close).toHaveBeenCalledOnce() expect(visibleContext.drawImage).toHaveBeenCalledTimes(drawCountBeforeReset) expect(visibleCanvas.style.display).toBe('none') + await waitFor(() => expect(replacementExtractor.drawFrame).toHaveBeenCalledOnce()) + expect(visibleCanvas.style.display).toBe('none') + + replacementDraw.resolve(false) + await flushDeferredWork() + }) + + it('closes a deferred bitmap after unmount without repainting the retired canvas', async () => { + const staleBitmap = { close: vi.fn() } + const bitmapCompletion = deferred() + decoderHarness.createImageBitmap.mockReturnValueOnce(bitmapCompletion.promise) + decoderHarness.extractors.set('blob:old', makeExtractor()) + + const rendered = render( + , + ) + await waitFor(() => expect(decoderHarness.createImageBitmap).toHaveBeenCalledOnce()) + const visibleCanvas = rendered.container.querySelector('canvas')! + const visibleContext = getCanvasContext(visibleCanvas) + const drawCountBeforeUnmount = visibleContext.drawImage.mock.calls.length + const clearCountBeforeUnmount = visibleContext.clearRect.mock.calls.length + + rendered.unmount() + expect(visibleContext.clearRect.mock.calls.length).toBeGreaterThan(clearCountBeforeUnmount) + + bitmapCompletion.resolve(staleBitmap) + await flushDeferredWork() + + expect(staleBitmap.close).toHaveBeenCalledOnce() + expect(visibleContext.drawImage).toHaveBeenCalledTimes(drawCountBeforeUnmount) }) it('ignores an in-flight shared-cache completion from the old source', async () => { diff --git a/src/features/preview/components/source-composition.tsx b/src/features/preview/components/source-composition.tsx index f5d8f9098..d1328f21d 100644 --- a/src/features/preview/components/source-composition.tsx +++ b/src/features/preview/components/source-composition.tsx @@ -101,26 +101,45 @@ export function SourceComposition({ function LottieSource({ src }: { src: string }) { const canvasRef = useRef(null) + const sourceGenerationRef = useRef(0) const clock = useClock() + useLayoutEffect(() => { + const generation = ++sourceGenerationRef.current + const canvas = canvasRef.current + canvas?.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height) + if (canvas) canvas.style.display = 'none' + return () => { + if (sourceGenerationRef.current !== generation) return + sourceGenerationRef.current += 1 + canvas?.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height) + if (canvas) canvas.style.display = 'none' + } + }, [src]) + useEffect(() => { const canvas = canvasRef.current if (!canvas || !src) return + const generation = sourceGenerationRef.current + const isCurrent = () => sourceGenerationRef.current === generation const renderer = new LottieRenderer({ canvas, src, autoResize: true }) let raf = 0 let lastFrame = -1 let loaded = false renderer.ready.then(() => { - loaded = renderer.isLoaded + if (isCurrent()) loaded = renderer.isLoaded }) // Drive frames from the source clock imperatively (no per-frame React render). const tick = () => { + if (!isCurrent()) return if (loaded) { const total = renderer.totalFrames const frame = total > 0 ? Math.max(0, Math.min(Math.round(clock.currentFrame), total - 1)) : 0 if (frame !== lastFrame) { renderer.renderFrame(frame) + if (!isCurrent()) return + canvas.style.display = 'block' lastFrame = frame } } @@ -135,7 +154,7 @@ function LottieSource({ src }: { src: string }) { return ( - + ) } @@ -517,6 +536,28 @@ function VideoSource({ [activeSrc], ) + const commitDecodedFrameResult = useCallback( + (didDraw: boolean, targetTime: number): boolean => { + if (didDraw) { + consecutiveDecodeFailuresRef.current = 0 + queueDirectionalPrewarm(targetTime) + return true + } + + if (extractorRef.current?.getLastFailureKind() !== 'decode-error') return true + consecutiveDecodeFailuresRef.current += 1 + if (consecutiveDecodeFailuresRef.current < SOURCE_MONITOR_STRICT_DECODE_FALLBACK_FAILURES) { + return true + } + + decoderReadyRef.current = false + setStrictDecodeReady(false) + setUseLegacyPausedSeek((prev) => (prev ? prev : true)) + return false + }, + [queueDirectionalPrewarm], + ) + const pumpLatestDecodedFrame = useCallback(() => { if (renderInFlightGenerationRef.current !== null) return const generation = sourceGenerationRef.current @@ -535,24 +576,10 @@ function VideoSource({ pendingTimeRef.current = null const didDraw = await drawDecodedFrame(targetTime).catch(() => false) - if (didDraw) { - consecutiveDecodeFailuresRef.current = 0 - queueDirectionalPrewarm(targetTime) - continue - } - - const failureKind = extractorRef.current?.getLastFailureKind() ?? 'decode-error' - if (failureKind === 'decode-error') { - consecutiveDecodeFailuresRef.current += 1 - if ( - consecutiveDecodeFailuresRef.current >= SOURCE_MONITOR_STRICT_DECODE_FALLBACK_FAILURES - ) { - decoderReadyRef.current = false - setStrictDecodeReady(false) - setUseLegacyPausedSeek((prev) => (prev ? prev : true)) - return - } + if (!mountedRef.current || sourceGenerationRef.current !== generation) { + return } + if (!commitDecodedFrameResult(didDraw, targetTime)) return } } finally { if (renderInFlightGenerationRef.current === generation) { @@ -574,7 +601,7 @@ function VideoSource({ } void run() - }, [drawDecodedFrame, queueDirectionalPrewarm]) + }, [commitDecodedFrameResult, drawDecodedFrame]) pumpLatestDecodedFrameRef.current = pumpLatestDecodedFrame // Acquire/release pooled element when source changes. @@ -899,6 +926,7 @@ function ImageSource({ src }: { src: string }) { return ( Source preview ({ resolveMediaUrl: vi.fn().mockResolvedValue('blob:media-1'), })) +vi.mock('@/infrastructure/browser/blob-url-manager', () => ({ + useBlobUrlVersion: () => 0, +})) + vi.mock('@/features/preview/deps/media-library', () => { const useMediaLibraryStore = Object.assign( (selector: (state: typeof mediaStoreState) => unknown) => selector(mediaStoreState), diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index 1063dcd78..43cd01416 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react' +import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo, memo } from 'react' import { X, Play, @@ -70,6 +70,7 @@ import { import { formatTimecodeCompact } from '@/shared/utils/time-utils' import { getPreviewPixelSnapSize } from '../utils/preview-pixel-snap' import type { TimelineTrack } from '@/types/timeline' +import { useBlobUrlVersion } from '@/infrastructure/browser/blob-url-manager' interface SourceMonitorProps { mediaId: string @@ -205,6 +206,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ }: SourceMonitorProps) { const [blobUrl, setBlobUrl] = useState('') const media = useMediaLibraryStore((s) => s.mediaById[mediaId]) + const blobUrlVersion = useBlobUrlVersion() // Sync current media ID into source player store for I/O points useEffect(() => { @@ -228,8 +230,14 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ } }, [media, onClose]) - // Resolve the original source URL once. SourceComposition can swap to a - // ready proxy for video preview without losing the original fallback URL. + // Blank the retired source in layout so a same-ID relink cannot leave its + // canvas visible while the replacement URL resolves. + useLayoutEffect(() => { + setBlobUrl('') + }, [blobUrlVersion, mediaId]) + + // SourceComposition can swap to a ready proxy for video preview without + // losing the original fallback URL. Blob invalidation retries the same ID. useEffect(() => { let cancelled = false resolveMediaUrl(mediaId) @@ -242,7 +250,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ return () => { cancelled = true } - }, [mediaId]) + }, [blobUrlVersion, mediaId]) if (!media) return null diff --git a/src/features/preview/components/video-preview.sync.test.tsx b/src/features/preview/components/video-preview.sync.test.tsx index 1e6d4b2cb..72ff44db0 100644 --- a/src/features/preview/components/video-preview.sync.test.tsx +++ b/src/features/preview/components/video-preview.sync.test.tsx @@ -257,6 +257,7 @@ vi.mock('@/infrastructure/browser/blob-url-manager', async () => { return { blobUrlManager: { get: (mediaId: string) => mockState.blobUrls.get(mediaId) ?? null, + getEpoch: () => String(mockState.version.current), getMediaIdByUrl: (url: string) => [...mockState.blobUrls.entries()].find(([, candidate]) => candidate === url)?.[0] ?? null, has: (mediaId: string) => mockState.blobUrls.has(mediaId), @@ -552,6 +553,24 @@ function getCanvasClearRectCallCount(canvas: HTMLCanvasElement) { ) } +function getCanvasDrawImageCallCountFor(canvas: HTMLCanvasElement) { + const results = canvasGetContextSpy?.mock.results as + | Array<{ type: string; value: unknown }> + | undefined + return ( + results?.reduce((total: number, result) => { + if (result.type !== 'return' || !result.value) return total + const context = result.value as { + canvas?: HTMLCanvasElement + drawImage?: unknown + } + if (context.canvas !== canvas || typeof context.drawImage !== 'function') return total + if (!('mock' in context.drawImage)) return total + return total + (context.drawImage as { mock: { calls: unknown[] } }).mock.calls.length + }, 0) ?? 0 + ) +} + function resetStores() { usePlaybackStore.setState({ currentFrame: 0, @@ -2068,6 +2087,96 @@ describe('VideoPreview sync behavior', () => { }) }) + it('retires a deferred split-grade surface before same-item source replacement', async () => { + canvasPixelReadbackEnabled = true + const gradeEffect = { + id: 'effect-grade', + enabled: true, + effect: { + type: 'gpu-effect' as const, + gpuEffectType: 'gpu-color-wheels' as const, + params: { exposure: 0.5 }, + }, + } + setSingleVideoItemAtFrame({ + id: 'item-graded-replacement', + src: 'blob:old-graded-source', + effects: [gradeEffect], + }) + + const { container } = renderDefaultPreview() + await waitFor(() => expect(rendererMockState.instances).toHaveLength(1)) + act(() => { + useGizmoStore.getState().setColorGradeComparisonMode('split') + }) + + const splitRenderer = await waitFor(() => { + expect(rendererMockState.instances).toHaveLength(2) + expect(container.querySelector('[data-grade-comparison-after-layer="true"]')).not.toBeNull() + return rendererMockState.instances[1]! + }) + const rendererCalls = createCompositionRendererMock.mock.calls as unknown as Array< + [unknown, HTMLCanvasElement] + > + const oldSplitOffscreen = rendererCalls[1]![1] + const gpuDisplayCanvas = container.querySelectorAll('canvas')[1] as HTMLCanvasElement + setMockCanvasBlank(oldSplitOffscreen, false) + setMockCanvasBlank(gpuDisplayCanvas, false) + + let resolveOldRender: (() => void) | null = null + splitRenderer.renderFrame.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOldRender = resolve + }), + ) + act(() => { + useGizmoStore.getState().setEffectsPreviewNew({ + 'item-graded-replacement': [ + { ...gradeEffect, effect: { ...gradeEffect.effect, params: { exposure: 0.8 } } }, + ], + }) + }) + await waitFor(() => expect(resolveOldRender).not.toBeNull()) + + const clearCountBeforeReplacement = getCanvasClearRectCallCount(gpuDisplayCanvas) + const drawCountBeforeReplacement = getCanvasDrawImageCallCountFor(gpuDisplayCanvas) + const defaultRendererFactory = createCompositionRendererMock.getMockImplementation()! + createCompositionRendererMock.mockImplementation(() => new Promise(() => undefined)) + + act(() => { + useItemsStore.getState().setItems([ + { + id: 'item-graded-replacement', + type: 'video', + trackId: 'track-video', + from: 0, + durationInFrames: 120, + src: 'blob:new-graded-source', + effects: [gradeEffect], + } as unknown as TimelineItem, + ]) + }) + + await waitFor(() => expect(splitRenderer.dispose).toHaveBeenCalledOnce()) + expect(getCanvasClearRectCallCount(gpuDisplayCanvas)).toBeGreaterThan( + clearCountBeforeReplacement, + ) + expect(blankCanvasState.has(gpuDisplayCanvas)).toBe(true) + expect(gpuDisplayCanvas.style.visibility).toBe('hidden') + expect(container.querySelector('[data-grade-comparison-after-layer="true"]')).toBeNull() + + await act(async () => { + resolveOldRender?.() + await Promise.resolve() + await Promise.resolve() + }) + expect(getCanvasDrawImageCallCountFor(gpuDisplayCanvas)).toBe(drawCountBeforeReplacement) + expect(blankCanvasState.has(gpuDisplayCanvas)).toBe(true) + expect(gpuDisplayCanvas.style.visibility).toBe('hidden') + createCompositionRendererMock.mockImplementation(defaultRendererFactory) + }) + it('keeps the split after renderer warm when toggling away from split and back', async () => { setSingleVideoItemAtFrame({ id: 'item-graded', diff --git a/src/features/preview/components/video-preview.tsx b/src/features/preview/components/video-preview.tsx index 92d05c534..ab8cf95fc 100644 --- a/src/features/preview/components/video-preview.tsx +++ b/src/features/preview/components/video-preview.tsx @@ -123,13 +123,18 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ const livePreviewEdits = useGizmoStore((s) => s.preview) const [playerDisplayedFrame, setPlayerDisplayedFrame] = useState(null) const latestPlayerDisplayedFrameRef = useRef(null) - const [splitAfterRenderedFrame, setSplitAfterRenderedFrame] = useState(null) + const [splitAfterPresentation, setSplitAfterPresentation] = useState<{ + frame: number + structureKey: string + } | null>(null) const splitAfterRendererRef = useRef(null) const splitAfterInitPromiseRef = useRef | null>(null) const splitAfterInitGenerationRef = useRef(0) const splitAfterCanvasRef = useRef(null) const splitAfterRendererStructureKeyRef = useRef(null) - const splitAfterRenderInFlightRef = useRef(false) + const splitAfterRenderGenerationRef = useRef(0) + const splitAfterRenderOwnerRef = useRef(null) + const splitAfterRenderPumpRef = useRef<() => void>(() => {}) const splitAfterPendingFrameRef = useRef(null) const { playerRef, @@ -404,12 +409,12 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ const disposeSplitAfterRenderer = useCallback(() => { splitAfterInitGenerationRef.current += 1 + splitAfterRenderGenerationRef.current += 1 splitAfterInitPromiseRef.current = null splitAfterRendererStructureKeyRef.current = null splitAfterCanvasRef.current = null splitAfterPendingFrameRef.current = null - splitAfterRenderInFlightRef.current = false - setSplitAfterRenderedFrame(null) + setSplitAfterPresentation(null) const renderer = splitAfterRendererRef.current splitAfterRendererRef.current = null @@ -429,6 +434,20 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ if (canvas.height !== backingSize.height) canvas.height = backingSize.height }, [gpuEffectsCanvasRef, playerSize, renderSize]) + // A structure/source replacement can retain the same target frame, so frame + // readiness alone is insufficient. Retire the old split surface during the + // layout cleanup, before the replacement commit can paint, and use the same + // barrier on unmount. + useLayoutEffect(() => { + const canvas = gpuEffectsCanvasRef.current + return () => { + disposeSplitAfterRenderer() + if (!canvas) return + canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height) + canvas.style.visibility = 'hidden' + } + }, [disposeSplitAfterRenderer, fastScrubRendererStructureKey, gpuEffectsCanvasRef]) + const ensureSplitAfterRenderer = useCallback(async (): Promise => { if (!FAST_SCRUB_RENDERER_ENABLED) return null @@ -452,6 +471,7 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ if (!ctx) return null const { createCompositionRenderer } = await importCompositionRenderer() + if (splitAfterInitGenerationRef.current !== initGeneration) return null const renderer = await createCompositionRenderer(fastScrubInputProps, canvas, ctx, { mode: 'preview', useProxyMedia: useProxy, @@ -508,10 +528,6 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ useProxy, ]) - useEffect(() => { - disposeSplitAfterRenderer() - }, [disposeSplitAfterRenderer, fastScrubRendererStructureKey]) - // Enter the composited path in the same render that activates the editor. // Waiting for the timeline-wide effect scan adds a reactive round trip that // makes the first neutral-EV drag look stuck until another parameter changes. @@ -905,34 +921,38 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ useEffect(() => { if (stageColorGradeComparisonMode === 'split') return splitAfterPendingFrameRef.current = null - setSplitAfterRenderedFrame((frame) => (frame === null ? frame : null)) + setSplitAfterPresentation((presentation) => (presentation === null ? presentation : null)) }, [stageColorGradeComparisonMode]) useEffect(() => { if (stageColorGradeComparisonMode !== 'split') return let cancelled = false + const renderGeneration = ++splitAfterRenderGenerationRef.current splitAfterPendingFrameRef.current = comparisonTargetFrame - // Intentionally NOT resetting `splitAfterRenderedFrame` here: the readiness - // check (`splitAfterRenderedFrame === comparisonTargetFrame`) already gates - // the overlay, so a stale frame stays hidden until the async render catches - // up. The previous synchronous reset fed a render cascade - // (displayedFrame → comparisonTargetFrame → setState → displayedFrame …) - // that tripped React's "maximum update depth". + const isCurrent = () => !cancelled && splitAfterRenderGenerationRef.current === renderGeneration const renderPendingSplitAfter = async () => { - if (splitAfterRenderInFlightRef.current) return - splitAfterRenderInFlightRef.current = true + if (splitAfterRenderOwnerRef.current !== null) return + splitAfterRenderOwnerRef.current = renderGeneration try { - while (!cancelled && splitAfterPendingFrameRef.current !== null) { + while (isCurrent() && splitAfterPendingFrameRef.current !== null) { const targetFrame = splitAfterPendingFrameRef.current splitAfterPendingFrameRef.current = null const renderer = await ensureSplitAfterRenderer() + if (!isCurrent()) return const offscreen = splitAfterCanvasRef.current const displayCanvas = gpuEffectsCanvasRef.current - if (cancelled || !renderer || !offscreen || !displayCanvas) return + if ( + !renderer || + !offscreen || + !displayCanvas || + splitAfterRendererRef.current !== renderer || + splitAfterCanvasRef.current !== offscreen + ) + return try { renderer.invalidateFrameCache({ frames: [targetFrame] }) @@ -940,36 +960,55 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ // Some renderer doubles do not support selective invalidation. } await renderer.renderFrame(targetFrame) - if (cancelled || splitAfterPendingFrameRef.current !== null) continue + if ( + !isCurrent() || + splitAfterRendererRef.current !== renderer || + splitAfterCanvasRef.current !== offscreen || + gpuEffectsCanvasRef.current !== displayCanvas + ) + return + if (splitAfterPendingFrameRef.current !== null) continue const displayCtx = displayCanvas.getContext('2d') if (!displayCtx) return + if (!isCurrent()) return drawSourceToPreviewDisplayCanvas(displayCtx, displayCanvas, offscreen) - setSplitAfterRenderedFrame(targetFrame) + if (!isCurrent()) return + setSplitAfterPresentation({ + frame: targetFrame, + structureKey: fastScrubRendererStructureKey, + }) } } finally { - splitAfterRenderInFlightRef.current = false - if (!cancelled && splitAfterPendingFrameRef.current !== null) { - void renderPendingSplitAfter() + if (splitAfterRenderOwnerRef.current === renderGeneration) { + splitAfterRenderOwnerRef.current = null + } + if (splitAfterPendingFrameRef.current !== null) { + queueMicrotask(() => splitAfterRenderPumpRef.current()) } } } - void renderPendingSplitAfter() + splitAfterRenderPumpRef.current = () => { + void renderPendingSplitAfter() + } + splitAfterRenderPumpRef.current() return () => { cancelled = true + if (splitAfterRenderGenerationRef.current === renderGeneration) { + splitAfterRenderGenerationRef.current += 1 + } } }, [ comparisonTargetFrame, ensureSplitAfterRenderer, + fastScrubRendererStructureKey, gpuEffectsCanvasRef, livePreviewEdits, stageColorGradeComparisonMode, ]) - useEffect(() => () => disposeSplitAfterRenderer(), [disposeSplitAfterRenderer]) - const livePlayerFrame = playerRef.current?.getCurrentFrame() const normalizedLivePlayerFrame = livePlayerFrame === undefined || !Number.isFinite(livePlayerFrame) @@ -981,7 +1020,8 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ const isColorGradeComparisonFrameReady = comparisonDisplayedFrame === comparisonTargetFrame && (isSplitGradeComparison - ? splitAfterRenderedFrame === comparisonTargetFrame + ? splitAfterPresentation?.frame === comparisonTargetFrame && + splitAfterPresentation.structureKey === fastScrubRendererStructureKey : stageColorGradeComparisonMode === 'before' || effectivePlayerDisplayedFrame === comparisonTargetFrame) const stageRenderedOverlayVisible = isColorGradeComparisonActive diff --git a/src/features/preview/hooks/use-preview-media-resolution.test.tsx b/src/features/preview/hooks/use-preview-media-resolution.test.tsx index ac9c58cce..e2b1005a7 100644 --- a/src/features/preview/hooks/use-preview-media-resolution.test.tsx +++ b/src/features/preview/hooks/use-preview-media-resolution.test.tsx @@ -4,17 +4,52 @@ import { useMediaDependencyStore } from '@/features/preview/deps/timeline-store' import type { TimelineTrack } from '@/types/timeline' import { usePreviewMediaResolution } from './use-preview-media-resolution' -vi.mock('../utils/media-resolver', () => ({ +const resolverHarness = vi.hoisted(() => ({ + epoch: 0, resolveMediaUrl: vi.fn(() => new Promise(() => {})), })) +vi.mock('../utils/media-resolver', () => ({ + resolveMediaUrl: resolverHarness.resolveMediaUrl, +})) + vi.mock('@/infrastructure/browser/blob-url-manager', () => ({ blobUrlManager: { get: (mediaId: string) => (mediaId === 'media-priority' ? 'blob:priority' : null), + getEpoch: () => String(resolverHarness.epoch), invalidateAll: vi.fn(), }, })) +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +function makeHookParams() { + return { + fps: 30, + combinedTracks: [] as TimelineTrack[], + mediaResolveCostById: new Map(), + mediaDependencyVersion: 0, + blobUrlVersion: 0, + brokenMediaCount: 0, + previewPerfRef: { + current: { + resolveSamples: 0, + resolveTotalMs: 0, + resolveTotalIds: 0, + resolveLastMs: 0, + resolveLastIds: 0, + }, + }, + isGizmoInteractingRef: { current: false }, + } +} + const combinedTracks = [ { id: 'track-video', @@ -41,6 +76,9 @@ const combinedTracks = [ describe('usePreviewMediaResolution', () => { afterEach(() => { + resolverHarness.epoch = 0 + resolverHarness.resolveMediaUrl.mockReset() + resolverHarness.resolveMediaUrl.mockImplementation(() => new Promise(() => {})) useMediaDependencyStore.setState({ mediaIds: [], mediaDependencyVersion: 0 }) }) @@ -88,4 +126,38 @@ describe('usePreviewMediaResolution', () => { unmount() }) + + it('deduplicates only within the current media epoch while an old request drains', async () => { + const oldResolution = deferred() + const newResolution = deferred() + resolverHarness.resolveMediaUrl + .mockReturnValueOnce(oldResolution.promise) + .mockReturnValueOnce(newResolution.promise) + const { result } = renderHook(() => usePreviewMediaResolution(makeHookParams())) + + const firstBatch = result.current.resolveMediaBatch(['media-race']) + await waitFor(() => expect(resolverHarness.resolveMediaUrl).toHaveBeenCalledTimes(1)) + + resolverHarness.epoch += 1 + const secondBatch = result.current.resolveMediaBatch(['media-race']) + await waitFor(() => expect(resolverHarness.resolveMediaUrl).toHaveBeenCalledTimes(2)) + + const thirdBatch = result.current.resolveMediaBatch(['media-race']) + expect(resolverHarness.resolveMediaUrl).toHaveBeenCalledTimes(2) + + oldResolution.resolve(null) + await expect(firstBatch).resolves.toEqual({ + resolvedEntries: [], + failedIds: ['media-race'], + }) + expect(resolverHarness.resolveMediaUrl).toHaveBeenCalledTimes(2) + + newResolution.resolve('blob:new-source') + const expected = { + resolvedEntries: [{ mediaId: 'media-race', url: 'blob:new-source' }], + failedIds: [], + } + await expect(secondBatch).resolves.toEqual(expected) + await expect(thirdBatch).resolves.toEqual(expected) + }) }) diff --git a/src/features/preview/hooks/use-preview-media-resolution.ts b/src/features/preview/hooks/use-preview-media-resolution.ts index 1b52ee656..5700e38f5 100644 --- a/src/features/preview/hooks/use-preview-media-resolution.ts +++ b/src/features/preview/hooks/use-preview-media-resolution.ts @@ -24,6 +24,11 @@ type ResolveMediaBatchResult = { failedIds: string[] } +interface PendingPreviewResolve { + epoch: string + promise: Promise +} + interface UsePreviewMediaResolutionParams { fps: number combinedTracks: TimelineTrack[] @@ -58,7 +63,7 @@ export function usePreviewMediaResolution({ const unresolvedMediaIdsRef = useRef([]) const unresolvedMediaIdSetRef = useRef>(new Set()) - const pendingResolvePromisesRef = useRef>>(new Map()) + const pendingResolvePromisesRef = useRef>(new Map()) const preloadResolveInFlightRef = useRef(false) const preloadBurstRemainingRef = useRef(0) const preloadScanTrackCursorRef = useRef(0) @@ -234,19 +239,23 @@ export function usePreviewMediaResolution({ const resolveMediaUrlDeduped = useCallback((mediaId: string): Promise => { const pendingMap = pendingResolvePromisesRef.current - const existingPromise = pendingMap.get(mediaId) - if (existingPromise) { - return existingPromise + const epoch = blobUrlManager.getEpoch(mediaId) + const existingRequest = pendingMap.get(mediaId) + if (existingRequest?.epoch === epoch) { + return existingRequest.promise } - const promise = resolveMediaUrl(mediaId) + let promise!: Promise + promise = resolveMediaUrl(mediaId) .then((url) => url ?? null) .catch(() => null) .finally(() => { - pendingMap.delete(mediaId) + if (pendingMap.get(mediaId)?.promise === promise) { + pendingMap.delete(mediaId) + } }) - pendingMap.set(mediaId, promise) + pendingMap.set(mediaId, { epoch, promise }) return promise }, []) diff --git a/src/features/preview/utils/media-resolver.test.ts b/src/features/preview/utils/media-resolver.test.ts index 1b09f3ca4..4d40b0bd0 100644 --- a/src/features/preview/utils/media-resolver.test.ts +++ b/src/features/preview/utils/media-resolver.test.ts @@ -47,6 +47,16 @@ vi.mock('@/features/media-library/stores/media-library-store', () => ({ let blobUrlCounter = 0 +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + beforeEach(() => { vi.clearAllMocks() blobUrlManager.releaseAll() @@ -257,6 +267,34 @@ describe('resolveMediaUrl', () => { // Service should only be called once (second call uses pending promise) expect(mediaLibraryService.getMedia).toHaveBeenCalledTimes(1) }) + + it('keeps a late pre-invalidation request from replacing the new source', async () => { + const oldBlob = deferred() + const newBlob = deferred() + ;(mediaLibraryService.getMedia as Mock) + .mockResolvedValueOnce({ id: 'media-1', fileName: 'old.mp4' }) + .mockResolvedValueOnce({ id: 'media-1', fileName: 'new.mp4' }) + ;(mediaLibraryService.getMediaFile as Mock) + .mockReturnValueOnce(oldBlob.promise) + .mockReturnValueOnce(newBlob.promise) + + const oldResolution = resolveMediaUrl('media-1') + await vi.waitFor(() => expect(mediaLibraryService.getMediaFile).toHaveBeenCalledTimes(1)) + + blobUrlManager.invalidate('media-1') + const newResolution = resolveMediaUrl('media-1') + await vi.waitFor(() => expect(mediaLibraryService.getMediaFile).toHaveBeenCalledTimes(2)) + + newBlob.resolve(new Blob(['new-source'])) + await expect(newResolution).resolves.toBe('blob:test-1') + expect(blobUrlManager.get('media-1')).toBe('blob:test-1') + + oldBlob.resolve(new Blob(['old-source'])) + await expect(oldResolution).resolves.toBe('') + expect(blobUrlManager.get('media-1')).toBe('blob:test-1') + expect(blobUrlCounter).toBe(1) + expect(mockMarkMediaHealthy).toHaveBeenCalledTimes(1) + }) }) describe('resolveMediaUrls', () => { diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx index dadbc47a5..7bac058d4 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx @@ -410,4 +410,49 @@ describe('useClipboardShortcuts paste placement', () => { expect(useTimelineStore.getState().tracks[0]?.id).toBe(TARGET_TRACK.id) expect(useTimelineStore.getState().items).toEqual([]) }) + + it('creates and atomically undoes a missing linked audio lane after a video collision', () => { + useTimelineStore.setState({ + tracks: [TARGET_TRACK], + items: [makeVideoItem({ id: 'occupied-video', from: 200 })], + }) + useClipboardStore.getState().copyItems( + [ + makeVideoItem({ + id: 'linked-video', + trackId: 'missing-video', + linkedGroupId: 'source-pair', + }), + makeAudioItem({ + id: 'linked-audio', + trackId: 'missing-audio', + linkedGroupId: 'source-pair', + }), + ], + 0, + 'copy', + ) + + render() + act(() => getPasteCallback()({ preventDefault: vi.fn() })) + + const pasted = useTimelineStore.getState().items.filter((item) => item.id !== 'occupied-video') + expect(pasted).toHaveLength(2) + expect(pasted.map((item) => item.from)).toEqual([210, 210]) + expect(pasted[0]!.linkedGroupId).toBeTruthy() + expect(pasted[1]!.linkedGroupId).toBe(pasted[0]!.linkedGroupId) + expect(useTimelineStore.getState().tracks.map((track) => track.kind)).toEqual([ + 'video', + 'audio', + ]) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + + act(() => useTimelineCommandStore.getState().undo()) + expect(useTimelineStore.getState().tracks).toHaveLength(1) + expect(useTimelineStore.getState().tracks[0]).toMatchObject({ + id: TARGET_TRACK.id, + kind: 'video', + }) + expect(useTimelineStore.getState().items.map((item) => item.id)).toEqual(['occupied-video']) + }) }) diff --git a/src/features/timeline/stores/actions/export-snapshot.ts b/src/features/timeline/stores/actions/export-snapshot.ts index 631f14f57..fd1e2e074 100644 --- a/src/features/timeline/stores/actions/export-snapshot.ts +++ b/src/features/timeline/stores/actions/export-snapshot.ts @@ -127,8 +127,16 @@ export function getExportableSequence(sequenceId: string | null): ExportableSequ if (sequenceId === null) { const root = getRootTimelineSnapshot(current) const metadata = useProjectStore.getState().currentProject?.metadata - // Main's audio bus / range are live when Main is active, else held aside. - const busAudioEq = activeTabId === null ? playback.busAudioEq : nav.mainHolder?.busAudioEq + // Main owns the live mixer/range only when Main itself is the loaded + // composition. While drilling from Main, its complete snapshot is the + // null-composition root stash; while another tab is active it is held in + // mainHolder. The active tab alone cannot distinguish Main from a drilled + // child because both retain a null root breadcrumb. + const heldRoot = + activeTabId === null + ? nav.stashStack.find((stash) => stash.compositionId === null) + : nav.mainHolder + const busAudioEq = nav.activeCompositionId === null ? playback.busAudioEq : heldRoot?.busAudioEq return { id: null, name: MAIN_LABEL, @@ -143,7 +151,7 @@ export function getExportableSequence(sequenceId: string | null): ExportableSequ busAudioEq: cloneAudioEq(busAudioEq), masterBusDb: playback.masterBusDb, durationFrames: furthestItemEnd(root.items), - ...range(nav.mainHolder), + ...range(heldRoot), } } diff --git a/src/features/timeline/stores/export-snapshot.test.ts b/src/features/timeline/stores/export-snapshot.test.ts index 782e86ce4..036e3693d 100644 --- a/src/features/timeline/stores/export-snapshot.test.ts +++ b/src/features/timeline/stores/export-snapshot.test.ts @@ -10,6 +10,7 @@ import { useItemsStore } from './items-store' import { useCompositionsStore } from './compositions-store' import { useSequencesStore } from './sequences-store' import { useCompositionNavigationStore } from './composition-navigation-store' +import { useMarkersStore } from './markers-store' import { usePlaybackStore } from '@/shared/state/playback' import { getActiveExportSequenceId, @@ -62,6 +63,55 @@ function seedNestedSequence(): void { }) } +function makeCompositionEntry( + id: string, + compositionId: string, + trackId: string, + durationInFrames: number, +): CompositionItem { + return { + ...makeVideoItem({ id, trackId, durationInFrames }), + type: 'composition', + compositionId, + compositionWidth: 640, + compositionHeight: 360, + } as unknown as CompositionItem +} + +function seedMainChildGrandchild(): void { + useCompositionsStore.getState().addComposition({ + id: 'grandchild', + name: 'grandchild', + tracks: [makeTrack({ id: 'grandchild-v1', name: 'V1', kind: 'video', order: 0 })], + items: [ + makeVideoItem({ + id: 'grandchild-clip', + trackId: 'grandchild-v1', + durationInFrames: 20, + }), + ], + transitions: [], + keyframes: [], + fps: 24, + width: 640, + height: 360, + durationInFrames: 20, + }) + useCompositionsStore.getState().addComposition({ + id: 'child', + name: 'child', + tracks: [makeTrack({ id: 'child-v1', name: 'V1', kind: 'video', order: 0 })], + items: [makeCompositionEntry('grandchild-entry', 'grandchild', 'child-v1', 20)], + transitions: [], + keyframes: [], + fps: 24, + width: 640, + height: 360, + durationInFrames: 20, + }) + useItemsStore.getState().setItems([makeCompositionEntry('child-entry', 'child', 'track-v1', 20)]) +} + describe('export-snapshot sourcing', () => { beforeEach(() => { resetTimelineCompositionTestState() @@ -168,4 +218,36 @@ describe('export-snapshot sourcing', () => { expect(sequence.busAudioEq).toEqual(sequenceEq) expect(main.busAudioEq).toEqual(mainEq) }) + + it('keeps Main, child, and grandchild EQ and ranges owned by their actual composition', () => { + const mainEq = { enabled: true, lowGainDb: 1 } + const childEq = { enabled: true, lowGainDb: 5 } + const grandchildEq = { enabled: true, lowGainDb: 9 } + seedMainChildGrandchild() + + usePlaybackStore.getState().setBusAudioEq(mainEq) + useMarkersStore.getState().setInOutPoints(2, 18) + useCompositionNavigationStore.getState().enterComposition('child', 'child', 'child-entry') + + usePlaybackStore.getState().setBusAudioEq(childEq) + useMarkersStore.getState().setInOutPoints(3, 15) + useCompositionNavigationStore + .getState() + .enterComposition('grandchild', 'grandchild', 'grandchild-entry') + + usePlaybackStore.getState().setBusAudioEq(grandchildEq) + useMarkersStore.getState().setInOutPoints(4, 12) + + const main = getExportableSequence(null) + const child = getExportableSequence('child') + const grandchild = getExportableSequence('grandchild') + + expect(main).toMatchObject({ busAudioEq: mainEq, inPoint: 2, outPoint: 18 }) + expect(child).toMatchObject({ busAudioEq: childEq, inPoint: 3, outPoint: 15 }) + expect(grandchild).toMatchObject({ + busAudioEq: grandchildEq, + inPoint: 4, + outPoint: 12, + }) + }) }) diff --git a/src/infrastructure/browser/blob-url-manager.test.ts b/src/infrastructure/browser/blob-url-manager.test.ts index 17ac2c918..20d5ecdd7 100644 --- a/src/infrastructure/browser/blob-url-manager.test.ts +++ b/src/infrastructure/browser/blob-url-manager.test.ts @@ -96,8 +96,12 @@ describe('BlobUrlManager', () => { }) it('is a no-op for unknown mediaId', () => { + const epoch = blobUrlManager.getEpoch('unknown') + const version = blobUrlManager.getSnapshot() blobUrlManager.invalidate('unknown') expect(blobUrlManager.size).toBe(0) + expect(blobUrlManager.getEpoch('unknown')).not.toBe(epoch) + expect(blobUrlManager.getSnapshot()).toBeGreaterThan(version) }) it('allows re-acquiring after invalidation', () => { @@ -153,5 +157,15 @@ describe('BlobUrlManager', () => { expect(revokedUrls.has(url1)).toBe(true) expect(revokedUrls.has(url2)).toBe(true) }) + + it('retires pending generations for every media id', () => { + const firstEpoch = blobUrlManager.getEpoch('media-1') + const secondEpoch = blobUrlManager.getEpoch('media-2') + + blobUrlManager.releaseAll() + + expect(blobUrlManager.getEpoch('media-1')).not.toBe(firstEpoch) + expect(blobUrlManager.getEpoch('media-2')).not.toBe(secondEpoch) + }) }) }) diff --git a/src/infrastructure/browser/blob-url-manager.ts b/src/infrastructure/browser/blob-url-manager.ts index cc0db975b..1b245085c 100644 --- a/src/infrastructure/browser/blob-url-manager.ts +++ b/src/infrastructure/browser/blob-url-manager.ts @@ -29,6 +29,8 @@ interface BlobUrlEntry { class BlobUrlManager { private entries = new Map() private version = 0 + private invalidationEpoch = 0 + private mediaInvalidationEpochs = new Map() private listeners = new Set<() => void>() /** Notify React subscribers that blob URLs have changed */ @@ -106,6 +108,19 @@ class BlobUrlManager { return this.entries.has(mediaId) } + /** + * Token identifying the currently valid source generation for one media id. + * Resolvers capture this before async storage reads and must discard a + * completion when invalidation advances either component. + */ + getEpoch(mediaId: string): string { + return `${this.invalidationEpoch}:${this.mediaInvalidationEpochs.get(mediaId) ?? 0}` + } + + private advanceMediaEpoch(mediaId: string): void { + this.mediaInvalidationEpochs.set(mediaId, (this.mediaInvalidationEpochs.get(mediaId) ?? 0) + 1) + } + /** * Reverse-lookup: find the mediaId that owns a given blob URL. * Returns null if the URL is not tracked. @@ -122,10 +137,12 @@ class BlobUrlManager { * Used when the underlying media file has changed (e.g., after relinking). */ invalidate(mediaId: string): void { + this.advanceMediaEpoch(mediaId) const entry = this.entries.get(mediaId) - if (!entry) return - this.revokeEntry(entry) - this.entries.delete(mediaId) + if (entry) { + this.revokeEntry(entry) + this.entries.delete(mediaId) + } this.notify() } @@ -159,6 +176,8 @@ class BlobUrlManager { * Consumers will re-acquire fresh URLs on next resolve. */ invalidateAll(): void { + this.invalidationEpoch++ + this.mediaInvalidationEpochs.clear() for (const entry of this.entries.values()) { this.revokeEntry(entry) } @@ -170,6 +189,8 @@ class BlobUrlManager { * Release all blob URLs (e.g., on project cleanup). */ releaseAll(): void { + this.invalidationEpoch++ + this.mediaInvalidationEpochs.clear() for (const [mediaId, entry] of this.entries) { this.revokeEntry(entry) logger.debug(`Revoked blob URL for media ${mediaId}`) From 8eaf76de8d47b61f8479149dd5a7c25fd115c76c Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 22:16:37 -0700 Subject: [PATCH 15/64] fix(preview): retire invalidated source canvases (cherry picked from commit 5cf81b4e320cda8050d87da3a3fb73231be01d27) --- .../components/video-preview.sync.test.tsx | 197 +++++++++++++++++- .../preview/components/video-preview.tsx | 39 ++++ .../hooks/use-preview-composition-model.ts | 44 +++- 3 files changed, 274 insertions(+), 6 deletions(-) diff --git a/src/features/preview/components/video-preview.sync.test.tsx b/src/features/preview/components/video-preview.sync.test.tsx index 72ff44db0..8cb70449b 100644 --- a/src/features/preview/components/video-preview.sync.test.tsx +++ b/src/features/preview/components/video-preview.sync.test.tsx @@ -22,6 +22,7 @@ const playMock = vi.fn() const pauseMock = vi.fn() const mockState = vi.hoisted(() => { const blobUrls = new Map() + const mediaEpochs = new Map() const listeners = new Set<() => void>() const version = { current: 0 } const resolveMediaUrlMock = vi.fn(async (mediaId: string) => blobUrls.get(mediaId) ?? '') @@ -40,6 +41,7 @@ const mockState = vi.hoisted(() => { if (url === null) { blobUrls.delete(mediaId) + mediaEpochs.set(mediaId, (mediaEpochs.get(mediaId) ?? 0) + 1) } else { blobUrls.set(mediaId, url) } @@ -55,6 +57,7 @@ const mockState = vi.hoisted(() => { return { blobUrls, + mediaEpochs, listeners, version, resolveMediaUrlMock, @@ -67,12 +70,26 @@ const mockState = vi.hoisted(() => { const { blobUrls: mockBlobUrls, + mediaEpochs: mockMediaEpochs, listeners: blobUrlListeners, version: mockBlobUrlVersion, resolveMediaUrlMock, resolveProxyUrlMock, setBlobUrl: setMockBlobUrl, } = mockState + +function BlobBindingLayoutProbe({ onLayout }: { onLayout: (version: number) => void }) { + const version = React.useSyncExternalStore( + mockState.subscribeVersion, + () => mockState.version.current, + ) + + React.useLayoutEffect(() => { + onLayout(version) + }, [onLayout, version]) + + return null +} let mockedPlayerFrame = 0 let mockedPlayerIsPlaying = false let deferPlayerSeekCompletion = false @@ -257,7 +274,7 @@ vi.mock('@/infrastructure/browser/blob-url-manager', async () => { return { blobUrlManager: { get: (mediaId: string) => mockState.blobUrls.get(mediaId) ?? null, - getEpoch: () => String(mockState.version.current), + getEpoch: (mediaId: string) => String(mockState.mediaEpochs.get(mediaId) ?? 0), getMediaIdByUrl: (url: string) => [...mockState.blobUrls.entries()].find(([, candidate]) => candidate === url)?.[0] ?? null, has: (mediaId: string) => mockState.blobUrls.has(mediaId), @@ -275,9 +292,9 @@ vi.mock('@/infrastructure/browser/blob-url-manager', async () => { } }, invalidate: (mediaId: string) => { - if (mockState.blobUrls.delete(mediaId)) { - mockState.publishVersion() - } + mockState.blobUrls.delete(mediaId) + mockState.mediaEpochs.set(mediaId, (mockState.mediaEpochs.get(mediaId) ?? 0) + 1) + mockState.publishVersion() }, invalidateAll: () => { if (mockState.blobUrls.size === 0) return @@ -571,6 +588,25 @@ function getCanvasDrawImageCallCountFor(canvas: HTMLCanvasElement) { ) } +function getCanvasDrawImageCallCountFrom(canvas: HTMLCanvasElement, source: CanvasImageSource) { + const results = canvasGetContextSpy?.mock.results as + | Array<{ type: string; value: unknown }> + | undefined + return ( + results?.reduce((total: number, result) => { + if (result.type !== 'return' || !result.value) return total + const context = result.value as { + canvas?: HTMLCanvasElement + drawImage?: unknown + } + if (context.canvas !== canvas || typeof context.drawImage !== 'function') return total + if (!('mock' in context.drawImage)) return total + const calls = (context.drawImage as { mock: { calls: unknown[][] } }).mock.calls + return total + calls.filter(([drawSource]) => drawSource === source).length + }, 0) ?? 0 + ) +} + function resetStores() { usePlaybackStore.setState({ currentFrame: 0, @@ -946,6 +982,7 @@ describe('VideoPreview sync behavior', () => { lastCompositionKeyframes = [] lastCompositionMediaSources = [] mockBlobUrls.clear() + mockMediaEpochs.clear() blobUrlListeners.clear() mockBlobUrlVersion.current = 0 resolveMediaUrlMock.mockClear() @@ -2177,6 +2214,158 @@ describe('VideoPreview sync behavior', () => { createCompositionRendererMock.mockImplementation(defaultRendererFactory) }) + it('retires a split-grade source binding in layout before resolver effects can reuse it', async () => { + canvasPixelReadbackEnabled = true + const mediaId = 'same-media-relink' + const oldUrl = 'blob:old-relink-source' + const newUrl = 'blob:new-relink-source' + const oldPresentationUrl = 'blob:old-relink-proxy' + const newPresentationUrl = 'blob:new-relink-proxy' + const gradeEffect = { + id: 'effect-grade', + enabled: true, + effect: { + type: 'gpu-effect' as const, + gpuEffectType: 'gpu-color-wheels' as const, + params: { exposure: 0.5 }, + }, + } + resolveProxyUrlMock.mockImplementation((candidateId) => { + if (candidateId !== mediaId) return null + return mockBlobUrls.get(mediaId) === newUrl ? newPresentationUrl : oldPresentationUrl + }) + setMockBlobUrl(mediaId, oldUrl) + setSingleVideoItemAtFrame({ + id: 'item-same-media-relink', + mediaId, + src: oldUrl, + effects: [gradeEffect], + }) + + let observeRelinkLayout = false + let layoutObservation: + | { + blank: boolean + hidden: boolean + oldRendererDisposed: boolean + resolveCallCount: number + } + | undefined + let gpuDisplayCanvas: HTMLCanvasElement | null = null + let oldSplitRenderer: (typeof rendererMockState.instances)[number] | null = null + const onBindingLayout = () => { + if (!observeRelinkLayout || !gpuDisplayCanvas || !oldSplitRenderer) return + layoutObservation = { + blank: blankCanvasState.has(gpuDisplayCanvas), + hidden: gpuDisplayCanvas.style.visibility === 'hidden', + oldRendererDisposed: oldSplitRenderer.dispose.mock.calls.length === 1, + resolveCallCount: resolveMediaUrlMock.mock.calls.length, + } + } + + const { container } = render( + <> + + + , + ) + await waitFor(() => expect(rendererMockState.instances).toHaveLength(1)) + act(() => { + useGizmoStore.getState().setColorGradeComparisonMode('split') + }) + + oldSplitRenderer = await waitFor(() => { + expect(rendererMockState.instances).toHaveLength(2) + expect(container.querySelector('[data-grade-comparison-after-layer="true"]')).not.toBeNull() + return rendererMockState.instances[1]! + }) + const oldSplitCall = createCompositionRendererMock.mock.calls[1] as unknown as [ + unknown, + HTMLCanvasElement, + ] + gpuDisplayCanvas = container.querySelectorAll('canvas')[1] as HTMLCanvasElement + setMockCanvasBlank(oldSplitCall[1], false) + setMockCanvasBlank(gpuDisplayCanvas, false) + + let resolveOldRender: (() => void) | null = null + oldSplitRenderer.renderFrame.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOldRender = resolve + }), + ) + act(() => { + useGizmoStore.getState().setEffectsPreviewNew({ + 'item-same-media-relink': [ + { ...gradeEffect, effect: { ...gradeEffect.effect, params: { exposure: 0.8 } } }, + ], + }) + }) + await waitFor(() => expect(resolveOldRender).not.toBeNull()) + + const resolveCallsBeforeInvalidation = resolveMediaUrlMock.mock.calls.length + observeRelinkLayout = true + act(() => { + setMockBlobUrl(mediaId, null) + }) + + expect(layoutObservation).toEqual({ + blank: true, + hidden: true, + oldRendererDisposed: true, + resolveCallCount: resolveCallsBeforeInvalidation, + }) + await waitFor(() => { + expect(resolveMediaUrlMock.mock.calls.length).toBeGreaterThan(resolveCallsBeforeInvalidation) + }) + expect(blankCanvasState.has(gpuDisplayCanvas)).toBe(true) + expect(gpuDisplayCanvas.style.visibility).toBe('hidden') + + const oldSourceDrawCount = getCanvasDrawImageCallCountFrom(gpuDisplayCanvas, oldSplitCall[1]) + await act(async () => { + resolveOldRender?.() + await Promise.resolve() + await Promise.resolve() + }) + expect(getCanvasDrawImageCallCountFrom(gpuDisplayCanvas, oldSplitCall[1])).toBe( + oldSourceDrawCount, + ) + expect(gpuDisplayCanvas.style.visibility).toBe('hidden') + + act(() => { + setMockBlobUrl(mediaId, newUrl) + }) + const sourceBindingRendererCalls = createCompositionRendererMock.mock.calls as unknown as Array< + [ + inputProps: { + tracks: Array<{ items: Array<{ mediaId?: string; src?: string }> }> + }, + ] + > + const replacementCallIndex = await waitFor(() => { + const index = sourceBindingRendererCalls.findIndex(([inputProps]) => + inputProps.tracks.some((track) => + track.items.some((item) => item.mediaId === mediaId && item.src === newPresentationUrl), + ), + ) + expect(index).toBeGreaterThan(1) + return index + }) + const replacementRenderer = rendererMockState.instances[replacementCallIndex]! + await waitFor(() => expect(replacementRenderer.renderFrame).toHaveBeenCalledWith(24)) + await waitFor(() => { + expect(container.querySelector('[data-grade-comparison-after-layer="true"]')).not.toBeNull() + expect(gpuDisplayCanvas.style.visibility).toBe('visible') + }) + + for (const [inputProps] of sourceBindingRendererCalls.slice(2)) { + const sources = inputProps.tracks.flatMap((track) => + track.items.filter((item) => item.mediaId === mediaId).map((item) => item.src), + ) + expect(sources).not.toContain(oldPresentationUrl) + } + }) + it('keeps the split after renderer warm when toggling away from split and back', async () => { setSingleVideoItemAtFrame({ id: 'item-graded', diff --git a/src/features/preview/components/video-preview.tsx b/src/features/preview/components/video-preview.tsx index ab8cf95fc..771fe19d7 100644 --- a/src/features/preview/components/video-preview.tsx +++ b/src/features/preview/components/video-preview.tsx @@ -72,6 +72,27 @@ interface PreviewItemsSnapshot { itemsByTrackId: Record } +function hasResolvedVisualSourceAtFrame( + tracks: Array<{ visible?: boolean; solo?: boolean; items: TimelineItem[] }>, + frame: number, +): boolean { + const hasSoloTrack = tracks.some((track) => track.solo) + for (const track of tracks) { + if (track.visible === false || (hasSoloTrack && !track.solo)) continue + for (const item of track.items) { + if (frame < item.from || frame >= item.from + item.durationInFrames) continue + if ( + item.mediaId && + (item.type === 'video' || item.type === 'image' || item.type === 'lottie') && + (!('src' in item) || !item.src) + ) { + return false + } + } + } + return true +} + /** * Video Preview Component * @@ -296,6 +317,7 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ fastScrubInputProps, fastScrubPreviewItems, fastScrubTracksTopologyFingerprint, + sourceBindingIdentity, getPreviewTransformOverride, getPreviewEffectsOverride, getPreviewCornerPinOverride, @@ -382,6 +404,7 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ renderSize.height, project.backgroundColor ?? '', useProxy ? 'proxy' : 'source', + sourceBindingIdentity, fastScrubTracksTopologyFingerprint, domTextScrubOverlayPlan.enabled ? 'dom-text-overlay' : 'composited-text', playbackTransitionFingerprint, @@ -396,6 +419,7 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ project.width, renderSize.height, renderSize.width, + sourceBindingIdentity, useProxy, ], ) @@ -914,6 +938,14 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ stageColorGradeComparisonMode === 'split' && comparisonDisplayedFrame !== null ? comparisonDisplayedFrame : baseComparisonTargetFrame + const isComparisonSourceBindingReady = hasResolvedVisualSourceAtFrame( + fastScrubScaledTracks as Array<{ + visible?: boolean + solo?: boolean + items: TimelineItem[] + }>, + comparisonTargetFrame, + ) // Leaving split comparison clears the rendered after-frame. Kept as its own // effect keyed only on the mode so the per-frame `comparisonTargetFrame` @@ -926,6 +958,11 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ useEffect(() => { if (stageColorGradeComparisonMode !== 'split') return + if (!isComparisonSourceBindingReady) { + splitAfterPendingFrameRef.current = null + setSplitAfterPresentation((presentation) => (presentation === null ? presentation : null)) + return + } let cancelled = false const renderGeneration = ++splitAfterRenderGenerationRef.current @@ -1005,6 +1042,7 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ ensureSplitAfterRenderer, fastScrubRendererStructureKey, gpuEffectsCanvasRef, + isComparisonSourceBindingReady, livePreviewEdits, stageColorGradeComparisonMode, ]) @@ -1018,6 +1056,7 @@ const VideoPreviewBase = memo(function VideoPreviewBase({ const isColorGradeComparisonActive = stageColorGradeComparisonMode !== 'off' const isSplitGradeComparison = stageColorGradeComparisonMode === 'split' const isColorGradeComparisonFrameReady = + isComparisonSourceBindingReady && comparisonDisplayedFrame === comparisonTargetFrame && (isSplitGradeComparison ? splitAfterPresentation?.frame === comparisonTargetFrame && diff --git a/src/features/preview/hooks/use-preview-composition-model.ts b/src/features/preview/hooks/use-preview-composition-model.ts index e99bb8cf2..74a033f44 100644 --- a/src/features/preview/hooks/use-preview-composition-model.ts +++ b/src/features/preview/hooks/use-preview-composition-model.ts @@ -78,6 +78,7 @@ interface BuildPreviewCompositionDataParams { previewRenderSize?: PreviewPlayerSize resolveProxyUrlFn?: (mediaId: string) => string | null getBlobUrlFn?: (mediaId: string) => string | null + authoritativeSourceUrls?: ReadonlyMap } interface UsePreviewCompositionModelParams { @@ -101,6 +102,29 @@ interface UsePreviewCompositionBaseModelParams { mediaById: Record[0]> } +interface PreviewSourceBindings { + identity: string + urls: ReadonlyMap +} + +function getPreviewSourceBindings(combinedTracks: TimelineTrack[]): PreviewSourceBindings { + const urls = new Map() + const identities: Array<[mediaId: string, epoch: string, url: string]> = [] + const seenMediaIds = new Set() + + for (const track of combinedTracks) { + for (const item of track.items) { + if (!item.mediaId || seenMediaIds.has(item.mediaId)) continue + seenMediaIds.add(item.mediaId) + const url = blobUrlManager.get(item.mediaId) ?? '' + if (url) urls.set(item.mediaId, url) + identities.push([item.mediaId, blobUrlManager.getEpoch(item.mediaId), url]) + } + } + + return { identity: JSON.stringify(identities), urls } +} + /** * Apply transient panel edits to the item snapshot consumed by the canvas * renderer. The DOM player subscribes to the same preview store directly, but @@ -224,6 +248,14 @@ export function usePreviewCompositionModel({ () => ({ width: previewRenderWidth, height: previewRenderHeight }), [previewRenderHeight, previewRenderWidth], ) + const sourceBindings = useMemo(() => { + // Blob URL notifications are synchronous external-store updates. Snapshot + // the active media epochs and URLs during render so a relink/invalidation + // cannot leave the passive-effect-backed resolvedUrls map owning a retired + // source for the next layout/presentation phase. + void blobUrlVersion + return getPreviewSourceBindings(combinedTracks) + }, [blobUrlVersion, combinedTracks]) const { playbackVideoSourceSpans, scrubVideoSourceSpans, @@ -252,6 +284,7 @@ export function usePreviewCompositionModel({ blobUrlVersion, project, previewRenderSize, + authoritativeSourceUrls: sourceBindings.urls, }) }, [ blobUrlVersion, @@ -264,6 +297,7 @@ export function usePreviewCompositionModel({ previewRenderSize, proxyReadyCount, resolvedUrls, + sourceBindings.urls, transitions, useProxy, ]) @@ -374,6 +408,7 @@ export function usePreviewCompositionModel({ fastScrubInputProps, fastScrubPreviewItems, fastScrubTracksTopologyFingerprint, + sourceBindingIdentity: sourceBindings.identity, getPreviewTransformOverride, getPreviewEffectsOverride, getPreviewCornerPinOverride, @@ -397,6 +432,7 @@ export function buildPreviewCompositionData({ previewRenderSize, resolveProxyUrlFn = resolveProxyUrl, getBlobUrlFn = (mediaId: string) => blobUrlManager.get(mediaId), + authoritativeSourceUrls, }: BuildPreviewCompositionDataParams) { void blobUrlVersion const resolvedTrackList: CompositionInputProps['tracks'] = [] @@ -423,9 +459,13 @@ export function buildPreviewCompositionData({ continue } - const sourceUrl = resolvedUrls.get(item.mediaId) ?? getBlobUrlFn(item.mediaId) ?? '' + const sourceUrl = authoritativeSourceUrls + ? (authoritativeSourceUrls.get(item.mediaId) ?? '') + : (resolvedUrls.get(item.mediaId) ?? getBlobUrlFn(item.mediaId) ?? '') const proxyUrl = - item.type === 'video' ? resolveProxyUrlFn(item.mediaId) || sourceUrl : sourceUrl + item.type === 'video' && (!authoritativeSourceUrls || sourceUrl) + ? resolveProxyUrlFn(item.mediaId) || sourceUrl + : sourceUrl const resolvedSrc = useProxy && item.type === 'video' ? proxyUrl : sourceUrl const fastScrubSrc = resolvedSrc const hasMatchingAudioSrc = item.type !== 'video' || item.audioSrc === sourceUrl From 70012b8cca256bb0318f4ff4598ba08968e92645 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 22:40:40 -0700 Subject: [PATCH 16/64] fix(preview): scope source binding generations (cherry picked from commit b987c81070f6818ea374338354584913926d3c40) --- .../export/utils/client-render-engine.test.ts | 18 +- .../export/utils/client-render-engine.ts | 35 ++-- .../components/inline-source-preview.test.tsx | 166 ++++++++++++++++++ .../components/inline-source-preview.tsx | 8 +- .../components/source-monitor.test.tsx | 113 +++++++++++- .../preview/components/source-monitor.tsx | 8 +- .../components/video-preview.sync.test.tsx | 151 ++++++++++++++++ .../use-preview-composition-model.test.ts | 115 +++++++++++- .../hooks/use-preview-composition-model.ts | 72 ++++++-- .../browser/blob-url-manager.ts | 9 + 10 files changed, 651 insertions(+), 44 deletions(-) create mode 100644 src/features/preview/components/inline-source-preview.test.tsx diff --git a/src/features/export/utils/client-render-engine.test.ts b/src/features/export/utils/client-render-engine.test.ts index 4015a78b4..b6945a4de 100644 --- a/src/features/export/utils/client-render-engine.test.ts +++ b/src/features/export/utils/client-render-engine.test.ts @@ -403,7 +403,7 @@ describe('selectPreviewVideoSource', () => { ).toEqual(['blob:original', null, null]) }) - it('includes cached proxies when proxy media is selected', () => { + it('keeps the authoritative current source ahead of stale item and registered URLs', () => { expect( getPreviewVideoSourceCandidates({ itemSource: 'blob:proxy', @@ -412,7 +412,21 @@ describe('selectPreviewVideoSource', () => { cachedSource: 'blob:source', useProxyMedia: true, }), - ).toEqual(['blob:proxy', 'blob:proxy', 'blob:registered-proxy', 'blob:source']) + ).toEqual(['blob:proxy', 'blob:source', 'blob:registered-proxy', 'blob:proxy']) + }) + + it('selects a relinked current source instead of stale registered and item fallbacks', () => { + expect( + selectPreviewVideoSource({ + candidates: getPreviewVideoSourceCandidates({ + itemSource: 'blob:old-item', + proxySource: null, + registeredSource: 'blob:old-registered', + cachedSource: 'blob:new-current', + useProxyMedia: true, + }), + }), + ).toBe('blob:new-current') }) it('selects the cached proxy when a compound item still carries its original source', () => { diff --git a/src/features/export/utils/client-render-engine.ts b/src/features/export/utils/client-render-engine.ts index 01924ad3f..f12c1d4da 100644 --- a/src/features/export/utils/client-render-engine.ts +++ b/src/features/export/utils/client-render-engine.ts @@ -210,7 +210,7 @@ export function getPreviewVideoSourceCandidates({ useProxyMedia: boolean }): Array { if (useProxyMedia) { - return [proxySource, itemSource, registeredSource, cachedSource] + return [proxySource, cachedSource, registeredSource, itemSource] } // blobUrlManager owns the current original-media URL and is also what the @@ -305,12 +305,15 @@ function selectComparisonVideoSource( registeredSource: string | undefined, useProxyMedia: boolean, ): string | null { - return selectFirstMediaSource([ - registeredSource, - useProxyMedia && item.mediaId ? resolveProxyUrl(item.mediaId) : null, - item.src, - item.mediaId ? blobUrlManager.get(item.mediaId) : null, - ]) + return selectFirstMediaSource( + getPreviewVideoSourceCandidates({ + itemSource: item.src, + proxySource: item.mediaId ? resolveProxyUrl(item.mediaId) : null, + registeredSource, + cachedSource: item.mediaId ? blobUrlManager.get(item.mediaId) : null, + useProxyMedia, + }), + ) } function selectExportVideoSource( @@ -385,20 +388,26 @@ function waitForFallbackVideoReady(options: { }) } +function resolveRendererProxySource( + item: VideoItem | ImageItem | LottieItem, + useProxyMedia: boolean, +): string | null { + if (!useProxyMedia || item.type !== 'video' || !item.mediaId) return null + return resolveProxyUrl(item.mediaId) +} + async function resolveRendererMediaSource( item: VideoItem | ImageItem | LottieItem, useProxyMedia: boolean, signal?: AbortSignal, ): Promise { throwIfAborted(signal) - if (useProxyMedia && item.type === 'video' && item.mediaId) { - const proxyUrl = resolveProxyUrl(item.mediaId) - if (proxyUrl) return proxyUrl - } - if (item.src) return item.src - if (!item.mediaId) return null + const proxyUrl = resolveRendererProxySource(item, useProxyMedia) + if (proxyUrl) return proxyUrl + if (!item.mediaId) return item.src ?? null const cachedUrl = blobUrlManager.get(item.mediaId) if (cachedUrl) return cachedUrl + if (item.src) return item.src const resolvedUrl = await resolveMediaUrl(item.mediaId) throwIfAborted(signal) return resolvedUrl || null diff --git a/src/features/preview/components/inline-source-preview.test.tsx b/src/features/preview/components/inline-source-preview.test.tsx new file mode 100644 index 000000000..cacc4cf90 --- /dev/null +++ b/src/features/preview/components/inline-source-preview.test.tsx @@ -0,0 +1,166 @@ +import { useEffect, useSyncExternalStore, type ReactNode } from 'react' +import { act, render, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' + +const harness = vi.hoisted(() => ({ + globalVersion: 0, + epochs: new Map(), + resolveMediaUrl: vi.fn<(mediaId: string) => Promise>(), + mounts: 0, + unmounts: 0, + listeners: new Set<() => void>(), + publish: () => { + for (const listener of harness.listeners) listener() + }, +})) + +vi.mock('@/features/preview/deps/player-context', () => ({ + PlayerEmitterProvider: ({ children }: { children: ReactNode }) => <>{children}, + ClockBridgeProvider: ({ children }: { children: ReactNode }) => <>{children}, + VideoConfigProvider: ({ children }: { children: ReactNode }) => <>{children}, + useClock: () => ({ seekToFrame: vi.fn() }), +})) + +vi.mock('@/features/preview/deps/media-library', () => ({ + useMediaLibraryStore: (selector: (state: Record) => unknown) => + selector({ + mediaById: { + 'media-1': { + id: 'media-1', + fileName: 'clip.mp4', + mimeType: 'video/mp4', + duration: 5, + width: 1920, + height: 1080, + fps: 30, + }, + }, + }), + getMediaType: () => 'video', +})) + +vi.mock('@/shared/state/playback', () => ({ + usePlaybackStore: (selector: (state: { zoom: number }) => unknown) => selector({ zoom: -1 }), +})) + +vi.mock('@/infrastructure/browser/blob-url-manager', () => ({ + useBlobUrlVersion: () => + useSyncExternalStore( + (listener) => { + harness.listeners.add(listener) + return () => harness.listeners.delete(listener) + }, + () => harness.globalVersion, + ), + useBlobUrlEpoch: (mediaId: string) => + useSyncExternalStore( + (listener) => { + harness.listeners.add(listener) + return () => harness.listeners.delete(listener) + }, + () => String(harness.epochs.get(mediaId) ?? 0), + ), +})) + +vi.mock('../utils/media-resolver', () => ({ + resolveMediaUrl: harness.resolveMediaUrl, +})) + +vi.mock('./source-composition', () => ({ + SourceComposition: ({ src }: { src: string }) => { + useEffect(() => { + harness.mounts += 1 + return () => { + harness.unmounts += 1 + } + }, []) + return
+ }, +})) + +import { InlineSourcePreview } from './inline-source-preview' + +describe('InlineSourcePreview source binding ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + harness.globalVersion = 0 + harness.epochs.clear() + harness.resolveMediaUrl.mockResolvedValue('blob:media-1') + harness.mounts = 0 + harness.unmounts = 0 + harness.listeners.clear() + }) + + it('preserves the current frame generation across unrelated blob URL activity', async () => { + const rendered = render( + , + ) + + await waitFor(() => { + expect(rendered.getByTestId('inline-source-composition')).toHaveAttribute( + 'data-source', + 'blob:media-1', + ) + }) + expect(harness.resolveMediaUrl).toHaveBeenCalledTimes(1) + + act(() => { + harness.globalVersion += 1 + harness.publish() + }) + await act(async () => { + await Promise.resolve() + }) + + expect(harness.resolveMediaUrl).toHaveBeenCalledTimes(1) + expect(harness.mounts).toBe(1) + expect(harness.unmounts).toBe(0) + }) + + it('retires the relevant frame generation before resolving its replacement once', async () => { + let resolveReplacement!: (url: string) => void + const replacement = new Promise((resolve) => { + resolveReplacement = resolve + }) + harness.resolveMediaUrl.mockResolvedValueOnce('blob:old').mockReturnValueOnce(replacement) + const rendered = render( + , + ) + + await waitFor(() => { + expect(rendered.getByTestId('inline-source-composition')).toHaveAttribute( + 'data-source', + 'blob:old', + ) + }) + + act(() => { + harness.epochs.set('media-1', 1) + harness.globalVersion += 1 + harness.publish() + }) + + expect(rendered.queryByTestId('inline-source-composition')).toBeNull() + await waitFor(() => expect(harness.resolveMediaUrl).toHaveBeenCalledTimes(2)) + + await act(async () => { + resolveReplacement('blob:new') + await replacement + }) + + expect(rendered.getByTestId('inline-source-composition')).toHaveAttribute( + 'data-source', + 'blob:new', + ) + expect(harness.resolveMediaUrl).toHaveBeenCalledTimes(2) + expect(harness.unmounts).toBe(1) + }) +}) diff --git a/src/features/preview/components/inline-source-preview.tsx b/src/features/preview/components/inline-source-preview.tsx index 7de19fafc..05b7ca6c3 100644 --- a/src/features/preview/components/inline-source-preview.tsx +++ b/src/features/preview/components/inline-source-preview.tsx @@ -11,7 +11,7 @@ import { SourceComposition } from './source-composition' import { usePlaybackStore } from '@/shared/state/playback' import { EDITOR_LAYOUT_CSS_VALUES } from '@/config/editor-layout' import { getPreviewNeedsOverflow, getPreviewPlayerSize } from '../utils/preview-pixel-snap' -import { useBlobUrlVersion } from '@/infrastructure/browser/blob-url-manager' +import { useBlobUrlEpoch } from '@/infrastructure/browser/blob-url-manager' interface InlineSourcePreviewProps { mediaId: string @@ -54,14 +54,14 @@ const InlineSourcePreviewContent = memo(function InlineSourcePreviewContent({ }: InlineSourcePreviewProps) { const [blobUrl, setBlobUrl] = useState('') const media = useMediaLibraryStore((s) => s.mediaById[mediaId]) - const blobUrlVersion = useBlobUrlVersion() + const blobUrlEpoch = useBlobUrlEpoch(mediaId) const zoom = usePlaybackStore((s) => s.zoom) const mediaWidth = media?.width || 640 const mediaHeight = media?.height || 360 useLayoutEffect(() => { setBlobUrl('') - }, [blobUrlVersion, mediaId]) + }, [blobUrlEpoch, mediaId]) useEffect(() => { let cancelled = false @@ -78,7 +78,7 @@ const InlineSourcePreviewContent = memo(function InlineSourcePreviewContent({ return () => { cancelled = true } - }, [blobUrlVersion, mediaId]) + }, [blobUrlEpoch, mediaId]) const containerWidth = containerSize.width const containerHeight = containerSize.height diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx index 0fa97bf6b..1e3f128c4 100644 --- a/src/features/preview/components/source-monitor.test.tsx +++ b/src/features/preview/components/source-monitor.test.tsx @@ -1,6 +1,18 @@ -import { StrictMode, type ReactNode } from 'react' +import { StrictMode, useEffect, useSyncExternalStore, type ReactNode } from 'react' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { fireEvent, render, waitFor } from '@testing-library/react' +import { act, fireEvent, render, waitFor } from '@testing-library/react' + +const sourceBindingState = vi.hoisted(() => ({ + globalVersion: 0, + epochs: new Map(), + resolveMediaUrl: vi.fn<(mediaId: string) => Promise>(), + compositionMounts: 0, + compositionUnmounts: 0, + listeners: new Set<() => void>(), + publish: () => { + for (const listener of sourceBindingState.listeners) listener() + }, +})) const editorStoreState = vi.hoisted(() => ({ sourcePreviewMediaId: 'media-1' as string | null, @@ -78,7 +90,15 @@ vi.mock('@/features/preview/deps/player-context', () => ({ })) vi.mock('./source-composition', () => ({ - SourceComposition: () =>
, + SourceComposition: ({ src }: { src: string }) => { + useEffect(() => { + sourceBindingState.compositionMounts += 1 + return () => { + sourceBindingState.compositionUnmounts += 1 + } + }, []) + return
+ }, })) vi.mock('@/components/ui/tooltip', () => ({ @@ -97,11 +117,26 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ })) vi.mock('../utils/media-resolver', () => ({ - resolveMediaUrl: vi.fn().mockResolvedValue('blob:media-1'), + resolveMediaUrl: sourceBindingState.resolveMediaUrl, })) vi.mock('@/infrastructure/browser/blob-url-manager', () => ({ - useBlobUrlVersion: () => 0, + useBlobUrlVersion: () => + useSyncExternalStore( + (listener) => { + sourceBindingState.listeners.add(listener) + return () => sourceBindingState.listeners.delete(listener) + }, + () => sourceBindingState.globalVersion, + ), + useBlobUrlEpoch: (mediaId: string) => + useSyncExternalStore( + (listener) => { + sourceBindingState.listeners.add(listener) + return () => sourceBindingState.listeners.delete(listener) + }, + () => String(sourceBindingState.epochs.get(mediaId) ?? 0), + ), })) vi.mock('@/features/preview/deps/media-library', () => { @@ -196,6 +231,12 @@ describe('SourceMonitor current media ownership', () => { beforeEach(() => { vi.clearAllMocks() + sourceBindingState.globalVersion = 0 + sourceBindingState.epochs.clear() + sourceBindingState.resolveMediaUrl.mockResolvedValue('blob:media-1') + sourceBindingState.compositionMounts = 0 + sourceBindingState.compositionUnmounts = 0 + sourceBindingState.listeners.clear() editorStoreState.sourcePreviewMediaId = 'media-1' clockState.currentFrame = 0 clockState.isPlaying = false @@ -287,4 +328,66 @@ describe('SourceMonitor current media ownership', () => { expect(playerMethodsState.pause).toHaveBeenCalledTimes(1) }) + + it('keeps the current source generation mounted across unrelated blob URL activity', async () => { + const rendered = render() + + await waitFor(() => { + expect(rendered.getByTestId('source-composition')).toHaveAttribute( + 'data-source', + 'blob:media-1', + ) + }) + expect(sourceBindingState.resolveMediaUrl).toHaveBeenCalledTimes(1) + expect(sourceBindingState.compositionMounts).toBe(1) + + act(() => { + sourceBindingState.globalVersion += 1 + sourceBindingState.publish() + }) + await act(async () => { + await Promise.resolve() + }) + + expect(sourceBindingState.resolveMediaUrl).toHaveBeenCalledTimes(1) + expect(sourceBindingState.compositionMounts).toBe(1) + expect(sourceBindingState.compositionUnmounts).toBe(0) + expect(rendered.getByTestId('source-composition')).toHaveAttribute( + 'data-source', + 'blob:media-1', + ) + }) + + it('retires and resolves a relevant source epoch exactly once', async () => { + let resolveReplacement!: (url: string) => void + const replacement = new Promise((resolve) => { + resolveReplacement = resolve + }) + sourceBindingState.resolveMediaUrl + .mockResolvedValueOnce('blob:old') + .mockReturnValueOnce(replacement) + const rendered = render() + + await waitFor(() => { + expect(rendered.getByTestId('source-composition')).toHaveAttribute('data-source', 'blob:old') + }) + + act(() => { + sourceBindingState.epochs.set('media-1', 1) + sourceBindingState.globalVersion += 1 + sourceBindingState.publish() + }) + + expect(rendered.queryByTestId('source-composition')).toBeNull() + await waitFor(() => expect(sourceBindingState.resolveMediaUrl).toHaveBeenCalledTimes(2)) + expect(sourceBindingState.compositionUnmounts).toBe(1) + + await act(async () => { + resolveReplacement('blob:new') + await replacement + }) + + expect(rendered.getByTestId('source-composition')).toHaveAttribute('data-source', 'blob:new') + expect(sourceBindingState.resolveMediaUrl).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index 43cd01416..6791c60c3 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -70,7 +70,7 @@ import { import { formatTimecodeCompact } from '@/shared/utils/time-utils' import { getPreviewPixelSnapSize } from '../utils/preview-pixel-snap' import type { TimelineTrack } from '@/types/timeline' -import { useBlobUrlVersion } from '@/infrastructure/browser/blob-url-manager' +import { useBlobUrlEpoch } from '@/infrastructure/browser/blob-url-manager' interface SourceMonitorProps { mediaId: string @@ -206,7 +206,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ }: SourceMonitorProps) { const [blobUrl, setBlobUrl] = useState('') const media = useMediaLibraryStore((s) => s.mediaById[mediaId]) - const blobUrlVersion = useBlobUrlVersion() + const blobUrlEpoch = useBlobUrlEpoch(mediaId) // Sync current media ID into source player store for I/O points useEffect(() => { @@ -234,7 +234,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ // canvas visible while the replacement URL resolves. useLayoutEffect(() => { setBlobUrl('') - }, [blobUrlVersion, mediaId]) + }, [blobUrlEpoch, mediaId]) // SourceComposition can swap to a ready proxy for video preview without // losing the original fallback URL. Blob invalidation retries the same ID. @@ -250,7 +250,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ return () => { cancelled = true } - }, [blobUrlVersion, mediaId]) + }, [blobUrlEpoch, mediaId]) if (!media) return null diff --git a/src/features/preview/components/video-preview.sync.test.tsx b/src/features/preview/components/video-preview.sync.test.tsx index 8cb70449b..2f3628fd4 100644 --- a/src/features/preview/components/video-preview.sync.test.tsx +++ b/src/features/preview/components/video-preview.sync.test.tsx @@ -2366,6 +2366,157 @@ describe('VideoPreview sync behavior', () => { } }) + it('retires a reachable nested same-id source binding before the unchanged wrapper can repaint', async () => { + canvasPixelReadbackEnabled = true + const mediaId = 'nested-same-media-relink' + const gradeEffect = { + id: 'effect-grade', + enabled: true, + effect: { + type: 'gpu-effect' as const, + gpuEffectType: 'gpu-color-wheels' as const, + params: { exposure: 0.5 }, + }, + } + setMockBlobUrl(mediaId, 'blob:nested-old') + const nestedTrack = { + id: 'nested-track', + name: 'Nested', + height: 60, + locked: false, + visible: true, + muted: false, + solo: false, + order: 0, + items: [], + } + useCompositionsStore.getState().setCompositions([ + { + id: 'nested-composition', + name: 'Nested composition', + width: 1920, + height: 1080, + fps: 30, + durationInFrames: 120, + tracks: [nestedTrack], + transitions: [], + keyframes: [], + items: [ + { + id: 'nested-video', + label: 'Nested video', + type: 'video', + trackId: nestedTrack.id, + mediaId, + src: 'blob:nested-stale-item', + from: 0, + durationInFrames: 120, + } as unknown as TimelineItem, + ], + }, + ]) + setSingleVideoTrack() + useItemsStore.getState().setItems([ + { + id: 'compound-with-grade', + label: 'Compound', + type: 'composition', + trackId: 'track-video', + compositionId: 'nested-composition', + compositionWidth: 1920, + compositionHeight: 1080, + from: 0, + durationInFrames: 120, + effects: [gradeEffect], + } as unknown as TimelineItem, + ]) + act(() => { + usePlaybackStore.getState().setCurrentFrame(24) + }) + + let observeRelinkLayout = false + let wasBlankInLayout = false + let wasHiddenInLayout = false + let wasDisposedInLayout = false + let gpuDisplayCanvas: HTMLCanvasElement | null = null + let oldSplitRenderer: (typeof rendererMockState.instances)[number] | null = null + const onBindingLayout = () => { + if (!observeRelinkLayout || !gpuDisplayCanvas || !oldSplitRenderer) return + wasBlankInLayout = blankCanvasState.has(gpuDisplayCanvas) + wasHiddenInLayout = gpuDisplayCanvas.style.visibility === 'hidden' + wasDisposedInLayout = oldSplitRenderer.dispose.mock.calls.length === 1 + } + + const { container } = render( + <> + + + , + ) + await waitFor(() => expect(rendererMockState.instances).toHaveLength(1)) + act(() => { + useGizmoStore.getState().setColorGradeComparisonMode('split') + }) + + oldSplitRenderer = await waitFor(() => { + expect(rendererMockState.instances).toHaveLength(2) + return rendererMockState.instances[1]! + }) + const oldSplitCall = createCompositionRendererMock.mock.calls[1] as unknown as [ + unknown, + HTMLCanvasElement, + ] + gpuDisplayCanvas = container.querySelectorAll('canvas')[1] as HTMLCanvasElement + setMockCanvasBlank(oldSplitCall[1], false) + setMockCanvasBlank(gpuDisplayCanvas, false) + + let resolveOldRender: (() => void) | null = null + oldSplitRenderer.renderFrame.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOldRender = resolve + }), + ) + act(() => { + useGizmoStore.getState().setEffectsPreviewNew({ + 'compound-with-grade': [ + { ...gradeEffect, effect: { ...gradeEffect.effect, params: { exposure: 0.8 } } }, + ], + }) + }) + await waitFor(() => expect(resolveOldRender).not.toBeNull()) + + observeRelinkLayout = true + act(() => { + setMockBlobUrl(mediaId, null) + }) + + expect(wasBlankInLayout).toBe(true) + expect(wasHiddenInLayout).toBe(true) + expect(wasDisposedInLayout).toBe(true) + const drawCountAfterRetirement = getCanvasDrawImageCallCountFrom( + gpuDisplayCanvas, + oldSplitCall[1], + ) + + await act(async () => { + resolveOldRender?.() + await Promise.resolve() + await Promise.resolve() + }) + expect(getCanvasDrawImageCallCountFrom(gpuDisplayCanvas, oldSplitCall[1])).toBe( + drawCountAfterRetirement, + ) + + act(() => { + setMockBlobUrl(mediaId, 'blob:nested-new') + }) + await waitFor(() => expect(rendererMockState.instances.length).toBeGreaterThan(2)) + await waitFor(() => { + expect(rendererMockState.instances.at(-1)?.renderFrame).toHaveBeenCalledWith(24) + }) + }) + it('keeps the split after renderer warm when toggling away from split and back', async () => { setSingleVideoItemAtFrame({ id: 'item-graded', diff --git a/src/features/preview/hooks/use-preview-composition-model.test.ts b/src/features/preview/hooks/use-preview-composition-model.test.ts index a1edca27c..5b4eeb64e 100644 --- a/src/features/preview/hooks/use-preview-composition-model.test.ts +++ b/src/features/preview/hooks/use-preview-composition-model.test.ts @@ -3,11 +3,122 @@ import { describe, expect, it } from 'vite-plus/test' import type { TimelineTrack } from '@/types/timeline' import { + buildPreviewSourceBindings, buildPreviewCompositionData, mergeLiveItemPresentation, mergeLiveItemPreview, } from './use-preview-composition-model' +describe('buildPreviewSourceBindings', () => { + const directTrack: TimelineTrack = { + id: 'root-track', + name: 'Root', + height: 80, + locked: false, + visible: true, + muted: false, + solo: false, + order: 0, + items: [ + { + id: 'direct-video', + trackId: 'root-track', + type: 'video', + mediaId: 'media-direct', + src: 'blob:stale-direct', + label: 'Direct', + from: 0, + durationInFrames: 30, + }, + { + id: 'compound', + trackId: 'root-track', + type: 'composition', + compositionId: 'composition-a', + compositionWidth: 1920, + compositionHeight: 1080, + label: 'Compound', + from: 0, + durationInFrames: 30, + }, + { + id: 'missing-compound', + trackId: 'root-track', + type: 'composition', + compositionId: 'missing-composition', + compositionWidth: 1920, + compositionHeight: 1080, + label: 'Missing', + from: 0, + durationInFrames: 30, + }, + ], + } + + it('walks reachable nested media deterministically and survives cycles and missing references', () => { + const compositionById = { + 'composition-a': { + id: 'composition-a', + items: [ + { + id: 'nested-video', + trackId: 'nested-track', + type: 'video' as const, + mediaId: 'media-nested', + src: 'blob:stale-nested', + label: 'Nested', + from: 0, + durationInFrames: 30, + }, + { + id: 'cycle-to-b', + trackId: 'nested-track', + type: 'composition' as const, + compositionId: 'composition-b', + compositionWidth: 1920, + compositionHeight: 1080, + label: 'B', + from: 0, + durationInFrames: 30, + }, + ], + }, + 'composition-b': { + id: 'composition-b', + items: [ + { + id: 'cycle-to-a', + trackId: 'nested-track', + type: 'composition' as const, + compositionId: 'composition-a', + compositionWidth: 1920, + compositionHeight: 1080, + label: 'A', + from: 0, + durationInFrames: 30, + }, + ], + }, + } + + const result = buildPreviewSourceBindings({ + tracks: [directTrack], + compositionById, + getEpoch: (mediaId) => `epoch:${mediaId}`, + getUrl: (mediaId) => `blob:current:${mediaId}`, + }) + + expect([...result.urls]).toEqual([ + ['media-direct', 'blob:current:media-direct'], + ['media-nested', 'blob:current:media-nested'], + ]) + expect(JSON.parse(result.identity)).toEqual([ + ['media-direct', 'epoch:media-direct', 'blob:current:media-direct'], + ['media-nested', 'epoch:media-nested', 'blob:current:media-nested'], + ]) + }) +}) + describe('mergeLiveItemPreview', () => { it('merges live shape properties into the canvas renderer snapshot', () => { const shape = { @@ -268,7 +379,7 @@ describe('buildPreviewCompositionData', () => { expect(result.renderSize).toEqual({ width: 1504, height: 846 }) }) - it('uses an already-acquired blob URL before resolvedUrls catches up', () => { + it('uses an already-acquired blob URL before a stale resolvedUrls entry', () => { const track: TimelineTrack = { id: 'track-1', name: 'Video', @@ -298,7 +409,7 @@ describe('buildPreviewCompositionData', () => { items: track.items, keyframes: [], transitions: [], - resolvedUrls: new Map(), + resolvedUrls: new Map([['media-1', 'blob://stale-resolved']]), useProxy: false, blobUrlVersion: 1, project: { width: 1920, height: 1080 }, diff --git a/src/features/preview/hooks/use-preview-composition-model.ts b/src/features/preview/hooks/use-preview-composition-model.ts index 74a033f44..3f557eb36 100644 --- a/src/features/preview/hooks/use-preview-composition-model.ts +++ b/src/features/preview/hooks/use-preview-composition-model.ts @@ -9,7 +9,11 @@ import { blobUrlManager } from '@/infrastructure/browser/blob-url-manager' import { isColorGradeEffectType } from '@/infrastructure/gpu-effects' import { usePlaybackStore } from '@/shared/state/playback' import { resolveEffectiveTrackStates } from '@/features/preview/deps/timeline-utils' -import { useCompositionsStore, useItemsStore } from '@/features/preview/deps/timeline-store' +import { + useCompositionsStore, + useItemsStore, + type SubComposition, +} from '@/features/preview/deps/timeline-store' import { appendVirtualTranscriptCaptionTrack } from '@/features/preview/deps/caption-items' import { useCornerPinStore } from '../stores/corner-pin-store' import { useGizmoStore, type ItemPreview } from '../stores/gizmo-store' @@ -107,21 +111,55 @@ interface PreviewSourceBindings { urls: ReadonlyMap } -function getPreviewSourceBindings(combinedTracks: TimelineTrack[]): PreviewSourceBindings { +function getRenderableMediaId(item: TimelineItem): string | null { + if (!item.mediaId) return null + switch (item.type) { + case 'video': + case 'audio': + case 'image': + case 'lottie': + return item.mediaId + default: + return null + } +} + +export function buildPreviewSourceBindings({ + tracks, + compositionById, + getEpoch, + getUrl, +}: { + tracks: TimelineTrack[] + compositionById: Readonly | undefined>> + getEpoch: (mediaId: string) => string + getUrl: (mediaId: string) => string | null +}): PreviewSourceBindings { const urls = new Map() const identities: Array<[mediaId: string, epoch: string, url: string]> = [] - const seenMediaIds = new Set() - - for (const track of combinedTracks) { - for (const item of track.items) { - if (!item.mediaId || seenMediaIds.has(item.mediaId)) continue - seenMediaIds.add(item.mediaId) - const url = blobUrlManager.get(item.mediaId) ?? '' - if (url) urls.set(item.mediaId, url) - identities.push([item.mediaId, blobUrlManager.getEpoch(item.mediaId), url]) + const reachableMediaIds = new Set() + const visitedCompositionIds = new Set() + + const visitItems = (items: readonly TimelineItem[]) => { + for (const item of items) { + const mediaId = getRenderableMediaId(item) + if (mediaId) reachableMediaIds.add(mediaId) + + if (!item.compositionId || visitedCompositionIds.has(item.compositionId)) continue + visitedCompositionIds.add(item.compositionId) + const composition = compositionById[item.compositionId] + if (composition) visitItems(composition.items) } } + for (const track of tracks) visitItems(track.items) + + for (const mediaId of [...reachableMediaIds].sort()) { + const url = getUrl(mediaId) ?? '' + if (url) urls.set(mediaId, url) + identities.push([mediaId, getEpoch(mediaId), url]) + } + return { identity: JSON.stringify(identities), urls } } @@ -248,14 +286,20 @@ export function usePreviewCompositionModel({ () => ({ width: previewRenderWidth, height: previewRenderHeight }), [previewRenderHeight, previewRenderWidth], ) + const compositionById = useCompositionsStore((state) => state.compositionById) const sourceBindings = useMemo(() => { // Blob URL notifications are synchronous external-store updates. Snapshot // the active media epochs and URLs during render so a relink/invalidation // cannot leave the passive-effect-backed resolvedUrls map owning a retired // source for the next layout/presentation phase. void blobUrlVersion - return getPreviewSourceBindings(combinedTracks) - }, [blobUrlVersion, combinedTracks]) + return buildPreviewSourceBindings({ + tracks: combinedTracks, + compositionById, + getEpoch: (mediaId) => blobUrlManager.getEpoch(mediaId), + getUrl: (mediaId) => blobUrlManager.get(mediaId), + }) + }, [blobUrlVersion, combinedTracks, compositionById]) const { playbackVideoSourceSpans, scrubVideoSourceSpans, @@ -461,7 +505,7 @@ export function buildPreviewCompositionData({ const sourceUrl = authoritativeSourceUrls ? (authoritativeSourceUrls.get(item.mediaId) ?? '') - : (resolvedUrls.get(item.mediaId) ?? getBlobUrlFn(item.mediaId) ?? '') + : (getBlobUrlFn(item.mediaId) ?? resolvedUrls.get(item.mediaId) ?? '') const proxyUrl = item.type === 'video' && (!authoritativeSourceUrls || sourceUrl) ? resolveProxyUrlFn(item.mediaId) || sourceUrl diff --git a/src/infrastructure/browser/blob-url-manager.ts b/src/infrastructure/browser/blob-url-manager.ts index 1b245085c..d3271c3a5 100644 --- a/src/infrastructure/browser/blob-url-manager.ts +++ b/src/infrastructure/browser/blob-url-manager.ts @@ -217,3 +217,12 @@ export const blobUrlManager = new BlobUrlManager() export function useBlobUrlVersion(): number { return useSyncExternalStore(blobUrlManager.subscribe, blobUrlManager.getSnapshot) } + +/** + * Subscribe to the source generation owned by one media id. Unlike the global + * version, this stays stable when another media item acquires or releases a + * URL, so source-local canvases can retain their decoded frame state. + */ +export function useBlobUrlEpoch(mediaId: string): string { + return useSyncExternalStore(blobUrlManager.subscribe, () => blobUrlManager.getEpoch(mediaId)) +} From b5fb12a85b25af7828a8981e2bfb0cabfc50bc7c Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 00:34:40 -0700 Subject: [PATCH 17/64] fix(editor): repair timeline seek and clip boundary playback (cherry picked from commit a92c12f024826bacc687f67100680ab69ddb0000) --- .../components/video-preview.sync.test.tsx | 108 ++++++++++++++---- .../use-preview-composition-model.test.ts | 58 +++++++++- .../hooks/use-preview-composition-model.ts | 2 +- .../use-preview-render-pump-controller.ts | 15 ++- .../components/timeline-content.test.tsx | 62 +++++++++- .../timeline/components/timeline-content.tsx | 3 +- ...se-timeline-item-pointer-handlers.test.tsx | 13 ++- .../use-timeline-item-pointer-handlers.ts | 31 +++-- .../components/timeline-markers.test.tsx | 31 +++++ .../timeline/components/timeline-markers.tsx | 24 ++++ src/runtime/player/clock/Clock.test.ts | 40 +++++++ 11 files changed, 339 insertions(+), 48 deletions(-) diff --git a/src/features/preview/components/video-preview.sync.test.tsx b/src/features/preview/components/video-preview.sync.test.tsx index 2f3628fd4..08fa70f36 100644 --- a/src/features/preview/components/video-preview.sync.test.tsx +++ b/src/features/preview/components/video-preview.sync.test.tsx @@ -121,36 +121,55 @@ const rendererMockState = vi.hoisted(() => { } const instances: RendererMock[] = [] + const renderedSources: Array<{ frame: number; src: string | null }> = [] const getBestDomVideoElementForItem = vi.fn<(itemId: string) => HTMLVideoElement | null>( () => null, ) - const create = vi.fn(async () => { - const prewarmFrame = vi.fn(async (frame: number) => { - void frame - }) - const renderer: RendererMock = { - preload: vi.fn(async () => {}), - renderFrame: vi.fn(async () => {}), - prewarmFrame, - prewarmFrames: vi.fn(async (frames: number[]) => { - for (const frame of frames) { - await prewarmFrame(frame) - } - }), - invalidateFrameCache: vi.fn(), - setDomVideoElementProvider: vi.fn(), - wasLastRenderAborted: vi.fn(() => false), - getScrubbingCache: () => null, - dispose: vi.fn(), - } - instances.push(renderer) - return renderer - }) + const create = vi.fn( + async (inputProps?: { + tracks?: Array<{ + items?: Array<{ type?: string; from?: number; durationInFrames?: number; src?: string }> + }> + }) => { + const prewarmFrame = vi.fn(async (frame: number) => { + void frame + }) + const renderFrame = vi.fn(async (frame: number) => { + const activeItem = (inputProps?.tracks ?? []) + .flatMap((track) => track.items ?? []) + .find( + (item) => + item.type === 'video' && + frame >= (item.from ?? 0) && + frame < (item.from ?? 0) + (item.durationInFrames ?? 0), + ) + renderedSources.push({ frame, src: activeItem?.src ?? null }) + }) + const renderer: RendererMock = { + preload: vi.fn(async () => {}), + renderFrame, + prewarmFrame, + prewarmFrames: vi.fn(async (frames: number[]) => { + for (const frame of frames) { + await prewarmFrame(frame) + } + }), + invalidateFrameCache: vi.fn(), + setDomVideoElementProvider: vi.fn(), + wasLastRenderAborted: vi.fn(() => false), + getScrubbingCache: () => null, + dispose: vi.fn(), + } + instances.push(renderer) + return renderer + }, + ) return { create, getBestDomVideoElementForItem, instances, + renderedSources, } }) @@ -976,6 +995,7 @@ describe('VideoPreview sync behavior', () => { completeDeferredPlayerSeek = null lastPlayerDimensions = null playerDimensionsHistory = [] + rendererMockState.renderedSources.length = 0 seekToMock.mockReset() playMock.mockReset() pauseMock.mockReset() @@ -4836,6 +4856,50 @@ describe('VideoPreview sync behavior', () => { }) }) + it('repaints a visible scrub canvas from the exact source after a paused cross-clip seek', async () => { + setMockBlobUrl('media-red', 'blob:red') + setMockBlobUrl('media-blue', 'blob:blue') + setSingleVideoTrack() + useItemsStore.getState().setItems([ + { + id: 'red', + label: 'Red', + type: 'video', + trackId: 'track-video', + mediaId: 'media-red', + src: 'blob:red', + from: 1, + durationInFrames: 90, + }, + { + id: 'blue', + label: 'Blue', + type: 'video', + trackId: 'track-video', + mediaId: 'media-blue', + src: 'blob:blue', + from: 91, + durationInFrames: 90, + }, + ] as TimelineItem[]) + + const { container } = renderDefaultPreview() + const scrubCanvas = getScrubCanvas(container) + await setScrubFrameAndWaitVisible(scrubCanvas, 45) + + act(() => { + usePlaybackStore.getState().setPreviewFrame(null) + usePlaybackStore.getState().setCurrentFrame(135) + }) + + await waitFor(() => { + expect(usePlaybackStore.getState().currentFrame).toBe(135) + expect(getDisplayedFrame()).toBe(135) + expect(scrubCanvas.style.visibility).toBe('visible') + expect(rendererMockState.renderedSources).toContainEqual({ frame: 135, src: 'blob:blue' }) + }) + }) + it('replays the latest scrub seek on play start when the warm seek has not landed yet', async () => { await renderAfterInitialSeek() diff --git a/src/features/preview/hooks/use-preview-composition-model.test.ts b/src/features/preview/hooks/use-preview-composition-model.test.ts index 5b4eeb64e..b296527ac 100644 --- a/src/features/preview/hooks/use-preview-composition-model.test.ts +++ b/src/features/preview/hooks/use-preview-composition-model.test.ts @@ -225,7 +225,7 @@ describe('buildPreviewCompositionData', () => { { frame: 10, srcs: ['blob://video'] }, { frame: 70, srcs: ['blob://video'] }, ]) - expect(result.totalFrames).toBe(220) + expect(result.totalFrames).toBe(70) const playbackVideoItem = result.inputProps.tracks[0]?.items[0] const scrubVideoItem = result.fastScrubInputProps.tracks[0]?.items[0] expect(playbackVideoItem?.type).toBe('video') @@ -238,6 +238,62 @@ describe('buildPreviewCompositionData', () => { } }) + it('uses the exclusive end of adjacent clips as the canonical player duration', () => { + const track: TimelineTrack = { + id: 'track-1', + name: 'Video', + height: 80, + locked: false, + visible: true, + muted: false, + solo: false, + order: 1, + items: [ + { + id: 'red', + trackId: 'track-1', + type: 'video', + mediaId: 'media-red', + src: 'blob:red', + label: 'Red', + from: 1, + durationInFrames: 90, + }, + { + id: 'blue', + trackId: 'track-1', + type: 'video', + mediaId: 'media-blue', + src: 'blob:blue', + label: 'Blue', + from: 91, + durationInFrames: 90, + }, + ], + } + + const result = buildPreviewCompositionData({ + combinedTracks: [track], + fps: 30, + items: track.items, + keyframes: [], + transitions: [], + resolvedUrls: new Map([ + ['media-red', 'blob:red'], + ['media-blue', 'blob:blue'], + ]), + useProxy: false, + blobUrlVersion: 0, + project: { width: 1920, height: 1080, backgroundColor: '#000000' }, + }) + + expect(result.totalFrames).toBe(181) + expect(result.playbackVideoSourceSpans).toEqual([ + { src: 'blob:red', startFrame: 1, endFrame: 91 }, + { src: 'blob:blue', startFrame: 91, endFrame: 181 }, + ]) + }) + it('uses proxy media for playback and fast scrubbing when proxies are enabled', () => { const track: TimelineTrack = { id: 'track-1', diff --git a/src/features/preview/hooks/use-preview-composition-model.ts b/src/features/preview/hooks/use-preview-composition-model.ts index 3f557eb36..53e7a03f9 100644 --- a/src/features/preview/hooks/use-preview-composition-model.ts +++ b/src/features/preview/hooks/use-preview-composition-model.ts @@ -593,7 +593,7 @@ export function buildPreviewCompositionData({ (max, item) => Math.max(max, item.from + item.durationInFrames), 0, ) - const totalFrames = furthestItemEndFrame === 0 ? 900 : furthestItemEndFrame + fps * 5 + const totalFrames = furthestItemEndFrame === 0 ? 900 : furthestItemEndFrame const inputProps: CompositionInputProps = { fps, width: project.width, diff --git a/src/features/preview/hooks/use-preview-render-pump-controller.ts b/src/features/preview/hooks/use-preview-render-pump-controller.ts index 6a9f2cd79..ca560ba46 100644 --- a/src/features/preview/hooks/use-preview-render-pump-controller.ts +++ b/src/features/preview/hooks/use-preview-render-pump-controller.ts @@ -2490,6 +2490,9 @@ export function usePreviewRenderPump({ const playStateChanged = state.isPlaying !== prev.isPlaying || renderedPlaybackActive !== renderedPlaybackWasActive const isAtomicScrubTarget = isAtomicPreviewTarget(state) + const visibleOverlayNeedsCurrentFrame = + showFastScrubOverlayRef.current && + usePreviewBridgeStore.getState().displayedFrame !== state.currentFrame // Pointer release keeps the same numerical target, but it is still a // first-class committed request. Let it refresh the latest-target @@ -2498,7 +2501,8 @@ export function usePreviewRenderPump({ if ( targetFrame === prevTargetFrame && !playStateChanged && - settlingReleasedScrubFrame === null + settlingReleasedScrubFrame === null && + !visibleOverlayNeedsCurrentFrame ) { return } @@ -2633,7 +2637,14 @@ export function usePreviewRenderPump({ const requiresRenderedPath = forceFastScrubOverlay || shouldPreserveHighFidelityBackwardPreview(state.currentFrame) if (showFastScrubOverlayRef.current) { - if (settlingReleasedScrubFrame !== null && requiresRenderedPath) { + if ( + (settlingReleasedScrubFrame !== null && requiresRenderedPath) || + visibleOverlayNeedsCurrentFrame + ) { + // A prior skim canvas still covers the Player. A direct paused + // seek can move the Player to the exact frame without changing + // overlay ownership, so repaint that visible canvas as well; + // otherwise its previous clip remains on top indefinitely. scrubRequestedFrameRef.current = state.currentFrame void pumpRenderLoop() } diff --git a/src/features/timeline/components/timeline-content.test.tsx b/src/features/timeline/components/timeline-content.test.tsx index cb396033e..4e088d93c 100644 --- a/src/features/timeline/components/timeline-content.test.tsx +++ b/src/features/timeline/components/timeline-content.test.tsx @@ -59,7 +59,7 @@ vi.mock('./timeline-markers', () => ({ })) vi.mock('./timeline-playhead', () => ({ - TimelinePlayhead: () =>
, + TimelinePlayhead: () =>
, })) vi.mock('./timeline-preview-scrubber', () => ({ @@ -653,6 +653,66 @@ describe('TimelineContent playback selection behavior', () => { cancelAnimationFrameSpy.mockRestore() }) + it('cancels a queued hover preview when playhead scrubbing starts', () => { + let nextFrameId = 1 + const scheduledFrames = new Map() + const animationFrameSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + const id = nextFrameId++ + scheduledFrames.set(id, callback) + return id + }) + const cancelAnimationFrameSpy = vi + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation((id) => { + scheduledFrames.delete(id) + }) + + const { container, getByTestId, unmount } = render( + , + ) + const scrollContainer = container.querySelector('[data-timeline-scroll-container]') + if (!(scrollContainer instanceof HTMLDivElement)) { + throw new Error('Expected timeline scroll container') + } + + Object.defineProperty(scrollContainer, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + left: 0, + top: 0, + right: 400, + bottom: 200, + width: 400, + height: 200, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }) + scheduledFrames.clear() + + fireEvent.mouseMove(scrollContainer, { clientX: 180, clientY: 48 }) + const previewFrameId = [...scheduledFrames.keys()].at(-1) + expect(previewFrameId).toBeDefined() + + fireEvent.mouseDown(getByTestId('unified-timeline-playhead'), { button: 0 }) + + expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(previewFrameId) + act(() => { + for (const [id, callback] of [...scheduledFrames]) { + scheduledFrames.delete(id) + callback(performance.now()) + } + }) + expect(usePlaybackStore.getState().previewFrame).toBeNull() + + unmount() + animationFrameSpy.mockRestore() + cancelAnimationFrameSpy.mockRestore() + }) + it('gives dense timelines a short window to cancel hover preview before zoom', () => { vi.useFakeTimers() const denseItems = Array.from({ length: 80 }, (_, index) => ({ diff --git a/src/features/timeline/components/timeline-content.tsx b/src/features/timeline/components/timeline-content.tsx index e81c06b85..497ff55a8 100644 --- a/src/features/timeline/components/timeline-content.tsx +++ b/src/features/timeline/components/timeline-content.tsx @@ -1343,6 +1343,7 @@ export const TimelineContent = memo(function TimelineContent({ const target = e.target as HTMLElement // Check if mousedown is on a playhead handle or timeline ruler if (target.closest('[data-playhead-handle]') || target.closest('.timeline-ruler')) { + cancelPendingHoverPreview() scrubWasActiveRef.current = true } } @@ -1374,7 +1375,7 @@ export const TimelineContent = memo(function TimelineContent({ scrubTimeoutRef.current = null } } - }, []) + }, [cancelPendingHoverPreview]) // Commit the hover skimmer on a normal timeline click. Ruler clicks own their // own scrub path, while drag/marquee/razor gestures must not move playback. diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx index 1d82ba306..98ee53983 100644 --- a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx +++ b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx @@ -150,13 +150,18 @@ describe('useTimelineItemPointerHandlers', () => { expect(selectItems).toHaveBeenCalledWith(['item-1']) }) - it('commits the hover preview when selecting a clip', () => { - usePlaybackStore.setState({ currentFrame: 0, previewFrame: 34, isPlaying: true }) + it('seeks from click geometry when the hover preview is stale at the next boundary', () => { + usePlaybackStore.setState({ + currentFrame: 0, + previewFrame: 50, + previewItemId: 'item-1', + isPlaying: true, + }) const handlers = renderHandlers(makeInput({ activeTool: 'select' })) - handlers.handleClick(makeMouseEvent()) + handlers.handleClick(makeMouseEvent({ clientX: 2 })) - expect(usePlaybackStore.getState().currentFrame).toBe(34) + expect(usePlaybackStore.getState().currentFrame).toBe(20) expect(usePlaybackStore.getState().previewFrame).toBeNull() expect(usePlaybackStore.getState().isPlaying).toBe(false) }) diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts index 3451d820b..1621f61a8 100644 --- a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts +++ b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts @@ -1,7 +1,7 @@ import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react' import type { TimelineItem as TimelineItemType } from '@/types/timeline' import type { SelectionState } from '@/shared/state/selection' -import { commitPreviewFrameToCurrentFrame, usePlaybackStore } from '@/shared/state/playback' +import { usePlaybackStore } from '@/shared/state/playback' import { isMicRecordingActive, useMicRecordingStore } from '@/shared/state/mic-recording-store' import { useEditorStore } from '@/shared/state/editor' import { useSourcePlayerStore } from '@/shared/state/source-player' @@ -158,24 +158,23 @@ export function useTimelineItemPointerHandlers({ } // Clip clicks stop propagation for selection, so they must explicitly - // commit the transient hover skimmer just like a timeline-body click. + // seek from this click's own geometry. The hover skimmer can still hold + // the previous pointer event (including the next clip boundary), so it + // must never own the committed click frame. if (!isMicRecordingActive(useMicRecordingStore.getState().status)) { const playback = usePlaybackStore.getState() + const rect = e.currentTarget.getBoundingClientRect() + const relativeX = Math.max(0, Math.min(e.clientX - rect.left, rect.width)) + const frameOffset = + rect.width > 0 + ? Math.min( + Math.max(0, item.durationInFrames - 1), + Math.floor((relativeX / rect.width) * item.durationInFrames), + ) + : 0 + const clickedFrame = Math.max(0, item.from + frameOffset) playback.pause() - if (playback.previewFrame !== null) { - commitPreviewFrameToCurrentFrame() - } else { - const rect = e.currentTarget.getBoundingClientRect() - const relativeX = Math.max(0, Math.min(e.clientX - rect.left, rect.width)) - const frameOffset = - rect.width > 0 - ? Math.min( - item.durationInFrames - 1, - Math.floor((relativeX / rect.width) * item.durationInFrames), - ) - : 0 - playback.setCurrentFrame(Math.max(0, item.from + frameOffset)) - } + playback.finishScrub(clickedFrame) } if (activeToolRef.current === 'select' || activeToolRef.current === 'trim-edit') { diff --git a/src/features/timeline/components/timeline-markers.test.tsx b/src/features/timeline/components/timeline-markers.test.tsx index bbe6783a6..e6699c419 100644 --- a/src/features/timeline/components/timeline-markers.test.tsx +++ b/src/features/timeline/components/timeline-markers.test.tsx @@ -219,6 +219,37 @@ describe('TimelineMarkers ruler scrub cancellation', () => { expect(usePlaybackStore.getState().previewFrame).toBe(30) }) + it('does not restore a queued ruler hover after click-seek release', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const { container } = render( +
+ +
, + ) + const ruler = container.querySelector('[style*="cursor: ew-resize"]') as HTMLDivElement + ruler.getBoundingClientRect = () => + ({ + left: 0, + right: 1000, + top: 0, + bottom: 34, + width: 1000, + height: 34, + }) as DOMRect + + fireEvent.mouseMove(ruler, { clientX: 100 }) + fireEvent.mouseDown(ruler, { button: 0, clientX: 260 }) + fireEvent.mouseUp(document, { clientX: 260 }) + act(() => frameCallbacks.splice(0).forEach((callback) => callback(performance.now()))) + + expect(usePlaybackStore.getState().currentFrame).toBe(78) + expect(usePlaybackStore.getState().previewFrame).toBeNull() + }) + it('keeps the IO strip in its own lane above the viewport ruler canvas', () => { useTimelineStore.setState({ inPoint: 15, outPoint: 45 }) diff --git a/src/features/timeline/components/timeline-markers.tsx b/src/features/timeline/components/timeline-markers.tsx index 9fa308c0c..2991b4895 100644 --- a/src/features/timeline/components/timeline-markers.tsx +++ b/src/features/timeline/components/timeline-markers.tsx @@ -18,6 +18,7 @@ import { beginTimelineSkimmerScrub, endTimelineSkimmerScrub, mainTimelineScrubActiveRef, + timelineSkimmerScrubSignal, } from '@/shared/timeline/main-timeline-scrub' import { getTimelineScrubViewportProgress, @@ -912,6 +913,19 @@ export const TimelineMarkers = memo(function TimelineMarkers({ [], ) + useEffect( + () => + timelineSkimmerScrubSignal.subscribe(() => { + if (!timelineSkimmerScrubSignal.current) return + if (hoverPreviewRafRef.current !== null) { + cancelAnimationFrame(hoverPreviewRafRef.current) + hoverPreviewRafRef.current = null + } + pendingHoverPreviewFrameRef.current = null + }), + [], + ) + const handleRangeMouseDown = useCallback( (e: React.PointerEvent) => { const startIn = inPointRef.current @@ -997,6 +1011,16 @@ export const TimelineMarkers = memo(function TimelineMarkers({ // without moving the mic audio would desync the recording irreparably. if (isMicRecordingActive(useMicRecordingStore.getState().status)) return + // A pointer move immediately before mousedown may still have a hover + // publication queued for the next animation frame. The click scrub now + // owns this pointer sample, so cancel that older preview before it can + // resurrect transient state after mouseup clears the scrub preview. + if (hoverPreviewRafRef.current !== null) { + cancelAnimationFrame(hoverPreviewRafRef.current) + hoverPreviewRafRef.current = null + } + pendingHoverPreviewFrameRef.current = null + // Clear marker selection when clicking on ruler (only if a marker is selected) const { selectedMarkerId } = useSelectionStore.getState() if (selectedMarkerId) { diff --git a/src/runtime/player/clock/Clock.test.ts b/src/runtime/player/clock/Clock.test.ts index da4435226..860eabf0f 100644 --- a/src/runtime/player/clock/Clock.test.ts +++ b/src/runtime/player/clock/Clock.test.ts @@ -186,4 +186,44 @@ describe('Clock playback timing', () => { clock.dispose() }) + + it('crosses adjacent clip sources before applying genuine end or loop behavior', () => { + const sources = [ + { from: 1, end: 91, picture: 'red', audioHz: 440 }, + { from: 91, end: 181, picture: 'blue', audioHz: 880 }, + ] + const sourceAt = (frame: number) => + sources.find((source) => frame >= source.from && frame < source.end) ?? null + const clock = new Clock({ + fps: 30, + durationInFrames: 181, + initialFrame: 90, + }) + + expect(sourceAt(clock.currentFrame)).toMatchObject({ picture: 'red', audioHz: 440 }) + clock.play() + runNextAnimationFrame(34) + expect(clock.currentFrame).toBe(91) + expect(sourceAt(clock.currentFrame)).toMatchObject({ picture: 'blue', audioHz: 880 }) + + runNextAnimationFrame(4_000) + expect(clock.currentFrame).toBe(180) + expect(sourceAt(clock.currentFrame)).toMatchObject({ picture: 'blue', audioHz: 880 }) + expect(clock.isPlaying).toBe(false) + clock.dispose() + + nowMs = 0 + const loopingClock = new Clock({ + fps: 30, + durationInFrames: 181, + initialFrame: 180, + loop: true, + }) + loopingClock.play() + expect(loopingClock.currentFrame).toBe(0) + runNextAnimationFrame(34) + expect(loopingClock.currentFrame).toBe(1) + expect(sourceAt(loopingClock.currentFrame)).toMatchObject({ picture: 'red', audioHz: 440 }) + loopingClock.dispose() + }) }) From 0493156e29791d57242a36bd3e1f08ef522b9358 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 01:23:11 -0700 Subject: [PATCH 18/64] fix(timeline): invalidate stale hover previews (cherry picked from commit 1f8df90347307106cbe4cd57c8ac24781f5fd0fb) --- .../components/timeline-content.test.tsx | 112 +++++++++++++++--- .../timeline/components/timeline-content.tsx | 100 ++++++++++------ 2 files changed, 157 insertions(+), 55 deletions(-) diff --git a/src/features/timeline/components/timeline-content.test.tsx b/src/features/timeline/components/timeline-content.test.tsx index 4e088d93c..75ad99df0 100644 --- a/src/features/timeline/components/timeline-content.test.tsx +++ b/src/features/timeline/components/timeline-content.test.tsx @@ -68,6 +68,7 @@ vi.mock('./timeline-preview-scrubber', () => ({ vi.mock('./timeline-track', async () => { const { useState } = await import('react') + const { usePlaybackStore } = await import('@/shared/state/playback') const { createTimelineTrackContentLayerRef } = await import('../utils/timeline-live-geometry') return { @@ -77,6 +78,14 @@ vi.mock('./timeline-track', async () => { return (
+
{ + event.stopPropagation() + usePlaybackStore.getState().finishScrub(1) + }} + />
) }, @@ -1073,13 +1082,38 @@ describe('TimelineContent playback selection behavior', () => { expect(usePlaybackStore.getState().isPlaying).toBe(false) }) - it('seeks from a clip-body click even when the clip stops bubble propagation', () => { - const { container } = render() - const track = container.querySelector(`[data-track-id="${VIDEO_TRACK.id}"]`) + it('rejects a deferred hover from the click interaction but allows a new move', () => { + vi.useFakeTimers() + const denseItems = Array.from({ length: 80 }, (_, index) => ({ + ...VIDEO_ITEM, + id: `clip-video-${index}`, + from: index * VIDEO_ITEM.durationInFrames, + })) + act(() => { + useItemsStore.getState().setItems(denseItems) + }) + + const frameCallbacks: FrameRequestCallback[] = [] + const animationFrameSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const cancelAnimationFrameSpy = vi + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation(() => { + // Model a callback that was already dequeued when cancellation arrived. + }) + + const { container, getByTestId, unmount } = render( + , + ) const scrollContainer = container.querySelector('[data-timeline-scroll-container]') - expect(track).toBeTruthy() - expect(scrollContainer).toBeTruthy() - vi.spyOn(scrollContainer!, 'getBoundingClientRect').mockReturnValue({ + if (!(scrollContainer instanceof HTMLDivElement)) { + throw new Error('Expected timeline scroll container') + } + vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, @@ -1090,23 +1124,67 @@ describe('TimelineContent playback selection behavior', () => { height: 200, toJSON: () => ({}), } as DOMRect) - const clip = document.createElement('div') - clip.dataset.itemId = VIDEO_ITEM.id - clip.addEventListener('click', (event) => event.stopPropagation()) - track!.appendChild(clip) + frameCallbacks.length = 0 - fireEvent.click(clip, { button: 0, clientX: 120, clientY: 100 }) + const item = getByTestId('mock-timeline-item') + act(() => { + usePlaybackStore.getState().setCurrentFrame(91) + usePlaybackStore.getState().setPreviewFrame(91, VIDEO_ITEM.id) + }) - expect(usePlaybackStore.getState().currentFrame).toBe(36) - }) + fireEvent.mouseMove(item, { clientX: 7, clientY: 48 }) + act(() => vi.advanceTimersByTime(150)) + const staleClickPreview = frameCallbacks.at(-1) + expect(staleClickPreview).toBeDefined() + frameCallbacks.length = 0 - it('clears transient timeline preview state when the timeline unmounts', () => { - const { unmount } = render() - act(() => usePlaybackStore.getState().setPreviewFrame(24)) + fireEvent.mouseDown(item, { button: 0, clientX: 3, clientY: 48 }) + fireEvent.click(item, { button: 0, clientX: 3, clientY: 48 }) + expect(usePlaybackStore.getState()).toMatchObject({ + currentFrame: 1, + previewFrame: null, + previewItemId: null, + }) - unmount() + act(() => staleClickPreview?.(performance.now())) + expect(usePlaybackStore.getState()).toMatchObject({ + currentFrame: 1, + previewFrame: null, + previewItemId: null, + }) + fireEvent.mouseMove(item, { clientX: 7, clientY: 48 }) + act(() => vi.advanceTimersByTime(150)) + const freshPreview = frameCallbacks.at(-1) + expect(freshPreview).toBeDefined() + frameCallbacks.length = 0 + act(() => freshPreview?.(performance.now())) + expect(usePlaybackStore.getState()).toMatchObject({ + currentFrame: 1, + previewFrame: 2, + previewItemId: VIDEO_ITEM.id, + }) + + fireEvent.mouseMove(item, { clientX: 10, clientY: 48 }) + act(() => vi.advanceTimersByTime(150)) + const cancelledPreview = frameCallbacks.at(-1) + expect(cancelledPreview).toBeDefined() + frameCallbacks.length = 0 + fireEvent.mouseLeave(scrollContainer) + act(() => cancelledPreview?.(performance.now())) expect(usePlaybackStore.getState().previewFrame).toBeNull() + + fireEvent.mouseMove(item, { clientX: 7, clientY: 48 }) + act(() => vi.advanceTimersByTime(150)) + const unmountedPreview = frameCallbacks.at(-1) + expect(unmountedPreview).toBeDefined() + unmount() + act(() => unmountedPreview?.(performance.now())) + expect(usePlaybackStore.getState().previewFrame).toBeNull() + + animationFrameSpy.mockRestore() + cancelAnimationFrameSpy.mockRestore() + vi.useRealTimers() }) it('does not pause or seek when the timeline body is clicked during a microphone take', () => { diff --git a/src/features/timeline/components/timeline-content.tsx b/src/features/timeline/components/timeline-content.tsx index 497ff55a8..aa8ecdfba 100644 --- a/src/features/timeline/components/timeline-content.tsx +++ b/src/features/timeline/components/timeline-content.tsx @@ -882,9 +882,11 @@ export const TimelineContent = memo(function TimelineContent({ const setPreviewFrame = usePlaybackStore((s) => s.setPreviewFrame) const setPreviewFrameRef = useRef(setPreviewFrame) setPreviewFrameRef.current = setPreviewFrame + const previewInteractionEpochRef = useRef(0) const previewRafRef = useRef(null) const previewDelayTimeoutRef = useRef | null>(null) const cancelPendingHoverPreview = useCallback(() => { + previewInteractionEpochRef.current += 1 if (previewDelayTimeoutRef.current !== null) { clearTimeout(previewDelayTimeoutRef.current) previewDelayTimeoutRef.current = null @@ -1410,6 +1412,19 @@ export const TimelineContent = memo(function TimelineContent({ } } + const handleTimelineClickCapture = useCallback( + (e: React.MouseEvent) => { + if (e.button !== 0) return + const target = e.target as HTMLElement + if (!target.closest('[data-track-id]')) return + + // Item clicks stop propagation, so invalidate hover work here before the + // item's click handler commits its own geometry-derived seek. + cancelPendingHoverPreview() + }, + [cancelPendingHoverPreview], + ) + // Build snap targets for razor shift-snap (item edges, grid, playhead, markers) // Called on-demand during mouse move — reads stores directly to avoid subscriptions const buildRazorSnapTargets = useCallback((): RazorSnapTarget[] => { @@ -1434,37 +1449,33 @@ export const TimelineContent = memo(function TimelineContent({ }, []) // Preview scrubber: show ghost playhead on hover - const handleTimelineMouseDownCapture = useCallback((e: React.MouseEvent) => { - if (shouldIgnoreTimelineMouseDownCapture(e.button)) return + const handleTimelineMouseDownCapture = useCallback( + (e: React.MouseEvent) => { + if (shouldIgnoreTimelineMouseDownCapture(e.button)) return - const target = e.target as HTMLElement - if ( - !target.closest('[data-track-id]') || - target.closest('[data-item-id]') || - target.closest('[data-timeline-density-bucket]') - ) { - return - } + const target = e.target as HTMLElement + if (!target.closest('[data-track-id]')) return - // A press on track background is a potential marquee gesture. Freeze the - // skim target immediately so the few pixels before marquee activation do - // not briefly seek the preview away from the mouse-down frame. - marqueePointerDownRef.current = true - if (marqueeReleaseRafRef.current !== null) { - cancelAnimationFrame(marqueeReleaseRafRef.current) - marqueeReleaseRafRef.current = null - } - const playback = usePlaybackStore.getState() - marqueeStartPreviewFrameRef.current = playback.previewFrame - marqueeReleasePreviewRef.current = - playback.previewFrame === null - ? null - : { frame: playback.previewFrame, itemId: playback.previewItemId ?? undefined } - if (previewRafRef.current !== null) { - cancelAnimationFrame(previewRafRef.current) - previewRafRef.current = null - } - }, []) + // Start a new interaction epoch before item/background handlers run. A + // hover callback already dequeued by the browser can no longer take display + // ownership during this pointer interaction. + cancelPendingHoverPreview() + if (target.closest('[data-item-id]') || target.closest('[data-timeline-density-bucket]')) + return + + // A press on track background is a potential marquee gesture. Freeze the + // skim target immediately so the few pixels before marquee activation do + // not briefly seek the preview away from the mouse-down frame. + marqueePointerDownRef.current = true + const playback = usePlaybackStore.getState() + marqueeStartPreviewFrameRef.current = playback.previewFrame + marqueeReleasePreviewRef.current = + playback.previewFrame === null + ? null + : { frame: playback.previewFrame, itemId: playback.previewItemId ?? undefined } + }, + [cancelPendingHoverPreview], + ) const finishMarqueePointerGesture = useCallback((e: MouseEvent) => { const wasMarqueePointerGesture = marqueePointerDownRef.current @@ -1487,10 +1498,15 @@ export const TimelineContent = memo(function TimelineContent({ if (pointerIsInsideTimeline && releasePreview) { // Complete marquee teardown first. Its mouseup path may clear transient // preview state later in the same event dispatch. - marqueeReleaseRafRef.current = requestAnimationFrame(() => { - marqueeReleaseRafRef.current = null + const previewEpoch = previewInteractionEpochRef.current + const releaseRafId = requestAnimationFrame(() => { + if (marqueeReleaseRafRef.current === releaseRafId) { + marqueeReleaseRafRef.current = null + } + if (previewEpoch !== previewInteractionEpochRef.current) return setPreviewFrameRef.current(releasePreview.frame, releasePreview.itemId) }) + marqueeReleaseRafRef.current = releaseRafId } else { setPreviewFrameRef.current(null) } @@ -1612,21 +1628,29 @@ export const TimelineContent = memo(function TimelineContent({ // normal hover responsive while allowing Ctrl/Cmd-wheel to cancel the // pending preview before it can compete with the first zoom frame. cancelPendingHoverPreview() + const previewEpoch = previewInteractionEpochRef.current const schedulePreviewFrame = () => { - previewDelayTimeoutRef.current = null - previewRafRef.current = requestAnimationFrame(() => { - previewRafRef.current = null + if (previewEpoch !== previewInteractionEpochRef.current) return + const previewRafId = requestAnimationFrame(() => { + if (previewRafRef.current === previewRafId) { + previewRafRef.current = null + } + if (previewEpoch !== previewInteractionEpochRef.current) return withPerfMeasure('tl.raf.previewHover', () => setPreviewFrameRef.current(frame, itemId)) }) + previewRafRef.current = previewRafId } if ( useItemsStore.getState().items.length >= DENSE_TIMELINE_TRACK_ITEM_THRESHOLD && usePlaybackStore.getState().previewFrame === null ) { - previewDelayTimeoutRef.current = setTimeout( - schedulePreviewFrame, - DENSE_TIMELINE_HOVER_PREVIEW_DELAY_MS, - ) + const previewDelayTimeout = setTimeout(() => { + if (previewDelayTimeoutRef.current === previewDelayTimeout) { + previewDelayTimeoutRef.current = null + } + schedulePreviewFrame() + }, DENSE_TIMELINE_HOVER_PREVIEW_DELAY_MS) + previewDelayTimeoutRef.current = previewDelayTimeout } else { schedulePreviewFrame() } From 850214917efd9d3017fe4ac855845fbdc719dbd2 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 20:40:30 -0700 Subject: [PATCH 19/64] fix(export): drain in-flight encodes on errors (cherry picked from commit 7d3497438b24fcf307e594e0b60d9466f7360a56) --- .../export/utils/pipelined-frame-loop.test.ts | 75 +++++++++++++ .../export/utils/pipelined-frame-loop.ts | 104 ++++++++++-------- 2 files changed, 135 insertions(+), 44 deletions(-) diff --git a/src/features/export/utils/pipelined-frame-loop.test.ts b/src/features/export/utils/pipelined-frame-loop.test.ts index e4e503dce..3d8298808 100644 --- a/src/features/export/utils/pipelined-frame-loop.test.ts +++ b/src/features/export/utils/pipelined-frame-loop.test.ts @@ -182,6 +182,81 @@ describe('runPipelinedFrameLoop', () => { expect(samples[1]?.closed).toBe(true) }) + it('preserves a render error while observing a late encoder rejection', async () => { + const renderError = new Error('render failed') + const encoderError = new Error('encoder failed after render') + const encode = deferred() + const unhandledRejections: unknown[] = [] + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) + process.on('unhandledRejection', onUnhandledRejection) + + try { + const { events, samples, run } = createHarness(3, { + renderImpl: (frame) => { + if (frame === 1) throw renderError + }, + encodeImpl: () => encode.promise, + }) + + const outcome = run().then( + () => null, + (error: unknown) => error, + ) + await tick() + expect(events).toContain('render-1') + + encode.reject(encoderError) + expect(await outcome).toBe(renderError) + await tick() + expect(unhandledRejections).toEqual([]) + expect(samples[0]?.closed).toBe(true) + } finally { + process.off('unhandledRejection', onUnhandledRejection) + } + }) + + it('preserves a pending error while observing a late encoder rejection', async () => { + const pendingError = new Error('audio task failed') + const encoderError = new Error('encoder failed after pending error') + const encodes: Deferred[] = [] + const unhandledRejections: unknown[] = [] + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) + process.on('unhandledRejection', onUnhandledRejection) + let raised: unknown + + try { + const { events, samples, run } = createHarness(4, { + getPendingError: () => raised, + renderImpl: (frame) => { + if (frame === 1) raised = pendingError + }, + encodeImpl: () => { + const encode = deferred() + encodes.push(encode) + return encode.promise + }, + }) + + const outcome = run().then( + () => null, + (error: unknown) => error, + ) + await tick() + expect(events).toContain('render-1') + encodes[0]?.resolve() + await tick() + expect(events).toContain('encode-start-1') + + encodes[1]?.reject(encoderError) + expect(await outcome).toBe(pendingError) + await tick() + expect(unhandledRejections).toEqual([]) + expect(samples[1]?.closed).toBe(true) + } finally { + process.off('unhandledRejection', onUnhandledRejection) + } + }) + it('honours an abort signalled before the loop starts', async () => { const controller = new AbortController() controller.abort() diff --git a/src/features/export/utils/pipelined-frame-loop.ts b/src/features/export/utils/pipelined-frame-loop.ts index 583a5fb36..611b152ee 100644 --- a/src/features/export/utils/pipelined-frame-loop.ts +++ b/src/features/export/utils/pipelined-frame-loop.ts @@ -7,10 +7,9 @@ * previous encode has drained, so frames reach the encoder in order. * * Behavior must stay bit-identical to the original inline loop — this is the - * export hot path. Known pre-existing hole kept on purpose: when the loop - * exits via a non-abort error (renderFrame throw or pending error) while an - * encode is in flight, that encode promise is never awaited; only the abort - * path drains it. + * export hot path. Every exit drains an in-flight encode so its sample closes + * and its rejection is observed. A render or pending error remains the primary + * error when that drain also fails. */ export interface CloseableSample { @@ -65,56 +64,73 @@ export async function runPipelinedFrameLoop( let pendingEncode: Promise | null = null - for (let frame = 0; frame < totalFrames; frame++) { - const pendingError = getPendingError?.() - if (pendingError) throw pendingError + const drainPendingEncode = async () => { + if (!pendingEncode) return + const encode = pendingEncode + try { + await encode + } finally { + pendingEncode = null + } + } + + try { + for (let frame = 0; frame < totalFrames; frame++) { + const pendingError = getPendingError?.() + if (pendingError) throw pendingError - // Check for abort — drain any in-flight encode first so the encoder - // is idle before we cancel the output. Discard encoder errors since - // we are aborting anyway and must always surface AbortError. - if (signal?.aborted) { - if (pendingEncode) { + // Check for abort — drain any in-flight encode first so the encoder + // is idle before we cancel the output. Discard encoder errors since + // we are aborting anyway and must always surface AbortError. + if (signal?.aborted) { try { - await pendingEncode + await drainPendingEncode() } catch { /* discarded — aborting */ } + await onAbort() + throw new DOMException('Render cancelled', 'AbortError') } - await onAbort() - throw new DOMException('Render cancelled', 'AbortError') - } - // Render frame first — this overlaps with the previous frame's encode - // that is still in flight. The previous sample already copied its - // pixels, so writing to the capture surface here cannot corrupt it. - await renderFrame(frame) + // Render frame first — this overlaps with the previous frame's encode + // that is still in flight. The previous sample already copied its + // pixels, so writing to the capture surface here cannot corrupt it. + await renderFrame(frame) - // Now wait for the previous encode to finish before capturing a new - // sample. This ensures at most one encode is in flight and that frames - // are fed to the encoder in order. - if (pendingEncode) await pendingEncode + // Now wait for the previous encode to finish before capturing a new + // sample. This ensures at most one encode is in flight and that frames + // are fed to the encoder in order. + await drainPendingEncode() - // Snapshot pixels into a sample. The capture copies pixel data - // immediately — the surface is free for the next render. - const sample = captureSample(frame) + // Snapshot pixels into a sample. The capture copies pixel data + // immediately — the surface is free for the next render. + const sample = captureSample(frame) - // Kick off encoding in the background. NOT awaited here — it runs - // concurrently with the next iteration's renderFrame(). - const isKeyFrame = frame === 0 - pendingEncode = (async () => { - try { - await encodeSample(sample, isKeyFrame) - } finally { - // The encoder does NOT close samples. We must close to release the - // underlying frame's GPU memory, otherwise the browser throttles - // after ~8-16 outstanding frames. - sample.close() - } - })() + // Kick off encoding in the background. NOT awaited here — it runs + // concurrently with the next iteration's renderFrame(). + const isKeyFrame = frame === 0 + pendingEncode = (async () => { + try { + await encodeSample(sample, isKeyFrame) + } finally { + // The encoder does NOT close samples. We must close to release the + // underlying frame's GPU memory, otherwise the browser throttles + // after ~8-16 outstanding frames. + sample.close() + } + })() - onFrameProgress(frame) - } + onFrameProgress(frame) + } - // Drain the final in-flight encode before finalizing - if (pendingEncode) await pendingEncode + // Drain the final in-flight encode before finalizing + await drainPendingEncode() + } catch (primaryError) { + try { + await drainPendingEncode() + } catch { + // Preserve the error that selected this exit path. + } + throw primaryError + } } From aa623037a5f767b86e06509a9a7554636782b3cf Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:12:42 -0700 Subject: [PATCH 20/64] fix(export): observe encode failures immediately (cherry picked from commit e80e35b946811d571dca4d20b17d29c2428917cf) --- .../export/utils/pipelined-frame-loop.test.ts | 165 ++++++++++++++++++ .../export/utils/pipelined-frame-loop.ts | 122 ++++++++++--- 2 files changed, 264 insertions(+), 23 deletions(-) diff --git a/src/features/export/utils/pipelined-frame-loop.test.ts b/src/features/export/utils/pipelined-frame-loop.test.ts index 3d8298808..5a1c02d6b 100644 --- a/src/features/export/utils/pipelined-frame-loop.test.ts +++ b/src/features/export/utils/pipelined-frame-loop.test.ts @@ -39,6 +39,7 @@ interface HarnessOptions { getPendingError?: () => unknown renderImpl?: (frame: number) => void | Promise encodeImpl?: (sample: FakeSample, keyFrame: boolean) => Promise + closeImpl?: (sample: FakeSample) => void } function createHarness(totalFrames: number, opts: HarnessOptions = {}) { @@ -61,6 +62,7 @@ function createHarness(totalFrames: number, opts: HarnessOptions = {}) { close() { this.closed = true events.push(`close-${frame}`) + opts.closeImpl?.(this) }, } samples.push(sample) @@ -182,6 +184,125 @@ describe('runPipelinedFrameLoop', () => { expect(samples[1]?.closed).toBe(true) }) + it('observes an immediate encode rejection while the next render stays pending', async () => { + const encoderError = new Error('encoder rejected immediately') + const nextRender = deferred() + const unhandledRejections: unknown[] = [] + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) + process.on('unhandledRejection', onUnhandledRejection) + let outcome: Promise | undefined + + try { + const { events, samples, run } = createHarness(2, { + renderImpl: (frame) => (frame === 1 ? nextRender.promise : undefined), + encodeImpl: (sample) => + sample.frame === 0 ? Promise.reject(encoderError) : Promise.resolve(), + }) + + outcome = run().then( + () => null, + (error: unknown) => error, + ) + await tick() + await tick() + + expect(events).toContain('render-1') + expect(unhandledRejections).toEqual([]) + + nextRender.resolve() + expect(await outcome).toBe(encoderError) + expect(samples[0]?.closed).toBe(true) + } finally { + nextRender.resolve() + await outcome + process.off('unhandledRejection', onUnhandledRejection) + } + }) + + it('preserves an earlier audio error when video rejects during a pending render', async () => { + const audioError = new Error('audio task failed first') + const encoderError = new Error('video encoder failed later') + const encode = deferred() + const nextRender = deferred() + const unhandledRejections: unknown[] = [] + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) + process.on('unhandledRejection', onUnhandledRejection) + let pendingError: unknown + let outcome: Promise | undefined + + try { + const { events, samples, run } = createHarness(2, { + getPendingError: () => pendingError, + renderImpl: (frame) => (frame === 1 ? nextRender.promise : undefined), + encodeImpl: () => encode.promise, + }) + + outcome = run().then( + () => null, + (error: unknown) => error, + ) + await tick() + expect(events).toContain('render-1') + + pendingError = audioError + encode.reject(encoderError) + await tick() + expect(unhandledRejections).toEqual([]) + + nextRender.resolve() + expect(await outcome).toBe(audioError) + expect(samples[0]?.closed).toBe(true) + } finally { + nextRender.resolve() + encode.resolve() + await outcome + process.off('unhandledRejection', onUnhandledRejection) + } + }) + + it('preserves the video error when sample cleanup and rendering fail later', async () => { + const encoderError = new Error('video encoder failed first') + const cleanupError = new Error('sample cleanup failed later') + const renderError = new Error('render failed last') + const encode = deferred() + const nextRender = deferred() + const unhandledRejections: unknown[] = [] + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) + process.on('unhandledRejection', onUnhandledRejection) + let outcome: Promise | undefined + + try { + const { events, samples, run } = createHarness(2, { + renderImpl: (frame) => (frame === 1 ? nextRender.promise : undefined), + encodeImpl: () => encode.promise, + closeImpl: () => { + throw cleanupError + }, + }) + + outcome = run().then( + () => null, + (error: unknown) => error, + ) + await tick() + expect(events).toContain('render-1') + + encode.reject(encoderError) + await tick() + nextRender.reject(renderError) + + expect(await outcome).toBe(encoderError) + await tick() + expect(unhandledRejections).toEqual([]) + expect(samples[0]?.closed).toBe(true) + } finally { + encode.resolve() + nextRender.resolve() + await outcome + process.off('unhandledRejection', onUnhandledRejection) + } + }) + it('preserves a render error while observing a late encoder rejection', async () => { const renderError = new Error('render failed') const encoderError = new Error('encoder failed after render') @@ -311,6 +432,50 @@ describe('runPipelinedFrameLoop', () => { expect(events).not.toContain('capture-2') }) + it('observes and drains a pending encode when abort wins during rendering', async () => { + const controller = new AbortController() + const encoderError = new Error('encoder failed after abort') + const encode = deferred() + const nextRender = deferred() + const unhandledRejections: unknown[] = [] + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) + process.on('unhandledRejection', onUnhandledRejection) + let outcome: Promise | undefined + + try { + const { events, samples, run } = createHarness(2, { + signal: controller.signal, + renderImpl: (frame) => (frame === 1 ? nextRender.promise : undefined), + encodeImpl: () => encode.promise, + }) + + outcome = run().then( + () => null, + (error: unknown) => error, + ) + await tick() + expect(events).toContain('render-1') + + controller.abort() + encode.reject(encoderError) + await tick() + expect(unhandledRejections).toEqual([]) + + nextRender.resolve() + const error = await outcome + expect(error).toBeInstanceOf(DOMException) + expect((error as DOMException).name).toBe('AbortError') + expect(events).toContain('abort-cancel') + expect(samples[0]?.closed).toBe(true) + } finally { + controller.abort() + encode.resolve() + nextRender.resolve() + await outcome + process.off('unhandledRejection', onUnhandledRejection) + } + }) + it('throws a pending error at the top of the next iteration', async () => { const pendingError = new Error('audio task failed') let raised: unknown diff --git a/src/features/export/utils/pipelined-frame-loop.ts b/src/features/export/utils/pipelined-frame-loop.ts index 611b152ee..6b9023ba9 100644 --- a/src/features/export/utils/pipelined-frame-loop.ts +++ b/src/features/export/utils/pipelined-frame-loop.ts @@ -8,8 +8,9 @@ * * Behavior must stay bit-identical to the original inline loop — this is the * export hot path. Every exit drains an in-flight encode so its sample closes - * and its rejection is observed. A render or pending error remains the primary - * error when that drain also fails. + * and its rejection is observed. Failures retain their occurrence order: a + * render, pending audio, or abort failure that happens first is not replaced + * by a later encode or cleanup failure, and vice versa. */ export interface CloseableSample { @@ -41,13 +42,22 @@ export interface PipelinedFrameLoopDeps { encodeSample: (sample: S, keyFrame: boolean) => Promise /** * Abort path: called after the in-flight encode has been drained (its - * errors discarded), before the AbortError is thrown. + * errors observed), before the selected failure is thrown. */ onAbort: () => Promise /** Called once per frame, synchronously after its encode is kicked off. */ onFrameProgress: (frame: number) => void } +interface EncodeSettlement { + status: 'fulfilled' | 'rejected' + reason?: unknown +} + +interface RecordedFailure { + error: unknown +} + export async function runPipelinedFrameLoop( deps: PipelinedFrameLoopDeps, ): Promise { @@ -62,7 +72,37 @@ export async function runPipelinedFrameLoop( onFrameProgress, } = deps - let pendingEncode: Promise | null = null + // The promise stored here never rejects. Encode and sample-cleanup failures + // are reflected into a settlement immediately, so an encoder rejection is + // observed even while renderFrame remains pending for another event turn. + let pendingEncode: Promise | null = null + let firstFailure: RecordedFailure | null = null + let abortError: DOMException | null = null + let abortCleanupStarted = false + + const recordFailure = (error: unknown) => { + firstFailure ??= { error } + } + + const getAbortError = () => { + abortError ??= new DOMException('Render cancelled', 'AbortError') + return abortError + } + + const recordEncodeFailure = (error: unknown) => { + // A pending audio error or abort may have happened while renderFrame was + // still pending, before this encode rejected. Observe those primary exit + // conditions before recording the later encoder failure. + try { + const pendingError = getPendingError?.() + if (pendingError) recordFailure(pendingError) + } catch (pendingError) { + recordFailure(pendingError) + } + + if (signal?.aborted) recordFailure(getAbortError()) + recordFailure(error) + } const drainPendingEncode = async () => { if (!pendingEncode) return @@ -74,22 +114,37 @@ export async function runPipelinedFrameLoop( } } + const runAbortCleanup = async () => { + if (abortCleanupStarted) return + abortCleanupStarted = true + try { + await onAbort() + } catch (error) { + recordFailure(error) + } + } + + const throwFirstFailure = (): void => { + const failure = firstFailure + if (failure) throw failure.error + } + try { for (let frame = 0; frame < totalFrames; frame++) { const pendingError = getPendingError?.() - if (pendingError) throw pendingError + if (pendingError) { + recordFailure(pendingError) + throw pendingError + } - // Check for abort — drain any in-flight encode first so the encoder - // is idle before we cancel the output. Discard encoder errors since - // we are aborting anyway and must always surface AbortError. + // Check for abort — drain any in-flight encode first so the encoder is + // idle before we cancel the output. The first recorded failure wins, so + // this AbortError is preserved over an encoder failure during the drain. if (signal?.aborted) { - try { - await drainPendingEncode() - } catch { - /* discarded — aborting */ - } - await onAbort() - throw new DOMException('Render cancelled', 'AbortError') + recordFailure(getAbortError()) + await drainPendingEncode() + await runAbortCleanup() + throwFirstFailure() } // Render frame first — this overlaps with the previous frame's encode @@ -101,6 +156,10 @@ export async function runPipelinedFrameLoop( // sample. This ensures at most one encode is in flight and that frames // are fed to the encoder in order. await drainPendingEncode() + if (firstFailure) { + if (signal?.aborted) await runAbortCleanup() + throwFirstFailure() + } // Snapshot pixels into a sample. The capture copies pixel data // immediately — the surface is free for the next render. @@ -109,15 +168,30 @@ export async function runPipelinedFrameLoop( // Kick off encoding in the background. NOT awaited here — it runs // concurrently with the next iteration's renderFrame(). const isKeyFrame = frame === 0 - pendingEncode = (async () => { + pendingEncode = (async (): Promise => { + let failure: RecordedFailure | null = null try { await encodeSample(sample, isKeyFrame) - } finally { + } catch (error) { + failure = { error } + recordEncodeFailure(error) + } + + try { // The encoder does NOT close samples. We must close to release the // underlying frame's GPU memory, otherwise the browser throttles // after ~8-16 outstanding frames. sample.close() + } catch (error) { + // If encoding already failed, it happened before cleanup and remains + // the failure represented by this settlement. + if (!failure) { + failure = { error } + recordFailure(error) + } } + + return failure ? { status: 'rejected', reason: failure.error } : { status: 'fulfilled' } })() onFrameProgress(frame) @@ -125,12 +199,14 @@ export async function runPipelinedFrameLoop( // Drain the final in-flight encode before finalizing await drainPendingEncode() - } catch (primaryError) { - try { - await drainPendingEncode() - } catch { - // Preserve the error that selected this exit path. + if (firstFailure) { + if (signal?.aborted) await runAbortCleanup() + throwFirstFailure() } - throw primaryError + } catch (primaryError) { + recordFailure(primaryError) + await drainPendingEncode() + if (signal?.aborted) await runAbortCleanup() + throwFirstFailure() } } From b305682c32a1ea676548238ffb4ed6b34d9d7128 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:18:16 -0700 Subject: [PATCH 21/64] refactor(export): keep encode loop complexity bounded (cherry picked from commit 977473c9b7d78321687cb7c8c69edd94f1fa261d) --- .../export/utils/pipelined-frame-loop.ts | 87 ++++++++++--------- 1 file changed, 48 insertions(+), 39 deletions(-) diff --git a/src/features/export/utils/pipelined-frame-loop.ts b/src/features/export/utils/pipelined-frame-loop.ts index 6b9023ba9..ad7dcb243 100644 --- a/src/features/export/utils/pipelined-frame-loop.ts +++ b/src/features/export/utils/pipelined-frame-loop.ts @@ -58,6 +58,38 @@ interface RecordedFailure { error: unknown } +async function encodeAndCloseSample( + sample: S, + keyFrame: boolean, + encodeSample: (sample: S, keyFrame: boolean) => Promise, + recordEncodeFailure: (error: unknown) => void, + recordFailure: (error: unknown) => void, +): Promise { + let failure: RecordedFailure | null = null + try { + await encodeSample(sample, keyFrame) + } catch (error) { + failure = { error } + recordEncodeFailure(error) + } + + try { + // The encoder does NOT close samples. We must close to release the + // underlying frame's GPU memory, otherwise the browser throttles after + // ~8-16 outstanding frames. + sample.close() + } catch (error) { + // If encoding already failed, it happened before cleanup and remains the + // failure represented by this settlement. + if (!failure) { + failure = { error } + recordFailure(error) + } + } + + return failure ? { status: 'rejected', reason: failure.error } : { status: 'fulfilled' } +} + export async function runPipelinedFrameLoop( deps: PipelinedFrameLoopDeps, ): Promise { @@ -124,9 +156,11 @@ export async function runPipelinedFrameLoop( } } - const throwFirstFailure = (): void => { + const throwRecordedFailureAfterDrain = async () => { const failure = firstFailure - if (failure) throw failure.error + if (!failure) return + if (signal?.aborted) await runAbortCleanup() + throw failure.error } try { @@ -143,8 +177,7 @@ export async function runPipelinedFrameLoop( if (signal?.aborted) { recordFailure(getAbortError()) await drainPendingEncode() - await runAbortCleanup() - throwFirstFailure() + await throwRecordedFailureAfterDrain() } // Render frame first — this overlaps with the previous frame's encode @@ -156,10 +189,7 @@ export async function runPipelinedFrameLoop( // sample. This ensures at most one encode is in flight and that frames // are fed to the encoder in order. await drainPendingEncode() - if (firstFailure) { - if (signal?.aborted) await runAbortCleanup() - throwFirstFailure() - } + await throwRecordedFailureAfterDrain() // Snapshot pixels into a sample. The capture copies pixel data // immediately — the surface is free for the next render. @@ -168,45 +198,24 @@ export async function runPipelinedFrameLoop( // Kick off encoding in the background. NOT awaited here — it runs // concurrently with the next iteration's renderFrame(). const isKeyFrame = frame === 0 - pendingEncode = (async (): Promise => { - let failure: RecordedFailure | null = null - try { - await encodeSample(sample, isKeyFrame) - } catch (error) { - failure = { error } - recordEncodeFailure(error) - } - - try { - // The encoder does NOT close samples. We must close to release the - // underlying frame's GPU memory, otherwise the browser throttles - // after ~8-16 outstanding frames. - sample.close() - } catch (error) { - // If encoding already failed, it happened before cleanup and remains - // the failure represented by this settlement. - if (!failure) { - failure = { error } - recordFailure(error) - } - } - - return failure ? { status: 'rejected', reason: failure.error } : { status: 'fulfilled' } - })() + pendingEncode = encodeAndCloseSample( + sample, + isKeyFrame, + encodeSample, + recordEncodeFailure, + recordFailure, + ) onFrameProgress(frame) } // Drain the final in-flight encode before finalizing await drainPendingEncode() - if (firstFailure) { - if (signal?.aborted) await runAbortCleanup() - throwFirstFailure() - } + await throwRecordedFailureAfterDrain() } catch (primaryError) { recordFailure(primaryError) await drainPendingEncode() - if (signal?.aborted) await runAbortCleanup() - throwFirstFailure() + await throwRecordedFailureAfterDrain() + throw primaryError } } From 3804eb5c2a074a02e6266c0b2bf98d828f0cd3b7 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:41:19 -0700 Subject: [PATCH 22/64] fix(export): preserve first frame-loop failure (cherry picked from commit 5f31b1c6e5ad086464ab7e211849ce2763064688) --- .../export/utils/pipelined-frame-loop.test.ts | 118 ++++++++++++++++++ .../export/utils/pipelined-frame-loop.ts | 66 ++++++---- 2 files changed, 158 insertions(+), 26 deletions(-) diff --git a/src/features/export/utils/pipelined-frame-loop.test.ts b/src/features/export/utils/pipelined-frame-loop.test.ts index 5a1c02d6b..3404d3c5e 100644 --- a/src/features/export/utils/pipelined-frame-loop.test.ts +++ b/src/features/export/utils/pipelined-frame-loop.test.ts @@ -40,6 +40,7 @@ interface HarnessOptions { renderImpl?: (frame: number) => void | Promise encodeImpl?: (sample: FakeSample, keyFrame: boolean) => Promise closeImpl?: (sample: FakeSample) => void + onAbortImpl?: () => void | Promise } function createHarness(totalFrames: number, opts: HarnessOptions = {}) { @@ -77,6 +78,7 @@ function createHarness(totalFrames: number, opts: HarnessOptions = {}) { }, onAbort: async () => { events.push('abort-cancel') + await opts.onAbortImpl?.() }, onFrameProgress: (frame) => { events.push(`progress-${frame}`) @@ -260,6 +262,63 @@ describe('runPipelinedFrameLoop', () => { } }) + it('preserves earlier audio over abort and a rejecting render before encode drains', async () => { + const controller = new AbortController() + const audioError = new Error('audio task failed first') + const renderError = new Error('render failed later') + const cleanupError = new Error('sample cleanup failed last') + const encode = deferred() + const nextRender = deferred() + const unhandledRejections: unknown[] = [] + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) + process.on('unhandledRejection', onUnhandledRejection) + let pendingError: unknown + let outcome: Promise | undefined + + try { + const { events, samples, run } = createHarness(2, { + signal: controller.signal, + getPendingError: () => pendingError, + renderImpl: (frame) => (frame === 1 ? nextRender.promise : undefined), + encodeImpl: () => encode.promise, + closeImpl: () => { + throw cleanupError + }, + }) + + let settled = false + outcome = run().then( + () => null, + (error: unknown) => error, + ) + void outcome.finally(() => { + settled = true + }) + await tick() + expect(events).toContain('render-1') + + pendingError = audioError + controller.abort() + nextRender.reject(renderError) + await tick() + expect(settled).toBe(false) + expect(unhandledRejections).toEqual([]) + + encode.resolve() + expect(await outcome).toBe(audioError) + expect(events).toContain('abort-cancel') + expect(samples[0]?.closed).toBe(true) + await tick() + expect(unhandledRejections).toEqual([]) + } finally { + controller.abort() + nextRender.resolve() + encode.resolve() + await outcome + process.off('unhandledRejection', onUnhandledRejection) + } + }) + it('preserves the video error when sample cleanup and rendering fail later', async () => { const encoderError = new Error('video encoder failed first') const cleanupError = new Error('sample cleanup failed later') @@ -476,6 +535,65 @@ describe('runPipelinedFrameLoop', () => { } }) + it('preserves earlier abort over audio and a rejecting render before encode drains', async () => { + const controller = new AbortController() + const audioError = new Error('audio task failed later') + const renderError = new Error('render failed later') + const abortCleanupError = new Error('abort cleanup failed last') + const encode = deferred() + const nextRender = deferred() + const unhandledRejections: unknown[] = [] + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) + process.on('unhandledRejection', onUnhandledRejection) + let pendingError: unknown + let outcome: Promise | undefined + + try { + const { events, samples, run } = createHarness(2, { + signal: controller.signal, + getPendingError: () => pendingError, + renderImpl: (frame) => (frame === 1 ? nextRender.promise : undefined), + encodeImpl: () => encode.promise, + onAbortImpl: () => { + throw abortCleanupError + }, + }) + + let settled = false + outcome = run().then( + () => null, + (error: unknown) => error, + ) + void outcome.finally(() => { + settled = true + }) + await tick() + expect(events).toContain('render-1') + + controller.abort() + pendingError = audioError + nextRender.reject(renderError) + await tick() + expect(settled).toBe(false) + expect(unhandledRejections).toEqual([]) + + encode.resolve() + const error = await outcome + expect(error).toBeInstanceOf(DOMException) + expect((error as DOMException).name).toBe('AbortError') + expect(events).toContain('abort-cancel') + expect(samples[0]?.closed).toBe(true) + await tick() + expect(unhandledRejections).toEqual([]) + } finally { + controller.abort() + nextRender.resolve() + encode.resolve() + await outcome + process.off('unhandledRejection', onUnhandledRejection) + } + }) + it('throws a pending error at the top of the next iteration', async () => { const pendingError = new Error('audio task failed') let raised: unknown diff --git a/src/features/export/utils/pipelined-frame-loop.ts b/src/features/export/utils/pipelined-frame-loop.ts index ad7dcb243..e2733ef20 100644 --- a/src/features/export/utils/pipelined-frame-loop.ts +++ b/src/features/export/utils/pipelined-frame-loop.ts @@ -62,15 +62,14 @@ async function encodeAndCloseSample( sample: S, keyFrame: boolean, encodeSample: (sample: S, keyFrame: boolean) => Promise, - recordEncodeFailure: (error: unknown) => void, - recordFailure: (error: unknown) => void, + recordSettledFailure: (error: unknown) => void, ): Promise { let failure: RecordedFailure | null = null try { await encodeSample(sample, keyFrame) } catch (error) { failure = { error } - recordEncodeFailure(error) + recordSettledFailure(error) } try { @@ -83,7 +82,7 @@ async function encodeAndCloseSample( // failure represented by this settlement. if (!failure) { failure = { error } - recordFailure(error) + recordSettledFailure(error) } } @@ -121,26 +120,41 @@ export async function runPipelinedFrameLoop( return abortError } - const recordEncodeFailure = (error: unknown) => { - // A pending audio error or abort may have happened while renderFrame was - // still pending, before this encode rejected. Observe those primary exit - // conditions before recording the later encoder failure. + const recordPendingFailure = (): RecordedFailure | null => { try { const pendingError = getPendingError?.() - if (pendingError) recordFailure(pendingError) + if (!pendingError) return null + const failure = { error: pendingError } + recordFailure(pendingError) + return failure } catch (pendingError) { recordFailure(pendingError) + return { error: pendingError } } + } + const recordObservableFailures = () => { + // Sampling pending audio before recording an already-fired abort preserves + // their event order: the abort listener observes audio that failed first, + // while an abort already recorded by the listener remains primary over an + // audio failure that appears later. + recordPendingFailure() if (signal?.aborted) recordFailure(getAbortError()) + } + + const recordSettledFailure = (error: unknown) => { + // Audio or abort may have happened while renderFrame or this encode was + // pending. Observe those primary exit conditions before the later encode + // or sample-cleanup failure. + recordObservableFailures() recordFailure(error) } - const drainPendingEncode = async () => { - if (!pendingEncode) return + const drainPendingEncode = async (): Promise => { + if (!pendingEncode) return null const encode = pendingEncode try { - await encode + return await encode } finally { pendingEncode = null } @@ -163,13 +177,12 @@ export async function runPipelinedFrameLoop( throw failure.error } + signal?.addEventListener('abort', recordObservableFailures, { once: true }) + try { for (let frame = 0; frame < totalFrames; frame++) { - const pendingError = getPendingError?.() - if (pendingError) { - recordFailure(pendingError) - throw pendingError - } + const pendingFailure = recordPendingFailure() + if (pendingFailure) throw pendingFailure.error // Check for abort — drain any in-flight encode first so the encoder is // idle before we cancel the output. The first recorded failure wins, so @@ -188,8 +201,8 @@ export async function runPipelinedFrameLoop( // Now wait for the previous encode to finish before capturing a new // sample. This ensures at most one encode is in flight and that frames // are fed to the encoder in order. - await drainPendingEncode() - await throwRecordedFailureAfterDrain() + const previousEncode = await drainPendingEncode() + if (previousEncode?.status === 'rejected') await throwRecordedFailureAfterDrain() // Snapshot pixels into a sample. The capture copies pixel data // immediately — the surface is free for the next render. @@ -198,24 +211,25 @@ export async function runPipelinedFrameLoop( // Kick off encoding in the background. NOT awaited here — it runs // concurrently with the next iteration's renderFrame(). const isKeyFrame = frame === 0 - pendingEncode = encodeAndCloseSample( - sample, - isKeyFrame, - encodeSample, - recordEncodeFailure, - recordFailure, - ) + pendingEncode = encodeAndCloseSample(sample, isKeyFrame, encodeSample, recordSettledFailure) onFrameProgress(frame) } // Drain the final in-flight encode before finalizing await drainPendingEncode() + if (totalFrames > 0) recordObservableFailures() await throwRecordedFailureAfterDrain() } catch (primaryError) { + // A render/capture/progress rejection reaches this catch on a later promise + // turn. Sample failures already exposed by the concurrent channels before + // assigning that newly caught error. + recordObservableFailures() recordFailure(primaryError) await drainPendingEncode() await throwRecordedFailureAfterDrain() throw primaryError + } finally { + signal?.removeEventListener('abort', recordObservableFailures) } } From d6ce167730d222cae6eaa81662c5d13c762a7038 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:59:36 -0700 Subject: [PATCH 23/64] fix(export): order frame failures at source (cherry picked from commit c0c288fbfa95de7e59c11951b27d4da074382816) --- .../utils/canvas-render-orchestrator.ts | 32 ++- .../export/utils/pipelined-frame-loop.test.ts | 211 +++++++++++++++--- .../export/utils/pipelined-frame-loop.ts | 130 ++++++----- 3 files changed, 273 insertions(+), 100 deletions(-) diff --git a/src/features/export/utils/canvas-render-orchestrator.ts b/src/features/export/utils/canvas-render-orchestrator.ts index 7748689ad..c2ee46c25 100644 --- a/src/features/export/utils/canvas-render-orchestrator.ts +++ b/src/features/export/utils/canvas-render-orchestrator.ts @@ -28,7 +28,7 @@ import { createExportOutputTarget } from './export-output-target' // Subsystems import { createCompositionRenderer } from './client-render-engine' -import { runPipelinedFrameLoop } from './pipelined-frame-loop' +import { createPipelinedFrameLoopFailureState, runPipelinedFrameLoop } from './pipelined-frame-loop' function getLog() { return createLogger('CanvasRenderOrchestrator') @@ -714,6 +714,7 @@ export async function renderComposition(options: RenderEngineOptions): Promise { if (videoRenderingStarted) return const boundedSeconds = Math.min(durationSeconds, completedSeconds) @@ -760,6 +761,7 @@ export async function renderComposition(options: RenderEngineOptions): Promise { audioError = error + frameLoopFailureState.reportFailure(error) }) onProgress({ @@ -787,21 +789,31 @@ export async function renderComposition(options: RenderEngineOptions): Promise audioError, - renderFrame: async (frame) => { - await renderer.renderFrame(frame) - // Scale to output resolution if needed - if (needsScaling) { - outputCtx.clearRect(0, 0, exportWidth, exportHeight) - outputCtx.drawImage(renderCanvas, 0, 0, exportWidth, exportHeight) + failureState: frameLoopFailureState, + renderFrame: async (frame, reportFailure) => { + try { + await renderer.renderFrame(frame) + // Scale to output resolution if needed + if (needsScaling) { + outputCtx.clearRect(0, 0, exportWidth, exportHeight) + outputCtx.drawImage(renderCanvas, 0, 0, exportWidth, exportHeight) + } + } catch (error) { + reportFailure(error) + throw error } }, // VideoSampleSource does NOT close samples (unlike CanvasSource) — the // loop closes each sample to release the VideoFrame's GPU memory. captureSample: (frame) => new VideoSample(outputCanvas, { timestamp: frame / fps, duration: 1 / fps }), - encodeSample: (sample, keyFrame) => - keyFrame ? videoSource.add(sample, { keyFrame: true }) : videoSource.add(sample), + encodeSample: (sample, keyFrame, reportFailure) => { + const encoding = keyFrame + ? videoSource.add(sample, { keyFrame: true }) + : videoSource.add(sample) + void encoding.catch(reportFailure) + return encoding + }, onAbort: () => output.cancel(), onFrameProgress: (frame) => { onProgress({ diff --git a/src/features/export/utils/pipelined-frame-loop.test.ts b/src/features/export/utils/pipelined-frame-loop.test.ts index 3404d3c5e..edae84d36 100644 --- a/src/features/export/utils/pipelined-frame-loop.test.ts +++ b/src/features/export/utils/pipelined-frame-loop.test.ts @@ -5,8 +5,12 @@ // shape on mocks without importing production code). End-to-end protection of // the full orchestrator remains the headless chrome e2e (headless/test.mjs). -import { describe, it, expect } from 'vite-plus/test' -import { runPipelinedFrameLoop } from './pipelined-frame-loop' +import { describe, it, expect, vi } from 'vite-plus/test' +import { + createPipelinedFrameLoopFailureState, + runPipelinedFrameLoop, + type PipelinedFrameLoopFailureState, +} from './pipelined-frame-loop' interface Deferred { promise: Promise @@ -36,7 +40,7 @@ interface FakeSample { interface HarnessOptions { signal?: AbortSignal - getPendingError?: () => unknown + failureState?: PipelinedFrameLoopFailureState renderImpl?: (frame: number) => void | Promise encodeImpl?: (sample: FakeSample, keyFrame: boolean) => Promise closeImpl?: (sample: FakeSample) => void @@ -46,15 +50,21 @@ interface HarnessOptions { function createHarness(totalFrames: number, opts: HarnessOptions = {}) { const events: string[] = [] const samples: FakeSample[] = [] + const failureState = opts.failureState ?? createPipelinedFrameLoopFailureState() const run = () => runPipelinedFrameLoop({ totalFrames, signal: opts.signal, - getPendingError: opts.getPendingError, - renderFrame: async (frame) => { + failureState, + renderFrame: async (frame, reportFailure) => { events.push(`render-${frame}`) - await opts.renderImpl?.(frame) + try { + await opts.renderImpl?.(frame) + } catch (error) { + reportFailure(error) + throw error + } }, captureSample: (frame) => { const sample: FakeSample = { @@ -70,9 +80,11 @@ function createHarness(totalFrames: number, opts: HarnessOptions = {}) { events.push(`capture-${frame}`) return sample }, - encodeSample: (sample, keyFrame) => { + encodeSample: (sample, keyFrame, reportFailure) => { events.push(`encode-start-${sample.frame}${keyFrame ? '-key' : ''}`) - return (opts.encodeImpl?.(sample, keyFrame) ?? Promise.resolve()).then(() => { + const encoding = opts.encodeImpl?.(sample, keyFrame) ?? Promise.resolve() + void encoding.catch(reportFailure) + return encoding.then(() => { events.push(`encode-end-${sample.frame}`) }) }, @@ -85,7 +97,7 @@ function createHarness(totalFrames: number, opts: HarnessOptions = {}) { }, }) - return { events, samples, run } + return { events, samples, failureState, run } } const indexOf = (events: string[], event: string) => { @@ -94,7 +106,139 @@ const indexOf = (events: string[], event: string) => { return index } +type FailureSource = 'render' | 'encode' | 'abort' | 'audio' + +interface FailureOrderCase { + name: string + first: FailureSource + second: FailureSource + expected: FailureSource +} + +const failureOrderCases: FailureOrderCase[] = [ + { name: 'render first then abort', first: 'render', second: 'abort', expected: 'render' }, + { name: 'render first then audio', first: 'render', second: 'audio', expected: 'render' }, + { name: 'encode first then abort', first: 'encode', second: 'abort', expected: 'encode' }, + { name: 'abort first then render', first: 'abort', second: 'render', expected: 'abort' }, + { name: 'abort first then encode', first: 'abort', second: 'encode', expected: 'abort' }, + { name: 'audio first then render', first: 'audio', second: 'render', expected: 'audio' }, +] + +const failureOrderMatrix = failureOrderCases.flatMap((testCase) => [ + { ...testCase, timing: 'same turn' as const }, + { ...testCase, timing: 'one microtask apart' as const }, +]) + describe('runPipelinedFrameLoop', () => { + it.each(failureOrderMatrix)( + 'preserves source order: $name ($timing)', + async ({ first, second, expected, timing }) => { + const controller = new AbortController() + const render = deferred() + const encode = deferred() + const audio = deferred() + const errors: Record = { + render: new Error('render source failed'), + encode: new Error('encode source failed'), + audio: new Error('audio source failed'), + abort: null, + } + const cleanupError = new Error('abort cleanup must not mask the primary failure') + const failureState = createPipelinedFrameLoopFailureState() + const observedAudio = audio.promise.then( + () => undefined, + (error: unknown) => failureState.reportFailure(error), + ) + const unhandledRejections: unknown[] = [] + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + process.on('unhandledRejection', onUnhandledRejection) + + let renderSettled = false + let encodeSettled = false + let audioSettled = false + let outcome: Promise | undefined + + const fire = (source: FailureSource) => { + switch (source) { + case 'render': + renderSettled = true + render.reject(errors.render) + break + case 'encode': + encodeSettled = true + encode.reject(errors.encode) + break + case 'audio': + audioSettled = true + audio.reject(errors.audio) + break + case 'abort': + controller.abort() + break + } + } + + try { + const { events, samples, run } = createHarness(2, { + signal: controller.signal, + failureState, + renderImpl: (frame) => (frame === 1 ? render.promise : undefined), + encodeImpl: () => encode.promise, + onAbortImpl: () => { + throw cleanupError + }, + }) + + outcome = run().then( + () => null, + (error: unknown) => error, + ) + await tick() + expect(events).toContain('render-1') + + // Calls earlier in this list define same-turn ties. Promise reactions + // and the queued abort publication retain that source enqueue order. + fire(first) + if (timing === 'one microtask apart') await Promise.resolve() + fire(second) + + if (!renderSettled) render.resolve() + if (!encodeSettled) encode.resolve() + if (!audioSettled) audio.resolve() + + const error = await outcome + if (expected === 'abort') { + expect(error).toBeInstanceOf(DOMException) + expect((error as DOMException).name).toBe('AbortError') + } else { + expect(error).toBe(errors[expected]) + } + + await observedAudio + await tick() + expect(unhandledRejections).toEqual([]) + expect(samples).toHaveLength(1) + expect(samples[0]?.closed).toBe(true) + expect(events).toContain('close-0') + expect(events.includes('abort-cancel')).toBe(controller.signal.aborted) + if (controller.signal.aborted) { + expect(indexOf(events, 'close-0')).toBeLessThan(indexOf(events, 'abort-cancel')) + } + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + } finally { + controller.abort() + render.resolve() + encode.resolve() + audio.resolve() + await observedAudio + await outcome + removeListener.mockRestore() + process.off('unhandledRejection', onUnhandledRejection) + } + }, + ) + it('encodes all frames in order and closes every sample', async () => { const { events, samples, run } = createHarness(5) await run() @@ -229,12 +373,10 @@ describe('runPipelinedFrameLoop', () => { const unhandledRejections: unknown[] = [] const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) process.on('unhandledRejection', onUnhandledRejection) - let pendingError: unknown let outcome: Promise | undefined try { - const { events, samples, run } = createHarness(2, { - getPendingError: () => pendingError, + const { events, samples, failureState, run } = createHarness(2, { renderImpl: (frame) => (frame === 1 ? nextRender.promise : undefined), encodeImpl: () => encode.promise, }) @@ -246,7 +388,7 @@ describe('runPipelinedFrameLoop', () => { await tick() expect(events).toContain('render-1') - pendingError = audioError + failureState.reportFailure(audioError) encode.reject(encoderError) await tick() expect(unhandledRejections).toEqual([]) @@ -272,13 +414,11 @@ describe('runPipelinedFrameLoop', () => { const unhandledRejections: unknown[] = [] const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) process.on('unhandledRejection', onUnhandledRejection) - let pendingError: unknown let outcome: Promise | undefined try { - const { events, samples, run } = createHarness(2, { + const { events, samples, failureState, run } = createHarness(2, { signal: controller.signal, - getPendingError: () => pendingError, renderImpl: (frame) => (frame === 1 ? nextRender.promise : undefined), encodeImpl: () => encode.promise, closeImpl: () => { @@ -297,7 +437,7 @@ describe('runPipelinedFrameLoop', () => { await tick() expect(events).toContain('render-1') - pendingError = audioError + failureState.reportFailure(audioError) controller.abort() nextRender.reject(renderError) await tick() @@ -402,13 +542,13 @@ describe('runPipelinedFrameLoop', () => { const unhandledRejections: unknown[] = [] const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) process.on('unhandledRejection', onUnhandledRejection) - let raised: unknown + const failureState = createPipelinedFrameLoopFailureState() try { const { events, samples, run } = createHarness(4, { - getPendingError: () => raised, + failureState, renderImpl: (frame) => { - if (frame === 1) raised = pendingError + if (frame === 1) failureState.reportFailure(pendingError) }, encodeImpl: () => { const encode = deferred() @@ -545,13 +685,17 @@ describe('runPipelinedFrameLoop', () => { const unhandledRejections: unknown[] = [] const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason) process.on('unhandledRejection', onUnhandledRejection) - let pendingError: unknown let outcome: Promise | undefined try { + const audio = deferred() + const failureState = createPipelinedFrameLoopFailureState() + const observedAudio = audio.promise.catch((error: unknown) => { + failureState.reportFailure(error) + }) const { events, samples, run } = createHarness(2, { signal: controller.signal, - getPendingError: () => pendingError, + failureState, renderImpl: (frame) => (frame === 1 ? nextRender.promise : undefined), encodeImpl: () => encode.promise, onAbortImpl: () => { @@ -571,7 +715,7 @@ describe('runPipelinedFrameLoop', () => { expect(events).toContain('render-1') controller.abort() - pendingError = audioError + audio.reject(audioError) nextRender.reject(renderError) await tick() expect(settled).toBe(false) @@ -583,6 +727,7 @@ describe('runPipelinedFrameLoop', () => { expect((error as DOMException).name).toBe('AbortError') expect(events).toContain('abort-cancel') expect(samples[0]?.closed).toBe(true) + await observedAudio await tick() expect(unhandledRejections).toEqual([]) } finally { @@ -596,11 +741,11 @@ describe('runPipelinedFrameLoop', () => { it('throws a pending error at the top of the next iteration', async () => { const pendingError = new Error('audio task failed') - let raised: unknown + const failureState = createPipelinedFrameLoopFailureState() const { events, run } = createHarness(5, { - getPendingError: () => raised, + failureState, renderImpl: (frame) => { - if (frame === 1) raised = pendingError + if (frame === 1) failureState.reportFailure(pendingError) }, }) @@ -609,20 +754,20 @@ describe('runPipelinedFrameLoop', () => { expect(events).not.toContain('render-2') }) - it('ignores falsy pending errors (truthiness semantics)', async () => { - for (const falsy of [undefined, '', 0, null]) { - const { samples, run } = createHarness(2, { getPendingError: () => falsy }) - await run() - expect(samples).toHaveLength(2) - } + it('continues until an external source publishes a failure', async () => { + const { samples, run } = createHarness(2) + await run() + expect(samples).toHaveLength(2) }) it('resolves immediately for zero frames without touching any callback', async () => { const controller = new AbortController() controller.abort() + const failureState = createPipelinedFrameLoopFailureState() + failureState.reportFailure(new Error('never checked')) const { events, run } = createHarness(0, { signal: controller.signal, - getPendingError: () => new Error('never checked'), + failureState, }) await run() // Pre-loop abort/error checks are the caller's responsibility. diff --git a/src/features/export/utils/pipelined-frame-loop.ts b/src/features/export/utils/pipelined-frame-loop.ts index e2733ef20..0a2672561 100644 --- a/src/features/export/utils/pipelined-frame-loop.ts +++ b/src/features/export/utils/pipelined-frame-loop.ts @@ -17,19 +17,51 @@ export interface CloseableSample { close(): void } +interface RecordedFailure { + error: unknown +} + +export interface PipelinedFrameLoopFailureState { + readonly firstFailure: RecordedFailure | null + reportFailure(error: unknown): void +} + +/** + * Shared first-error latch for concurrently running export sources. + * + * Sources publish when their failure becomes observable. Promise sources must + * attach their rejection observer immediately; abort publication is queued as + * a microtask so same-turn promise rejection and abort events are ordered by + * the source events that queued their observers, not by a synchronous abort + * listener racing ahead of already-fired promise rejections. + */ +export function createPipelinedFrameLoopFailureState(): PipelinedFrameLoopFailureState { + let firstFailure: RecordedFailure | null = null + return { + get firstFailure() { + return firstFailure + }, + reportFailure(error) { + firstFailure ??= { error } + }, + } +} + export interface PipelinedFrameLoopDeps { totalFrames: number signal?: AbortSignal /** - * Read (not throw) a pending async error, e.g. a failed audio task. - * Checked with a truthiness test at the top of every iteration. + * Shared source-event latch. An independently running source such as audio + * must attach a rejection observer immediately and publish into this state. */ - getPendingError?: () => unknown + failureState?: PipelinedFrameLoopFailureState /** * Render the frame to the capture surface, including any scale-to-output - * blit. Overlaps with the previous frame's in-flight encode. + * blit. Overlaps with the previous frame's in-flight encode. The callback + * must be invoked by the rejection observer attached directly to the source + * promise, before rethrowing through any async wrapper. */ - renderFrame: (frame: number) => Promise + renderFrame: (frame: number, reportFailure: (error: unknown) => void) => Promise /** * Snapshot the capture surface (e.g. VideoSample construction). Called * strictly after the previous encode has drained; must stay synchronous. @@ -37,9 +69,14 @@ export interface PipelinedFrameLoopDeps { captureSample: (frame: number) => S /** * Feed the sample to the encoder. `keyFrame` is true only for frame 0. - * The loop closes the sample when the returned promise settles. + * The loop closes the sample when the returned promise settles. As with + * renderFrame, report a rejection from an observer on the source promise. */ - encodeSample: (sample: S, keyFrame: boolean) => Promise + encodeSample: ( + sample: S, + keyFrame: boolean, + reportFailure: (error: unknown) => void, + ) => Promise /** * Abort path: called after the in-flight encode has been drained (its * errors observed), before the selected failure is thrown. @@ -54,19 +91,19 @@ interface EncodeSettlement { reason?: unknown } -interface RecordedFailure { - error: unknown -} - async function encodeAndCloseSample( sample: S, keyFrame: boolean, - encodeSample: (sample: S, keyFrame: boolean) => Promise, + encodeSample: ( + sample: S, + keyFrame: boolean, + reportFailure: (error: unknown) => void, + ) => Promise, recordSettledFailure: (error: unknown) => void, ): Promise { let failure: RecordedFailure | null = null try { - await encodeSample(sample, keyFrame) + await encodeSample(sample, keyFrame, recordSettledFailure) } catch (error) { failure = { error } recordSettledFailure(error) @@ -95,7 +132,7 @@ export async function runPipelinedFrameLoop( const { totalFrames, signal, - getPendingError, + failureState = createPipelinedFrameLoopFailureState(), renderFrame, captureSample, encodeSample, @@ -107,12 +144,13 @@ export async function runPipelinedFrameLoop( // are reflected into a settlement immediately, so an encoder rejection is // observed even while renderFrame remains pending for another event turn. let pendingEncode: Promise | null = null - let firstFailure: RecordedFailure | null = null let abortError: DOMException | null = null let abortCleanupStarted = false + let abortPublicationQueued = false + let listenerActive = true const recordFailure = (error: unknown) => { - firstFailure ??= { error } + failureState.reportFailure(error) } const getAbortError = () => { @@ -120,34 +158,12 @@ export async function runPipelinedFrameLoop( return abortError } - const recordPendingFailure = (): RecordedFailure | null => { - try { - const pendingError = getPendingError?.() - if (!pendingError) return null - const failure = { error: pendingError } - recordFailure(pendingError) - return failure - } catch (pendingError) { - recordFailure(pendingError) - return { error: pendingError } - } - } - - const recordObservableFailures = () => { - // Sampling pending audio before recording an already-fired abort preserves - // their event order: the abort listener observes audio that failed first, - // while an abort already recorded by the listener remains primary over an - // audio failure that appears later. - recordPendingFailure() - if (signal?.aborted) recordFailure(getAbortError()) - } - - const recordSettledFailure = (error: unknown) => { - // Audio or abort may have happened while renderFrame or this encode was - // pending. Observe those primary exit conditions before the later encode - // or sample-cleanup failure. - recordObservableFailures() - recordFailure(error) + const publishAbort = () => { + if (abortPublicationQueued) return + abortPublicationQueued = true + queueMicrotask(() => { + if (listenerActive) recordFailure(getAbortError()) + }) } const drainPendingEncode = async (): Promise => { @@ -171,24 +187,28 @@ export async function runPipelinedFrameLoop( } const throwRecordedFailureAfterDrain = async () => { - const failure = firstFailure + const failure = failureState.firstFailure if (!failure) return if (signal?.aborted) await runAbortCleanup() throw failure.error } - signal?.addEventListener('abort', recordObservableFailures, { once: true }) + signal?.addEventListener('abort', publishAbort, { once: true }) try { for (let frame = 0; frame < totalFrames; frame++) { - const pendingFailure = recordPendingFailure() + const pendingFailure = failureState.firstFailure if (pendingFailure) throw pendingFailure.error // Check for abort — drain any in-flight encode first so the encoder is // idle before we cancel the output. The first recorded failure wins, so // this AbortError is preserved over an encoder failure during the drain. if (signal?.aborted) { - recordFailure(getAbortError()) + publishAbort() + // Let reactions queued by source failures that fired before abort run + // before the queued abort publication. If abort fired first, its + // publication was queued first and remains primary. + await Promise.resolve() await drainPendingEncode() await throwRecordedFailureAfterDrain() } @@ -196,7 +216,7 @@ export async function runPipelinedFrameLoop( // Render frame first — this overlaps with the previous frame's encode // that is still in flight. The previous sample already copied its // pixels, so writing to the capture surface here cannot corrupt it. - await renderFrame(frame) + await renderFrame(frame, recordFailure) // Now wait for the previous encode to finish before capturing a new // sample. This ensures at most one encode is in flight and that frames @@ -211,25 +231,21 @@ export async function runPipelinedFrameLoop( // Kick off encoding in the background. NOT awaited here — it runs // concurrently with the next iteration's renderFrame(). const isKeyFrame = frame === 0 - pendingEncode = encodeAndCloseSample(sample, isKeyFrame, encodeSample, recordSettledFailure) + pendingEncode = encodeAndCloseSample(sample, isKeyFrame, encodeSample, recordFailure) onFrameProgress(frame) } // Drain the final in-flight encode before finalizing await drainPendingEncode() - if (totalFrames > 0) recordObservableFailures() - await throwRecordedFailureAfterDrain() + if (totalFrames > 0) await throwRecordedFailureAfterDrain() } catch (primaryError) { - // A render/capture/progress rejection reaches this catch on a later promise - // turn. Sample failures already exposed by the concurrent channels before - // assigning that newly caught error. - recordObservableFailures() recordFailure(primaryError) await drainPendingEncode() await throwRecordedFailureAfterDrain() throw primaryError } finally { - signal?.removeEventListener('abort', recordObservableFailures) + listenerActive = false + signal?.removeEventListener('abort', publishAbort) } } From 08f38235d9649f3be803b13412613d4aa0b12112 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 02:19:11 -0700 Subject: [PATCH 24/64] fix(export): preserve primary frame-loop failures (cherry picked from commit 9cf093b81410d4b176c33f1f3b3ad19c12beb0ce) --- .../export/utils/pipelined-frame-loop.test.ts | 143 ++++++++++++ .../export/utils/pipelined-frame-loop.ts | 219 +++++++++++++----- 2 files changed, 301 insertions(+), 61 deletions(-) diff --git a/src/features/export/utils/pipelined-frame-loop.test.ts b/src/features/export/utils/pipelined-frame-loop.test.ts index edae84d36..8b5a0e7c1 100644 --- a/src/features/export/utils/pipelined-frame-loop.test.ts +++ b/src/features/export/utils/pipelined-frame-loop.test.ts @@ -239,6 +239,149 @@ describe('runPipelinedFrameLoop', () => { }, ) + it('preserves a render rejection boundary over queued encode-success cleanup', async () => { + const renderError = new Error('render source rejected first') + const closeError = new Error('sample close ran before the render observer') + let closeCount = 0 + const sample: FakeSample = { + frame: 0, + closed: false, + close() { + closeCount++ + this.closed = true + throw closeError + }, + } + + const outcome = runPipelinedFrameLoop({ + totalFrames: 2, + renderFrame: (frame, reportFailure) => { + if (frame === 0) return Promise.resolve() + + // The encode-success continuation is already queued, so it will run + // sample.close() before this rejection observer. The render rejection + // is nevertheless a primary source boundary and must own the result. + const rendering = Promise.reject(renderError) + void rendering.catch(reportFailure) + return rendering + }, + captureSample: () => sample, + encodeSample: (_sample, _keyFrame, reportFailure) => { + const encoding = Promise.resolve() + void encoding.catch(reportFailure) + return encoding + }, + onAbort: () => Promise.resolve(), + onFrameProgress: () => undefined, + }).then( + () => null, + (error: unknown) => error, + ) + + const error = await outcome + expect(error).toBe(renderError) + expect(closeCount).toBe(1) + }) + + it('preserves an abort boundary over a later synchronous render throw', async () => { + const controller = new AbortController() + const renderError = new Error('render threw after abort') + const onAbort = vi.fn(() => Promise.resolve()) + + const error = await runPipelinedFrameLoop({ + totalFrames: 1, + signal: controller.signal, + renderFrame: () => { + controller.abort() + throw renderError + }, + captureSample: () => { + throw new Error('capture must not run') + }, + encodeSample: () => Promise.resolve(), + onAbort, + onFrameProgress: () => undefined, + }).then( + () => null, + (failure: unknown) => failure, + ) + + expect(error).toBeInstanceOf(DOMException) + expect((error as DOMException).name).toBe('AbortError') + expect(onAbort).toHaveBeenCalledOnce() + }) + + it('lets an already-queued render rejection observer beat a later abort publication', async () => { + const controller = new AbortController() + const renderError = new Error('render rejected before abort') + + const error = await runPipelinedFrameLoop({ + totalFrames: 1, + signal: controller.signal, + renderFrame: (_frame, reportFailure) => { + const rendering = Promise.reject(renderError) + void rendering.catch(reportFailure) + controller.abort() + return rendering + }, + captureSample: () => { + throw new Error('capture must not run') + }, + encodeSample: () => Promise.resolve(), + onAbort: () => Promise.resolve(), + onFrameProgress: () => undefined, + }).then( + () => null, + (failure: unknown) => failure, + ) + + expect(error).toBe(renderError) + }) + + it('preserves an established primary when listener removal throws', async () => { + const controller = new AbortController() + const renderError = new Error('primary render failure') + const listenerError = new Error('listener removal failed') + const removeListener = vi + .spyOn(controller.signal, 'removeEventListener') + .mockImplementation(() => { + throw listenerError + }) + + try { + const { run } = createHarness(1, { + signal: controller.signal, + renderImpl: () => { + throw renderError + }, + }) + + await expect(run()).rejects.toBe(renderError) + expect(removeListener).toHaveBeenCalledOnce() + } finally { + removeListener.mockRestore() + } + }) + + it('surfaces listener-removal failure when there is no primary failure', async () => { + const controller = new AbortController() + const listenerError = new Error('listener removal failed') + const removeListener = vi + .spyOn(controller.signal, 'removeEventListener') + .mockImplementation(() => { + throw listenerError + }) + + try { + const { run } = createHarness(1, { signal: controller.signal }) + + await expect(run()).rejects.toBe(listenerError) + expect(removeListener).toHaveBeenCalledOnce() + } finally { + removeListener.mockRestore() + } + }) + it('encodes all frames in order and closes every sample', async () => { const { events, samples, run } = createHarness(5) await run() diff --git a/src/features/export/utils/pipelined-frame-loop.ts b/src/features/export/utils/pipelined-frame-loop.ts index 0a2672561..e13a5c245 100644 --- a/src/features/export/utils/pipelined-frame-loop.ts +++ b/src/features/export/utils/pipelined-frame-loop.ts @@ -8,9 +8,9 @@ * * Behavior must stay bit-identical to the original inline loop — this is the * export hot path. Every exit drains an in-flight encode so its sample closes - * and its rejection is observed. Failures retain their occurrence order: a - * render, pending audio, or abort failure that happens first is not replaced - * by a later encode or cleanup failure, and vice versa. + * and its rejection is observed. Primary render, encode, audio, and abort + * failures retain their source-boundary order. Cleanup failures are tracked + * separately and surface only when no primary operation failed. */ export interface CloseableSample { @@ -23,26 +23,41 @@ interface RecordedFailure { export interface PipelinedFrameLoopFailureState { readonly firstFailure: RecordedFailure | null + readonly firstPrimaryFailure: RecordedFailure | null + readonly firstCleanupFailure: RecordedFailure | null reportFailure(error: unknown): void + reportCleanupFailure(error: unknown): void } /** - * Shared first-error latch for concurrently running export sources. + * Shared failure ownership for concurrently running export sources. * * Sources publish when their failure becomes observable. Promise sources must * attach their rejection observer immediately; abort publication is queued as * a microtask so same-turn promise rejection and abort events are ordered by * the source events that queued their observers, not by a synchronous abort - * listener racing ahead of already-fired promise rejections. + * listener racing ahead of already-fired promise rejections. Cleanup has its + * own first-error latch so continuation order cannot let cleanup mask a + * primary source failure. */ export function createPipelinedFrameLoopFailureState(): PipelinedFrameLoopFailureState { - let firstFailure: RecordedFailure | null = null + let firstPrimaryFailure: RecordedFailure | null = null + let firstCleanupFailure: RecordedFailure | null = null return { get firstFailure() { - return firstFailure + return firstPrimaryFailure ?? firstCleanupFailure + }, + get firstPrimaryFailure() { + return firstPrimaryFailure + }, + get firstCleanupFailure() { + return firstCleanupFailure }, reportFailure(error) { - firstFailure ??= { error } + firstPrimaryFailure ??= { error } + }, + reportCleanupFailure(error) { + firstCleanupFailure ??= { error } }, } } @@ -86,7 +101,7 @@ export interface PipelinedFrameLoopDeps { onFrameProgress: (frame: number) => void } -interface EncodeSettlement { +interface OperationSettlement { status: 'fulfilled' | 'rejected' reason?: unknown } @@ -100,13 +115,27 @@ async function encodeAndCloseSample( reportFailure: (error: unknown) => void, ) => Promise, recordSettledFailure: (error: unknown) => void, -): Promise { + recordSynchronousFailure: (error: unknown) => Promise, + recordCleanupFailure: (error: unknown) => void, +): Promise { let failure: RecordedFailure | null = null + let encoding: Promise | null = null try { - await encodeSample(sample, keyFrame, recordSettledFailure) + encoding = encodeSample(sample, keyFrame, recordSettledFailure) } catch (error) { failure = { error } - recordSettledFailure(error) + await recordSynchronousFailure(error) + } + + if (encoding) { + const settlement: OperationSettlement = await encoding.then( + (): OperationSettlement => ({ status: 'fulfilled' }), + (error: unknown): OperationSettlement => { + recordSettledFailure(error) + return { status: 'rejected', reason: error } + }, + ) + if (settlement.status === 'rejected') failure = { error: settlement.reason } } try { @@ -119,7 +148,7 @@ async function encodeAndCloseSample( // failure represented by this settlement. if (!failure) { failure = { error } - recordSettledFailure(error) + recordCleanupFailure(error) } } @@ -143,7 +172,7 @@ export async function runPipelinedFrameLoop( // The promise stored here never rejects. Encode and sample-cleanup failures // are reflected into a settlement immediately, so an encoder rejection is // observed even while renderFrame remains pending for another event turn. - let pendingEncode: Promise | null = null + let pendingEncode: Promise | null = null let abortError: DOMException | null = null let abortCleanupStarted = false let abortPublicationQueued = false @@ -153,6 +182,10 @@ export async function runPipelinedFrameLoop( failureState.reportFailure(error) } + const recordCleanupFailure = (error: unknown) => { + failureState.reportCleanupFailure(error) + } + const getAbortError = () => { abortError ??= new DOMException('Render cancelled', 'AbortError') return abortError @@ -166,7 +199,18 @@ export async function runPipelinedFrameLoop( }) } - const drainPendingEncode = async (): Promise => { + const recordSynchronousFailure = async (error: unknown) => { + if (abortPublicationQueued) { + // Abort reserves its boundary synchronously but publishes in a + // microtask. Yield once so an observer queued before the abort can + // publish first, while the abort itself stays ahead of this later + // synchronous throw. + await Promise.resolve() + } + recordFailure(error) + } + + const drainPendingEncode = async (): Promise => { if (!pendingEncode) return null const encode = pendingEncode try { @@ -182,70 +226,123 @@ export async function runPipelinedFrameLoop( try { await onAbort() } catch (error) { - recordFailure(error) + recordCleanupFailure(error) } } const throwRecordedFailureAfterDrain = async () => { + if (signal?.aborted) await runAbortCleanup() const failure = failureState.firstFailure if (!failure) return - if (signal?.aborted) await runAbortCleanup() throw failure.error } + const renderAndObserve = async (frame: number) => { + let rendering: Promise + try { + rendering = renderFrame(frame, recordFailure) + } catch (error) { + await recordSynchronousFailure(error) + throw error + } + const settlement: OperationSettlement = await rendering.then( + (): OperationSettlement => ({ status: 'fulfilled' }), + (error: unknown): OperationSettlement => { + recordFailure(error) + return { status: 'rejected', reason: error } + }, + ) + if (settlement.status === 'rejected') throw settlement.reason + } + + const isRecordedFailure = (error: unknown) => + failureState.firstPrimaryFailure?.error === error || + failureState.firstCleanupFailure?.error === error + + const drainFinalEncode = async () => { + await drainPendingEncode() + if (totalFrames > 0) await throwRecordedFailureAfterDrain() + } + signal?.addEventListener('abort', publishAbort, { once: true }) - try { - for (let frame = 0; frame < totalFrames; frame++) { - const pendingFailure = failureState.firstFailure - if (pendingFailure) throw pendingFailure.error - - // Check for abort — drain any in-flight encode first so the encoder is - // idle before we cancel the output. The first recorded failure wins, so - // this AbortError is preserved over an encoder failure during the drain. - if (signal?.aborted) { - publishAbort() - // Let reactions queued by source failures that fired before abort run - // before the queued abort publication. If abort fired first, its - // publication was queued first and remains primary. - await Promise.resolve() - await drainPendingEncode() - await throwRecordedFailureAfterDrain() - } + const runLoop = async () => { + try { + for (let frame = 0; frame < totalFrames; frame++) { + const pendingFailure = failureState.firstFailure + if (pendingFailure) throw pendingFailure.error + + // Check for abort — drain any in-flight encode first so the encoder is + // idle before we cancel the output. The first recorded failure wins, so + // this AbortError is preserved over an encoder failure during the drain. + if (signal?.aborted) { + publishAbort() + // Let reactions queued by source failures that fired before abort run + // before the queued abort publication. If abort fired first, its + // publication was queued first and remains primary. + await Promise.resolve() + await drainPendingEncode() + await throwRecordedFailureAfterDrain() + } - // Render frame first — this overlaps with the previous frame's encode - // that is still in flight. The previous sample already copied its - // pixels, so writing to the capture surface here cannot corrupt it. - await renderFrame(frame, recordFailure) + // Render frame first — this overlaps with the previous frame's encode + // that is still in flight. The previous sample already copied its + // pixels, so writing to the capture surface here cannot corrupt it. + await renderAndObserve(frame) - // Now wait for the previous encode to finish before capturing a new - // sample. This ensures at most one encode is in flight and that frames - // are fed to the encoder in order. - const previousEncode = await drainPendingEncode() - if (previousEncode?.status === 'rejected') await throwRecordedFailureAfterDrain() + // Now wait for the previous encode to finish before capturing a new + // sample. This ensures at most one encode is in flight and that frames + // are fed to the encoder in order. + const previousEncode = await drainPendingEncode() + if (previousEncode?.status === 'rejected') await throwRecordedFailureAfterDrain() - // Snapshot pixels into a sample. The capture copies pixel data - // immediately — the surface is free for the next render. - const sample = captureSample(frame) + // Snapshot pixels into a sample. The capture copies pixel data + // immediately — the surface is free for the next render. + const sample = captureSample(frame) - // Kick off encoding in the background. NOT awaited here — it runs - // concurrently with the next iteration's renderFrame(). - const isKeyFrame = frame === 0 - pendingEncode = encodeAndCloseSample(sample, isKeyFrame, encodeSample, recordFailure) + // Kick off encoding in the background. NOT awaited here — it runs + // concurrently with the next iteration's renderFrame(). + const isKeyFrame = frame === 0 + pendingEncode = encodeAndCloseSample( + sample, + isKeyFrame, + encodeSample, + recordFailure, + recordSynchronousFailure, + recordCleanupFailure, + ) - onFrameProgress(frame) + onFrameProgress(frame) + } + + // Drain the final in-flight encode before finalizing + await drainFinalEncode() + } catch (primaryError) { + if (!isRecordedFailure(primaryError)) await recordSynchronousFailure(primaryError) + await drainPendingEncode() + await throwRecordedFailureAfterDrain() + throw primaryError } + } - // Drain the final in-flight encode before finalizing - await drainPendingEncode() - if (totalFrames > 0) await throwRecordedFailureAfterDrain() - } catch (primaryError) { - recordFailure(primaryError) - await drainPendingEncode() - await throwRecordedFailureAfterDrain() - throw primaryError - } finally { - listenerActive = false + const loopSettlement: OperationSettlement = await runLoop().then( + (): OperationSettlement => ({ status: 'fulfilled' }), + (error: unknown): OperationSettlement => ({ status: 'rejected', reason: error }), + ) + + listenerActive = false + let listenerRemovalFailure: RecordedFailure | null = null + try { signal?.removeEventListener('abort', publishAbort) + } catch (error) { + listenerRemovalFailure = { error } + recordCleanupFailure(error) + } + + if (loopSettlement.status === 'rejected') { + throw failureState.firstFailure?.error ?? loopSettlement.reason + } + if (listenerRemovalFailure) { + throw failureState.firstFailure?.error ?? listenerRemovalFailure.error } } From 76f8421df35aff210e5d089be84ea98519eab57b Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 19:00:36 -0700 Subject: [PATCH 25/64] fix(timeline): enforce locked track mutation invariants (cherry picked from commit 448a00f147276c65668dc6831e77f468c69b740e) --- .../hooks/use-timeline-tracks.test.tsx | 40 ++ .../timeline/hooks/use-timeline-tracks.ts | 4 +- .../timeline/hooks/use-timeline-trim.ts | 8 + src/features/timeline/hooks/use-track-drag.ts | 1 + .../timeline/hooks/use-track-push.test.tsx | 82 ++++ src/features/timeline/hooks/use-track-push.ts | 20 +- .../actions/edit/range-removal-actions.ts | 4 + .../stores/actions/edit/trim-actions.ts | 2 + .../item-actions.lock-invariants.test.ts | 268 ++++++++++++ .../actions/item-actions.track-push.test.ts | 58 ++- .../timeline/stores/actions/item-actions.ts | 359 +++++++++++----- .../stores/actions/sync-lock-ripple.test.ts | 125 ++++++ .../stores/actions/sync-lock-ripple.ts | 389 ++++++++++++------ .../timeline/utils/track-content-drag.test.ts | 74 ++++ .../timeline/utils/track-content-drag.ts | 45 +- .../timeline/utils/track-lock-invariants.ts | 74 ++++ 16 files changed, 1311 insertions(+), 242 deletions(-) create mode 100644 src/features/timeline/hooks/use-timeline-tracks.test.tsx create mode 100644 src/features/timeline/hooks/use-track-push.test.tsx create mode 100644 src/features/timeline/stores/actions/item-actions.lock-invariants.test.ts create mode 100644 src/features/timeline/utils/track-lock-invariants.ts diff --git a/src/features/timeline/hooks/use-timeline-tracks.test.tsx b/src/features/timeline/hooks/use-timeline-tracks.test.tsx new file mode 100644 index 000000000..07f271c88 --- /dev/null +++ b/src/features/timeline/hooks/use-timeline-tracks.test.tsx @@ -0,0 +1,40 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vite-plus/test' +import { useItemsStore } from '../stores/items-store' +import { useTimelineCommandStore } from '../stores/timeline-command-store' +import { useTimelineSettingsStore } from '../stores/timeline-settings-store' +import { makeTimelineTrack } from '../test-helpers' +import { useTimelineTracks } from './use-timeline-tracks' + +describe('useTimelineTracks solo contract', () => { + beforeEach(() => { + useItemsStore.getState().setItems([]) + useItemsStore + .getState() + .setTracks([ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ]) + useTimelineCommandStore.getState().clearHistory() + useTimelineSettingsStore.setState({ isDirty: false }) + }) + + it('keeps multiple stems soloed and toggles each track independently', () => { + const { result } = renderHook(() => useTimelineTracks()) + + act(() => result.current.toggleTrackSolo('v1')) + act(() => result.current.toggleTrackSolo('a1')) + + expect(useItemsStore.getState().tracks.map(({ id, solo }) => ({ id, solo }))).toEqual([ + { id: 'v1', solo: true }, + { id: 'a1', solo: true }, + ]) + + act(() => result.current.toggleTrackSolo('v1')) + + expect(useItemsStore.getState().tracks.map(({ id, solo }) => ({ id, solo }))).toEqual([ + { id: 'v1', solo: false }, + { id: 'a1', solo: true }, + ]) + }) +}) diff --git a/src/features/timeline/hooks/use-timeline-tracks.ts b/src/features/timeline/hooks/use-timeline-tracks.ts index 93d8bf60c..ad8d3edaa 100644 --- a/src/features/timeline/hooks/use-timeline-tracks.ts +++ b/src/features/timeline/hooks/use-timeline-tracks.ts @@ -232,8 +232,8 @@ export function useTimelineTracks() { ) /** - * Toggle track solo state - * Only one track can be soloed at a time - soloing a track will unsolo all others + * Toggle one track's solo state without changing any other soloed tracks. + * Multi-track solo is additive so editors can audition several stems together. * Reads latest state to avoid stale closure bugs */ const toggleTrackSolo = useCallback( diff --git a/src/features/timeline/hooks/use-timeline-trim.ts b/src/features/timeline/hooks/use-timeline-trim.ts index 8b1b8774b..df39d87e9 100644 --- a/src/features/timeline/hooks/use-timeline-trim.ts +++ b/src/features/timeline/hooks/use-timeline-trim.ts @@ -594,6 +594,10 @@ export function useTimelineTrim( items: allItems, tracks: useItemsStore.getState().tracks, editedTrackIds, + additionalAffectedIds: new Set([ + ...synchronizedItems.map((linkedItem) => linkedItem.id), + ...linkedPreviewUpdates.map((update) => update.id), + ]), intervals: [ { start: currentItem.from + currentItem.durationInFrames + rippleShift, @@ -605,6 +609,10 @@ export function useTimelineTrim( items: allItems, tracks: useItemsStore.getState().tracks, editedTrackIds, + additionalAffectedIds: new Set([ + ...synchronizedItems.map((linkedItem) => linkedItem.id), + ...linkedPreviewUpdates.map((update) => update.id), + ]), cutFrame: currentItem.from + currentItem.durationInFrames, amount: rippleShift, }) diff --git a/src/features/timeline/hooks/use-track-drag.ts b/src/features/timeline/hooks/use-track-drag.ts index a979f9d09..21449793a 100644 --- a/src/features/timeline/hooks/use-track-drag.ts +++ b/src/features/timeline/hooks/use-track-drag.ts @@ -375,6 +375,7 @@ export function useTrackDrag(track: TimelineTrack): UseTrackDragReturn { } } else { const updates = buildTrackContentMoveUpdates({ + tracks: allTracks, sectionTrackIds: dragState.sectionTrackIds, draggedTrackIds: draggedIds, items: itemsRef.current, diff --git a/src/features/timeline/hooks/use-track-push.test.tsx b/src/features/timeline/hooks/use-track-push.test.tsx new file mode 100644 index 000000000..7754a9177 --- /dev/null +++ b/src/features/timeline/hooks/use-track-push.test.tsx @@ -0,0 +1,82 @@ +import type { MouseEvent as ReactMouseEvent } from 'react' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { useSelectionStore } from '@/shared/state/selection' +import { useItemsStore } from '../stores/items-store' +import { useTimelineSettingsStore } from '../stores/timeline-settings-store' +import { useTrackPushPreviewStore } from '../stores/track-push-preview-store' +import { makeTimelineAudioItem, makeTimelineTrack, makeTimelineVideoItem } from '../test-helpers' +import { useTrackPush } from './use-track-push' + +function makeMouseEvent(): ReactMouseEvent { + return { + button: 0, + clientX: 100, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as ReactMouseEvent +} + +describe('useTrackPush lock preview', () => { + beforeEach(() => { + useItemsStore.getState().setItems([]) + useItemsStore.getState().setTracks([]) + useTimelineSettingsStore.setState({ fps: 30, snapEnabled: false }) + useTrackPushPreviewStore.getState().clearPreview() + useSelectionStore.getState().setDragState(null) + useSelectionStore.getState().setActiveSnapTarget(null) + }) + + it('previews eligible unlocked items without moving standalone locked-track items', () => { + const video = makeTimelineVideoItem({ id: 'video', from: 30 }) + const lockedAudio = makeTimelineAudioItem({ id: 'audio', from: 30 }) + useItemsStore.getState().setTracks([ + makeTimelineTrack({ id: 'track-v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ + id: 'track-a1', + name: 'A1', + kind: 'audio', + order: 1, + locked: true, + }), + ]) + useItemsStore.getState().setItems([video, lockedAudio]) + const { result } = renderHook(() => useTrackPush(video, 10)) + + act(() => result.current.handleTrackPushStart(makeMouseEvent())) + + expect(result.current.isTrackPushActive).toBe(true) + expect([...useTrackPushPreviewStore.getState().shiftedItemIds]).toEqual([video.id]) + }) + + it('does not start or create a preview when the anchor has a locked linked companion', () => { + const video = makeTimelineVideoItem({ + id: 'video', + from: 30, + linkedGroupId: 'linked-av', + }) + const audio = makeTimelineAudioItem({ + id: 'audio', + from: 30, + linkedGroupId: 'linked-av', + }) + useItemsStore.getState().setTracks([ + makeTimelineTrack({ id: 'track-v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ + id: 'track-a1', + name: 'A1', + kind: 'audio', + order: 1, + locked: true, + }), + ]) + useItemsStore.getState().setItems([video, audio]) + const { result } = renderHook(() => useTrackPush(video, 10)) + + act(() => result.current.handleTrackPushStart(makeMouseEvent())) + + expect(result.current.isTrackPushActive).toBe(false) + expect(useTrackPushPreviewStore.getState().anchorItemId).toBeNull() + expect(useSelectionStore.getState().dragState).toBeNull() + }) +}) diff --git a/src/features/timeline/hooks/use-track-push.ts b/src/features/timeline/hooks/use-track-push.ts index 6219733c9..ce1456b52 100644 --- a/src/features/timeline/hooks/use-track-push.ts +++ b/src/features/timeline/hooks/use-track-push.ts @@ -10,6 +10,7 @@ import { useSnapCalculator } from './use-snap-calculator' import { trackPushItems } from '../stores/actions/item-actions' import type { SnapTarget } from '../types/drag' import { setActiveSnapTargetIfChanged } from '../utils/snap-target-state' +import { partitionItemMutationIdsByLock } from '../utils/track-lock-invariants' interface TrackPushState { isActive: boolean @@ -145,16 +146,19 @@ export function useTrackPush( e.preventDefault() commitPreviewFrameToCurrentFrame() - const { items: allItems, itemsByTrackId } = useItemsStore.getState() + const { items: allItems, itemsByTrackId, tracks } = useItemsStore.getState() const cutFrame = item.from - // Collect ALL items at or after the anchor's position, across every track - const shiftedIds = new Set() - for (const ti of allItems) { - if (ti.from >= cutFrame) { - shiftedIds.add(ti.id) - } - } + // Locked tracks stay fixed. If one proposed item belongs to a linked + // cohort with a locked companion, reject the gesture instead of + // previewing an A/V desync that the commit cannot accept. + const mutationPartition = partitionItemMutationIdsByLock({ + items: allItems, + tracks, + itemIds: allItems.filter((candidate) => candidate.from >= cutFrame).map(({ id }) => id), + }) + const shiftedIds = new Set(mutationPartition.allowedIds) + if (mutationPartition.blockedByLockedLinkedCohort || !shiftedIds.has(item.id)) return // Compute the tightest gap across all tracks. // Per track, find the first shifted item and the last non-shifted item diff --git a/src/features/timeline/stores/actions/edit/range-removal-actions.ts b/src/features/timeline/stores/actions/edit/range-removal-actions.ts index 30d0b54b3..6bd40556c 100644 --- a/src/features/timeline/stores/actions/edit/range-removal-actions.ts +++ b/src/features/timeline/stores/actions/edit/range-removal-actions.ts @@ -152,6 +152,10 @@ function applyRippleRemoval(ids: string[]): { removedIds: string[]; affectedIds: const syncLockResult = propagateRemovedIntervalsToSyncLockedTracks({ editedTrackIds, intervals: removedIntervals, + additionalAffectedIds: new Set([ + ...allRemoveIds, + ...filteredUpdates.map((update) => update.id), + ]), }) const cascadedRemoveIds = Array.from(new Set([...allRemoveIds, ...syncLockResult.removedIds])) diff --git a/src/features/timeline/stores/actions/edit/trim-actions.ts b/src/features/timeline/stores/actions/edit/trim-actions.ts index e2f4bd421..81391ec0b 100644 --- a/src/features/timeline/stores/actions/edit/trim-actions.ts +++ b/src/features/timeline/stores/actions/edit/trim-actions.ts @@ -415,6 +415,7 @@ export function rippleTrimItem(id: string, handle: 'start' | 'end', trimDelta: n const result = propagateRemovedIntervalsToSyncLockedTracks({ editedTrackIds: editedTracks, intervals: [interval], + additionalAffectedIds: new Set([...syncedIds, ...updates.map((update) => update.id)]), }) lockedAffected = result.affectedIds lockedRemoved = result.removedIds @@ -423,6 +424,7 @@ export function rippleTrimItem(id: string, handle: 'start' | 'end', trimDelta: n editedTrackIds: editedTracks, cutFrame: insertAt, amount: shift, + additionalAffectedIds: new Set([...syncedIds, ...updates.map((update) => update.id)]), }) lockedAffected = result.affectedIds } diff --git a/src/features/timeline/stores/actions/item-actions.lock-invariants.test.ts b/src/features/timeline/stores/actions/item-actions.lock-invariants.test.ts new file mode 100644 index 000000000..482115da9 --- /dev/null +++ b/src/features/timeline/stores/actions/item-actions.lock-invariants.test.ts @@ -0,0 +1,268 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it } from 'vite-plus/test' +import type { AudioItem, TimelineTrack, VideoItem } from '@/types/timeline' +import { useEditorStore } from '@/shared/state/editor' +import { useItemsStore } from '../items-store' +import { useKeyframesStore } from '../keyframes-store' +import { useTimelineCommandStore } from '../timeline-command-store' +import { useTimelineSettingsStore } from '../timeline-settings-store' +import { useTransitionsStore } from '../transitions-store' +import { + closeAllGapsOnTrack, + closeGapAtPosition, + moveItem, + moveItems, + removeItems, + rippleDeleteItems, + unlinkItems, + updateItem, +} from './item-actions' + +function makeTrack( + overrides: Partial & Pick, +): TimelineTrack { + return { + height: 80, + locked: false, + syncLock: true, + visible: true, + muted: false, + solo: false, + volume: 0, + items: [], + ...overrides, + } +} + +function makeVideoItem(overrides: Partial = {}): VideoItem { + return { + id: 'video-1', + type: 'video', + trackId: 'video-track', + from: 0, + durationInFrames: 60, + label: 'clip.mp4', + src: 'blob:video', + mediaId: 'media-1', + sourceStart: 10, + sourceEnd: 70, + sourceDuration: 120, + sourceFps: 30, + ...overrides, + } +} + +function makeAudioItem(overrides: Partial = {}): AudioItem { + return { + id: 'audio-1', + type: 'audio', + trackId: 'audio-track', + from: 0, + durationInFrames: 60, + label: 'clip.wav', + src: 'blob:audio', + mediaId: 'media-1', + sourceStart: 10, + sourceEnd: 70, + sourceDuration: 120, + sourceFps: 30, + ...overrides, + } +} + +function expectNoHistory(): void { + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + expect(useTimelineSettingsStore.getState().isDirty).toBe(false) +} + +describe('track lock mutation invariants', () => { + beforeEach(() => { + useEditorStore.setState({ linkedSelectionEnabled: true }) + useItemsStore.getState().setItems([]) + useItemsStore.getState().setTracks([]) + useTransitionsStore.getState().setTransitions([]) + useKeyframesStore.getState().setKeyframes([]) + useTimelineCommandStore.getState().clearHistory() + useTimelineSettingsStore.setState({ fps: 30, isDirty: false }) + }) + + it('rejects direct timing, track, source-placement, and delete mutations on a locked item', () => { + const lockedTrack = makeTrack({ + id: 'video-track', + name: 'V1', + kind: 'video', + order: 0, + locked: true, + }) + const otherTrack = makeTrack({ + id: 'video-track-2', + name: 'V2', + kind: 'video', + order: 1, + }) + const original = makeVideoItem() + useItemsStore.getState().setTracks([lockedTrack, otherTrack]) + useItemsStore.getState().setItems([original]) + + updateItem(original.id, { + from: 20, + durationInFrames: 30, + trackId: otherTrack.id, + sourceStart: 40, + sourceEnd: 70, + }) + moveItem(original.id, 30, otherTrack.id) + removeItems([original.id]) + + expect(useItemsStore.getState().itemById[original.id]).toEqual(original) + expectNoHistory() + }) + + it('rejects plain and ripple delete atomically when a linked companion is locked', () => { + const videoTrack = makeTrack({ + id: 'video-track', + name: 'V1', + kind: 'video', + order: 0, + }) + const audioTrack = makeTrack({ + id: 'audio-track', + name: 'A1', + kind: 'audio', + order: 1, + locked: true, + }) + const video = makeVideoItem({ linkedGroupId: 'linked-av' }) + const audio = makeAudioItem({ linkedGroupId: 'linked-av' }) + useItemsStore.getState().setTracks([videoTrack, audioTrack]) + useItemsStore.getState().setItems([video, audio]) + + removeItems([video.id]) + rippleDeleteItems([video.id]) + + expect(useItemsStore.getState().items).toEqual([video, audio]) + expectNoHistory() + }) + + it('requires explicit unlink before deleting away from a locked companion', () => { + useEditorStore.setState({ linkedSelectionEnabled: false }) + useItemsStore.getState().setTracks([ + makeTrack({ id: 'video-track', name: 'V1', kind: 'video', order: 0 }), + makeTrack({ + id: 'audio-track', + name: 'A1', + kind: 'audio', + order: 1, + locked: true, + }), + ]) + const video = makeVideoItem({ linkedGroupId: 'linked-av' }) + const audio = makeAudioItem({ linkedGroupId: 'linked-av' }) + useItemsStore.getState().setItems([video, audio]) + + removeItems([video.id]) + expect(useItemsStore.getState().items).toHaveLength(2) + expectNoHistory() + + unlinkItems([video.id]) + removeItems([video.id]) + + expect(useItemsStore.getState().itemById[video.id]).toBeUndefined() + expect(useItemsStore.getState().itemById[audio.id]).toBeDefined() + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(2) + }) + + it('allows ripple delete on unlocked tracks while a locked sync-lock track stays byte-for-byte fixed', () => { + const videoTrack = makeTrack({ + id: 'video-track', + name: 'V1', + kind: 'video', + order: 0, + }) + const lockedAudioTrack = makeTrack({ + id: 'audio-track', + name: 'A1', + kind: 'audio', + order: 1, + locked: true, + syncLock: true, + }) + const deleted = makeVideoItem({ id: 'delete', durationInFrames: 30 }) + const downstream = makeVideoItem({ + id: 'downstream', + from: 50, + durationInFrames: 20, + mediaId: 'media-2', + }) + const lockedBed = makeAudioItem({ + id: 'locked-bed', + from: 0, + durationInFrames: 100, + sourceStart: 20, + sourceEnd: 120, + sourceDuration: 180, + }) + useItemsStore.getState().setTracks([videoTrack, lockedAudioTrack]) + useItemsStore.getState().setItems([deleted, downstream, lockedBed]) + + rippleDeleteItems([deleted.id]) + + expect(useItemsStore.getState().itemById[deleted.id]).toBeUndefined() + expect(useItemsStore.getState().itemById[downstream.id]).toMatchObject({ from: 20 }) + expect(useItemsStore.getState().itemById[lockedBed.id]).toEqual(lockedBed) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) + + it('rejects close-gap commands on a locked track without history', () => { + const lockedTrack = makeTrack({ + id: 'video-track', + name: 'V1', + kind: 'video', + order: 0, + locked: true, + }) + const first = makeVideoItem({ id: 'first', durationInFrames: 30 }) + const second = makeVideoItem({ id: 'second', from: 60, durationInFrames: 30 }) + useItemsStore.getState().setTracks([lockedTrack]) + useItemsStore.getState().setItems([first, second]) + + closeGapAtPosition(lockedTrack.id, 45) + closeAllGapsOnTrack(lockedTrack.id) + + expect(useItemsStore.getState().items).toEqual([first, second]) + expectNoHistory() + }) + + it('rejects close-gap and bulk-move plans that would peel away from a locked linked companion', () => { + const videoTrack = makeTrack({ + id: 'video-track', + name: 'V1', + kind: 'video', + order: 0, + }) + const audioTrack = makeTrack({ + id: 'audio-track', + name: 'A1', + kind: 'audio', + order: 1, + locked: true, + }) + const anchor = makeVideoItem({ id: 'anchor', durationInFrames: 30 }) + const video = makeVideoItem({ id: 'linked-video', from: 60, linkedGroupId: 'linked-av' }) + const audio = makeAudioItem({ id: 'linked-audio', from: 60, linkedGroupId: 'linked-av' }) + useItemsStore.getState().setTracks([videoTrack, audioTrack]) + useItemsStore.getState().setItems([anchor, video, audio]) + + closeGapAtPosition(videoTrack.id, 45) + closeAllGapsOnTrack(videoTrack.id) + moveItems([ + { id: video.id, from: 10 }, + { id: audio.id, from: 10 }, + ]) + + expect(useItemsStore.getState().itemById[video.id]).toEqual(video) + expect(useItemsStore.getState().itemById[audio.id]).toEqual(audio) + expectNoHistory() + }) +}) diff --git a/src/features/timeline/stores/actions/item-actions.track-push.test.ts b/src/features/timeline/stores/actions/item-actions.track-push.test.ts index 150feec07..f57764bfe 100644 --- a/src/features/timeline/stores/actions/item-actions.track-push.test.ts +++ b/src/features/timeline/stores/actions/item-actions.track-push.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { beforeEach, describe, expect, it } from 'vite-plus/test' -import type { AudioItem, VideoItem } from '@/types/timeline' +import type { AudioItem, TimelineTrack, VideoItem } from '@/types/timeline' import { useItemsStore } from '../items-store' import { useTimelineCommandStore } from '../timeline-command-store' import { useTimelineSettingsStore } from '../timeline-settings-store' @@ -36,6 +36,22 @@ function makeAudioItem(overrides: Partial = {}): AudioItem { } } +function makeTrack( + overrides: Partial & Pick, +): TimelineTrack { + return { + height: 80, + locked: false, + syncLock: true, + visible: true, + muted: false, + solo: false, + volume: 0, + items: [], + ...overrides, + } +} + describe('trackPushItems', () => { beforeEach(() => { useTimelineCommandStore.getState().clearHistory() @@ -112,4 +128,44 @@ describe('trackPushItems', () => { expect(reverted.find((i) => i.id === 'v2')).toMatchObject({ from: 100 }) expect(reverted.find((i) => i.id === 'a1')).toMatchObject({ from: 50 }) }) + + it('pushes eligible unlocked tracks while leaving standalone locked-track items fixed', () => { + useItemsStore + .getState() + .setTracks([ + makeTrack({ id: 'video-track', name: 'V1', order: 0, kind: 'video' }), + makeTrack({ id: 'audio-track', name: 'A1', order: 1, kind: 'audio', locked: true }), + ]) + const video = makeVideoItem({ id: 'v1', from: 50, durationInFrames: 30 }) + const lockedAudio = makeAudioItem({ id: 'a1', from: 50, durationInFrames: 30 }) + useItemsStore.getState().setItems([video, lockedAudio]) + + trackPushItems(video.id, 20) + + expect(useItemsStore.getState().itemById[video.id]).toMatchObject({ from: 70 }) + expect(useItemsStore.getState().itemById[lockedAudio.id]).toEqual(lockedAudio) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + + useTimelineCommandStore.getState().undo() + expect(useItemsStore.getState().itemById[video.id]).toMatchObject({ from: 50 }) + expect(useItemsStore.getState().itemById[lockedAudio.id]).toEqual(lockedAudio) + }) + + it('rejects a push atomically when the anchor has a locked linked companion', () => { + useItemsStore + .getState() + .setTracks([ + makeTrack({ id: 'video-track', name: 'V1', order: 0, kind: 'video' }), + makeTrack({ id: 'audio-track', name: 'A1', order: 1, kind: 'audio', locked: true }), + ]) + const video = makeVideoItem({ id: 'v1', from: 50, linkedGroupId: 'linked-av' }) + const audio = makeAudioItem({ id: 'a1', from: 50, linkedGroupId: 'linked-av' }) + useItemsStore.getState().setItems([video, audio]) + + trackPushItems(video.id, 20) + + expect(useItemsStore.getState().items).toEqual([video, audio]) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + expect(useTimelineSettingsStore.getState().isDirty).toBe(false) + }) }) diff --git a/src/features/timeline/stores/actions/item-actions.ts b/src/features/timeline/stores/actions/item-actions.ts index 0f2e15210..1c1a28729 100644 --- a/src/features/timeline/stores/actions/item-actions.ts +++ b/src/features/timeline/stores/actions/item-actions.ts @@ -52,11 +52,63 @@ import { wouldCreateTransformParentCycle, } from '@/shared/utils/transform-parenting' import { createDefaultControllerItem } from '../../utils/generated-layer-items' +import { + isTimelineTrackLocked, + partitionItemMutationIdsByLock, +} from '../../utils/track-lock-invariants' + +const LOCK_PROTECTED_ITEM_FIELDS = new Set([ + 'from', + 'durationInFrames', + 'trackId', + 'trimStart', + 'trimEnd', + 'sourceStart', + 'sourceEnd', + 'sourceDuration', + 'sourceFps', + 'speed', + 'offset', + 'isReversed', + 'reverseConformLocalStart', +]) function isLinkedSelectionEnabled(): boolean { return useEditorStore.getState().linkedSelectionEnabled } +function changesLockedItemPlacement(item: TimelineItem, updates: Partial): boolean { + const updateRecord = updates as Record + const itemRecord = item as unknown as Record + return Object.keys(updateRecord).some( + (key) => LOCK_PROTECTED_ITEM_FIELDS.has(key) && updateRecord[key] !== itemRecord[key], + ) +} + +function areItemMutationsUnlocked(itemIds: Iterable): boolean { + const { items, tracks } = useItemsStore.getState() + return partitionItemMutationIdsByLock({ items, tracks, itemIds }).blockedIds.length === 0 +} + +function areMoveUpdatesUnlocked( + updates: Array<{ id: string; from: number; trackId?: string }>, + destinationTracks: TimelineTrack[] = useItemsStore.getState().tracks, +): boolean { + if (updates.length === 0) return false + + const { items, tracks } = useItemsStore.getState() + const partition = partitionItemMutationIdsByLock({ + items, + tracks, + itemIds: updates.map((update) => update.id), + }) + if (partition.blockedIds.length > 0) return false + + return updates.every( + (update) => !update.trackId || !isTimelineTrackLocked(destinationTracks, update.trackId), + ) +} + function pruneLayerGroupsAfterItemRemoval(): void { const store = useItemsStore.getState() const nextTracks = pruneEmptyLayerGroupHierarchy(store.tracks, store.items) @@ -106,18 +158,18 @@ function isInvalidTransformParentUpdate( if (!child || !canParticipateInTransformHierarchy(child)) return true return Boolean( context.parentItemId && - (wouldCreateTransformParentCycle( - childItemId, - context.parentItemId, - context.getItem, - context.getKeyframes, - ) || - hasRedundantTransformParentLink( - childItemId, - context.parentItemId, - context.getItem, - context.getKeyframes, - )), + (wouldCreateTransformParentCycle( + childItemId, + context.parentItemId, + context.getItem, + context.getKeyframes, + ) || + hasRedundantTransformParentLink( + childItemId, + context.parentItemId, + context.getItem, + context.getKeyframes, + )), ) } @@ -715,6 +767,17 @@ export function addItemsOnNewTracks(items: TimelineItem[], tracks: TimelineTrack } export function updateItem(id: string, updates: Partial): void { + const item = useItemsStore.getState().itemById[id] + if (!item) return + if (changesLockedItemPlacement(item, updates) && !areItemMutationsUnlocked([id])) return + if ( + updates.trackId && + updates.trackId !== item.trackId && + isTimelineTrackLocked(useItemsStore.getState().tracks, updates.trackId) + ) { + return + } + execute( 'UPDATE_ITEM', () => { @@ -829,6 +892,7 @@ export function reverseItems(ids: string[]): void { ) if (reversibleItems.length === 0) return + if (!areItemMutationsUnlocked(reversibleItems.map((item) => item.id))) return const shouldReverse = !reversibleItems.every((item) => item.isReversed === true) if (shouldReverse) { const videoItems = reversibleItems.filter((item) => item.type === 'video') @@ -875,6 +939,7 @@ export function commitPreparedReverseItems( results: ReverseConformResult[], ): void { if (items.length === 0) return + if (!areItemMutationsUnlocked(items.map((item) => item.id))) return const resultByItemId = new Map(results.map((result) => [result.itemId, result])) execute( @@ -913,159 +978,230 @@ export function commitPreparedReverseItems( } export function removeItems(ids: string[]): void { - const expandedIds = expandIdsWithLinkedItems( - useItemsStore.getState().items, - ids, - isLinkedSelectionEnabled(), - ) - if (expandedIds.length === 0) return + const { items, tracks } = useItemsStore.getState() + const expandedIds = expandIdsWithLinkedItems(items, ids, isLinkedSelectionEnabled()) + const { allowedIds } = partitionItemMutationIdsByLock({ + items, + tracks, + itemIds: expandedIds, + }) + if (allowedIds.length === 0) return execute( 'REMOVE_ITEMS', () => { // Remove items - useItemsStore.getState()._removeItems(expandedIds) + useItemsStore.getState()._removeItems(allowedIds) // Cascade: Remove transitions referencing deleted items - useTransitionsStore.getState()._removeTransitionsForItems(expandedIds) + useTransitionsStore.getState()._removeTransitionsForItems(allowedIds) // Cascade: Remove keyframes for deleted items - useKeyframesStore.getState()._removeKeyframesForItems(expandedIds) + useKeyframesStore.getState()._removeKeyframesForItems(allowedIds) pruneLayerGroupsAfterItemRemoval() useTimelineSettingsStore.getState().markDirty() }, - { ids: expandedIds }, + { ids: allowedIds }, ) emitUiSound('delete') } -export function rippleDeleteItems(ids: string[]): void { - const items = useItemsStore.getState().items - const linkedSelectionEnabled = isLinkedSelectionEnabled() - const expandedIds = expandIdsWithLinkedItems(items, ids, linkedSelectionEnabled) - if (expandedIds.length === 0) return +type RippleMoveUpdate = { id: string; from: number } + +interface RippleDeletePlan { + allRemoveIds: string[] + editedTrackIds: Set + filteredUpdates: RippleMoveUpdate[] + removedIntervals: Array<{ start: number; end: number }> + updates: RippleMoveUpdate[] +} - const idsToDelete = new Set(expandedIds) - const remainingItems = items.filter((item) => !idsToDelete.has(item.id)) +function buildBaseRippleShifts( + remainingItems: TimelineItem[], + deletedItems: TimelineItem[], +): Map { const baseShiftByItemId = new Map() - const editedTrackIds = new Set( - items.filter((item) => idsToDelete.has(item.id)).map((item) => item.trackId), - ) - const removedIntervals = items - .filter((item) => idsToDelete.has(item.id)) - .map((item) => ({ - start: item.from, - end: item.from + item.durationInFrames, - })) - - // Per-track: shift downstream items on the same track as each deleted item. - // Linked counterparts and attached captions on tracks that won't be handled - // by sync-lock ripple get shifted manually. Solo clips on unrelated tracks - // are left in place. - for (const item of remainingItems) { - const shiftAmount = items - .filter((candidate) => idsToDelete.has(candidate.id)) - .filter( - (deletedItem) => - deletedItem.trackId === item.trackId && - deletedItem.from + deletedItem.durationInFrames <= item.from, - ) - .reduce((sum, deletedItem) => sum + deletedItem.durationInFrames, 0) - if (shiftAmount > 0) { - baseShiftByItemId.set(item.id, shiftAmount) + for (const item of remainingItems) { + let shiftAmount = 0 + for (const deletedItem of deletedItems) { + if ( + deletedItem.trackId === item.trackId && + deletedItem.from + deletedItem.durationInFrames <= item.from + ) { + shiftAmount += deletedItem.durationInFrames + } } + if (shiftAmount > 0) baseShiftByItemId.set(item.id, shiftAmount) } - const trackById = new Map(useItemsStore.getState().tracks.map((track) => [track.id, track])) - const itemById = new Map(remainingItems.map((item) => [item.id, item])) - const shiftByItemId = new Map() + return baseShiftByItemId +} - for (const [itemId, shiftAmount] of baseShiftByItemId) { - if (shiftAmount <= 0) continue +function buildRippleMoveUpdates(params: { + remainingItems: TimelineItem[] + tracks: TimelineTrack[] + editedTrackIds: Set + baseShiftByItemId: ReadonlyMap + linkedSelectionEnabled: boolean +}): RippleMoveUpdate[] { + const trackById = new Map(params.tracks.map((track) => [track.id, track])) + const itemById = new Map(params.remainingItems.map((item) => [item.id, item])) + const shiftByItemId = new Map() - const relatedIds = expandIdsWithLinkedItems(remainingItems, [itemId], linkedSelectionEnabled) + for (const [itemId, shiftAmount] of params.baseShiftByItemId) { + const relatedIds = expandIdsWithLinkedItems( + params.remainingItems, + [itemId], + params.linkedSelectionEnabled, + ) for (const relatedId of relatedIds) { const relatedItem = itemById.get(relatedId) if (!relatedItem) continue const handledBySyncLock = - !editedTrackIds.has(relatedItem.trackId) && + !params.editedTrackIds.has(relatedItem.trackId) && isTrackSyncLockEnabled(trackById.get(relatedItem.trackId)) - if (handledBySyncLock) { - continue - } + if (handledBySyncLock) continue shiftByItemId.set(relatedId, Math.max(shiftByItemId.get(relatedId) ?? 0, shiftAmount)) } } - const updates = remainingItems.flatMap((item) => { + return params.remainingItems.flatMap((item) => { const shiftAmount = shiftByItemId.get(item.id) ?? 0 return shiftAmount > 0 ? [{ id: item.id, from: item.from - shiftAmount }] : [] }) +} - // Detect non-shifted items that would be overlapped by shifted items. - // These get deleted rather than creating overlaps. - const shiftedById = new Map(updates.map((u) => [u.id, u.from])) +function findItemsCoveredByRippleMove( + remainingItems: TimelineItem[], + updates: RippleMoveUpdate[], +): string[] { + const shiftedById = new Map(updates.map((update) => [update.id, update.from])) const coveredIds: string[] = [] + for (const item of remainingItems) { - if (shiftedById.has(item.id) || idsToDelete.has(item.id)) continue + if (shiftedById.has(item.id)) continue const itemEnd = item.from + item.durationInFrames - // Check if any shifted item on the same track would overlap this item - for (const other of remainingItems) { + + const isCovered = remainingItems.some((other) => { const newFrom = shiftedById.get(other.id) - if (newFrom === undefined || other.trackId !== item.trackId) continue - const newEnd = newFrom + other.durationInFrames - if (newFrom < itemEnd && newEnd > item.from) { - coveredIds.push(item.id) - break - } - } + if (newFrom === undefined || other.trackId !== item.trackId) return false + return newFrom < itemEnd && newFrom + other.durationInFrames > item.from + }) + if (isCovered) coveredIds.push(item.id) } - // Expand covered IDs with linked companions so we don't orphan them + return coveredIds +} + +function buildRippleDeletePlan(params: { + items: TimelineItem[] + tracks: TimelineTrack[] + deletionIds: string[] + linkedSelectionEnabled: boolean +}): RippleDeletePlan | null { + const idsToDelete = new Set(params.deletionIds) + const deletedItems = params.items.filter((item) => idsToDelete.has(item.id)) + const remainingItems = params.items.filter((item) => !idsToDelete.has(item.id)) + const editedTrackIds = new Set(deletedItems.map((item) => item.trackId)) + const removedIntervals = deletedItems.map((item) => ({ + start: item.from, + end: item.from + item.durationInFrames, + })) + const baseShiftByItemId = buildBaseRippleShifts(remainingItems, deletedItems) + const updates = buildRippleMoveUpdates({ + remainingItems, + tracks: params.tracks, + editedTrackIds, + baseShiftByItemId, + linkedSelectionEnabled: params.linkedSelectionEnabled, + }) + + const updatePartition = partitionItemMutationIdsByLock({ + items: remainingItems, + tracks: params.tracks, + itemIds: updates.map((update) => update.id), + }) + if (updatePartition.blockedIds.length > 0) return null + + const coveredIds = findItemsCoveredByRippleMove(remainingItems, updates) const expandedCoveredIds = expandIdsWithLinkedItems( remainingItems, coveredIds, - linkedSelectionEnabled, + params.linkedSelectionEnabled, ) - const allRemoveIds = [...expandedIds, ...expandedCoveredIds] + const coveredPartition = partitionItemMutationIdsByLock({ + items: remainingItems, + tracks: params.tracks, + itemIds: expandedCoveredIds, + }) + if (coveredPartition.blockedIds.length > 0) return null + + const coveredSet = new Set(coveredPartition.allowedIds) + return { + allRemoveIds: Array.from(new Set([...params.deletionIds, ...coveredSet])), + editedTrackIds, + filteredUpdates: updates.filter((update) => !coveredSet.has(update.id)), + removedIntervals, + updates, + } +} - // Filter out updates for items that were removed as covered (including their linked companions) - const coveredSet = new Set(expandedCoveredIds) - const filteredUpdates = - coveredSet.size > 0 ? updates.filter((u) => !coveredSet.has(u.id)) : updates +export function rippleDeleteItems(ids: string[]): void { + const { items, tracks } = useItemsStore.getState() + const linkedSelectionEnabled = isLinkedSelectionEnabled() + const expandedIds = expandIdsWithLinkedItems(items, ids, linkedSelectionEnabled) + const deletionPartition = partitionItemMutationIdsByLock({ + items, + tracks, + itemIds: expandedIds, + }) + if (deletionPartition.allowedIds.length === 0) return + const plan = buildRippleDeletePlan({ + items, + tracks, + deletionIds: deletionPartition.allowedIds, + linkedSelectionEnabled, + }) + if (!plan) return execute( 'RIPPLE_DELETE_ITEMS', () => { - useItemsStore.getState()._removeItems(allRemoveIds) - if (filteredUpdates.length > 0) { - useItemsStore.getState()._moveItems(filteredUpdates) + useItemsStore.getState()._removeItems(plan.allRemoveIds) + if (plan.filteredUpdates.length > 0) { + useItemsStore.getState()._moveItems(plan.filteredUpdates) } const syncLockResult = propagateRemovedIntervalsToSyncLockedTracks({ - editedTrackIds, - intervals: removedIntervals, + editedTrackIds: plan.editedTrackIds, + intervals: plan.removedIntervals, + additionalAffectedIds: new Set([ + ...plan.allRemoveIds, + ...plan.filteredUpdates.map((update) => update.id), + ]), }) // Cascade: Remove transitions and keyframes - const cascadedRemoveIds = Array.from(new Set([...allRemoveIds, ...syncLockResult.removedIds])) + const cascadedRemoveIds = Array.from( + new Set([...plan.allRemoveIds, ...syncLockResult.removedIds]), + ) useTransitionsStore.getState()._removeTransitionsForItems(cascadedRemoveIds) useKeyframesStore.getState()._removeKeyframesForItems(cascadedRemoveIds) // Repair transitions on moved clips (they may now overlap or gap differently) - if (filteredUpdates.length > 0) { - applyTransitionRepairs(filteredUpdates.map((u) => u.id)) + if (plan.filteredUpdates.length > 0) { + applyTransitionRepairs(plan.filteredUpdates.map((update) => update.id)) } // Repair transitions for surviving clips that were shifted const repairedClipIds = Array.from( - new Set([...updates.map((update) => update.id), ...syncLockResult.affectedIds]), + new Set([...plan.updates.map((update) => update.id), ...syncLockResult.affectedIds]), ) if (repairedClipIds.length > 0) { applyTransitionRepairs(repairedClipIds, new Set(cascadedRemoveIds)) @@ -1075,12 +1211,14 @@ export function rippleDeleteItems(ids: string[]): void { useTimelineSettingsStore.getState().markDirty() }, - { ids: allRemoveIds }, + { ids: plan.allRemoveIds }, ) } export function closeGapAtPosition(trackId: string, frame: number): void { - const items = useItemsStore.getState().items + const { items, tracks } = useItemsStore.getState() + if (isTimelineTrackLocked(tracks, trackId)) return + const targetFrame = Math.max(0, Math.round(frame)) const trackItems = items .filter((item) => item.trackId === trackId) @@ -1106,6 +1244,7 @@ export function closeGapAtPosition(trackId: string, frame: number): void { .filter((item) => item.trackId === trackId && item.from >= gapEnd) .map((item) => ({ id: item.id, from: item.from - gapSize })) if (updates.length === 0) return + if (!areMoveUpdatesUnlocked(updates)) return execute( 'CLOSE_GAP', @@ -1114,6 +1253,7 @@ export function closeGapAtPosition(trackId: string, frame: number): void { const syncLockResult = propagateRemovedIntervalsToSyncLockedTracks({ editedTrackIds: new Set([trackId]), intervals: [{ start: gapStart, end: gapEnd }], + additionalAffectedIds: new Set(updates.map((update) => update.id)), }) const removedIds = syncLockResult.removedIds @@ -1134,7 +1274,9 @@ export function closeGapAtPosition(trackId: string, frame: number): void { } export function closeAllGapsOnTrack(trackId: string): void { - const items = useItemsStore.getState().items + const { items, tracks } = useItemsStore.getState() + if (isTimelineTrackLocked(tracks, trackId)) return + const trackItems = items .filter((item) => item.trackId === trackId) .sort((left, right) => left.from - right.from) @@ -1154,6 +1296,7 @@ export function closeAllGapsOnTrack(trackId: string): void { const updates = buildLinkedLeftShiftUpdates(items, baseShiftByItemId, isLinkedSelectionEnabled()) if (updates.length === 0) return + if (!areMoveUpdatesUnlocked(updates)) return execute( 'CLOSE_ALL_GAPS', @@ -1175,16 +1318,26 @@ export function closeAllGapsOnTrack(trackId: string): void { export function trackPushItems(anchorId: string, delta: number): void { if (delta === 0) return - const items = useItemsStore.getState().items + const { items, tracks } = useItemsStore.getState() const anchor = items.find((i) => i.id === anchorId) if (!anchor) return + if (!areItemMutationsUnlocked([anchor.id])) return const cutFrame = anchor.from // Every item whose start is at or after the cut frame gets shifted + const candidateIds = items.filter((item) => item.from >= cutFrame).map((item) => item.id) + const mutationPartition = partitionItemMutationIdsByLock({ + items, + tracks, + itemIds: candidateIds, + }) + if (mutationPartition.blockedByLockedLinkedCohort) return + + const eligibleIds = new Set(mutationPartition.allowedIds) const updates: Array<{ id: string; from: number }> = [] for (const ti of items) { - if (ti.from >= cutFrame) { + if (eligibleIds.has(ti.id)) { updates.push({ id: ti.id, from: Math.max(0, ti.from + delta) }) } } @@ -1203,6 +1356,10 @@ export function trackPushItems(anchorId: string, delta: number): void { } export function moveItem(id: string, newFrom: number, newTrackId?: string): void { + const item = useItemsStore.getState().itemById[id] + if (!item) return + if (!areMoveUpdatesUnlocked([{ id, from: newFrom, trackId: newTrackId }])) return + execute( 'MOVE_ITEM', () => { @@ -1219,6 +1376,8 @@ export function moveItem(id: string, newFrom: number, newTrackId?: string): void } export function moveItems(updates: Array<{ id: string; from: number; trackId?: string }>): void { + if (!areMoveUpdatesUnlocked(updates)) return + execute( 'MOVE_ITEMS', () => { @@ -1260,6 +1419,8 @@ export function moveItemsWithTrackChanges( tracks: TimelineTrack[], updates: Array<{ id: string; from: number; trackId?: string }>, ): void { + if (!areMoveUpdatesUnlocked(updates, tracks)) return + execute( 'MOVE_ITEMS_WITH_TRACKS', () => { diff --git a/src/features/timeline/stores/actions/sync-lock-ripple.test.ts b/src/features/timeline/stores/actions/sync-lock-ripple.test.ts index 5bf70e31f..8d06086b9 100644 --- a/src/features/timeline/stores/actions/sync-lock-ripple.test.ts +++ b/src/features/timeline/stores/actions/sync-lock-ripple.test.ts @@ -253,4 +253,129 @@ describe('sync-lock ripple preview helpers', () => { linkedGroupId: undefined, }) }) + + it('lets track lock win over sync lock in previews and committed propagation', () => { + const tracks = [ + makeTrack({ id: 'edited-track', name: 'Edited', order: 0, kind: 'video' }), + makeTrack({ + id: 'audio-track', + name: 'A1', + order: 1, + kind: 'audio', + locked: true, + syncLock: true, + }), + ] + const lockedAudio = makeAudioItem({ + id: 'locked-audio', + trackId: 'audio-track', + from: 0, + durationInFrames: 100, + sourceStart: 20, + sourceEnd: 120, + sourceDuration: 180, + }) + + expect( + buildRemovedIntervalPreviewUpdatesForSyncLockedTracks({ + items: [lockedAudio], + tracks, + editedTrackIds: new Set(['edited-track']), + intervals: [{ start: 20, end: 40 }], + }), + ).toEqual([]) + + useItemsStore.getState().setTracks(tracks) + useItemsStore.getState().setItems([lockedAudio]) + const result = propagateRemovedIntervalsToSyncLockedTracks({ + editedTrackIds: new Set(['edited-track']), + intervals: [{ start: 20, end: 40 }], + }) + + expect(result).toEqual({ affectedIds: [], removedIds: [] }) + expect(useItemsStore.getState().itemById[lockedAudio.id]).toEqual(lockedAudio) + }) + + it('splits synchronized linked cohorts together and preserves per-side links', () => { + useItemsStore + .getState() + .setTracks([ + makeTrack({ id: 'edited-track', name: 'Edited', order: 0, kind: 'video' }), + makeTrack({ id: 'video-track', name: 'V1', order: 1, kind: 'video' }), + makeTrack({ id: 'audio-track', name: 'A1', order: 2, kind: 'audio' }), + ]) + useItemsStore.getState().setItems([ + makeVideoItem({ + id: 'linked-video', + trackId: 'video-track', + linkedGroupId: 'linked-av', + sourceStart: 0, + sourceEnd: 60, + sourceDuration: 120, + }), + makeAudioItem({ + id: 'linked-audio', + trackId: 'audio-track', + linkedGroupId: 'linked-av', + sourceStart: 0, + sourceEnd: 60, + sourceDuration: 120, + }), + ]) + + propagateInsertedGapToSyncLockedTracks({ + editedTrackIds: new Set(['edited-track']), + cutFrame: 20, + amount: 10, + }) + + const videos = useItemsStore + .getState() + .items.filter((item) => item.trackId === 'video-track') + .sort((left, right) => left.from - right.from) + const audios = useItemsStore + .getState() + .items.filter((item) => item.trackId === 'audio-track') + .sort((left, right) => left.from - right.from) + + expect(videos.map(({ from, durationInFrames }) => ({ from, durationInFrames }))).toEqual([ + { from: 0, durationInFrames: 20 }, + { from: 30, durationInFrames: 40 }, + ]) + expect(audios.map(({ from, durationInFrames }) => ({ from, durationInFrames }))).toEqual([ + { from: 0, durationInFrames: 20 }, + { from: 30, durationInFrames: 40 }, + ]) + expect(videos[0]?.linkedGroupId).toBe(audios[0]?.linkedGroupId) + expect(videos[1]?.linkedGroupId).toBe(audios[1]?.linkedGroupId) + expect(videos[0]?.linkedGroupId).not.toBe(videos[1]?.linkedGroupId) + }) + + it('rejects sync-lock mutation when a linked companion is locked', () => { + const video = makeVideoItem({ + id: 'linked-video', + trackId: 'video-track', + linkedGroupId: 'linked-av', + }) + const audio = makeAudioItem({ + id: 'linked-audio', + trackId: 'audio-track', + linkedGroupId: 'linked-av', + }) + useItemsStore + .getState() + .setTracks([ + makeTrack({ id: 'edited-track', name: 'Edited', order: 0, kind: 'video' }), + makeTrack({ id: 'video-track', name: 'V1', order: 1, kind: 'video' }), + makeTrack({ id: 'audio-track', name: 'A1', order: 2, kind: 'audio', locked: true }), + ]) + useItemsStore.getState().setItems([video, audio]) + + propagateRemovedIntervalsToSyncLockedTracks({ + editedTrackIds: new Set(['edited-track']), + intervals: [{ start: 20, end: 40 }], + }) + + expect(useItemsStore.getState().items).toEqual([video, audio]) + }) }) diff --git a/src/features/timeline/stores/actions/sync-lock-ripple.ts b/src/features/timeline/stores/actions/sync-lock-ripple.ts index 69be2e94d..8c347e9f2 100644 --- a/src/features/timeline/stores/actions/sync-lock-ripple.ts +++ b/src/features/timeline/stores/actions/sync-lock-ripple.ts @@ -2,7 +2,9 @@ import { useItemsStore } from '../items-store' import type { TimelineItem, TimelineTrack } from '@/types/timeline' import { isTrackSyncLockEnabled } from '../../utils/track-sync-lock' import type { PreviewItemUpdate } from '../../utils/item-edit-preview' -import { applySplitBookkeeping } from './split-bookkeeping' +import { applySplitBookkeeping, type SplitResultEntry } from './split-bookkeeping' +import { getLinkedItems } from '../../utils/linked-items' +import { isTimelineTrackLocked } from '../../utils/track-lock-invariants' export interface RipplePropagationResult { affectedIds: string[] @@ -52,30 +54,34 @@ function normalizeIntervals(intervals: TimeInterval[]): TimeInterval[] { return merged } +function canSyncLockRippleTrack( + tracks: TimelineTrack[], + track: TimelineTrack | undefined, + trackId: string, +): boolean { + return isTrackSyncLockEnabled(track) && !isTimelineTrackLocked(tracks, trackId) +} + function getCandidateTrackIdsFromState( items: TimelineItem[], tracks: TimelineTrack[], editedTrackIds: Set, ): string[] { - const trackIds = new Set() - - for (const track of tracks) { - if (!editedTrackIds.has(track.id) && isTrackSyncLockEnabled(track)) { - trackIds.add(track.id) - } - } - - for (const item of items) { - if (editedTrackIds.has(item.trackId)) continue - if (trackIds.has(item.trackId)) continue - - const track = tracks.find((candidate) => candidate.id === item.trackId) - if (isTrackSyncLockEnabled(track)) { - trackIds.add(item.trackId) - } - } + const trackById = new Map(tracks.map((track) => [track.id, track])) + const declaredCandidateIds = tracks + .filter( + (track) => !editedTrackIds.has(track.id) && canSyncLockRippleTrack(tracks, track, track.id), + ) + .map((track) => track.id) + const itemCandidateIds = items + .map((item) => item.trackId) + .filter( + (trackId) => + !editedTrackIds.has(trackId) && + canSyncLockRippleTrack(tracks, trackById.get(trackId), trackId), + ) - return [...trackIds] + return uniqueIds([...declaredCandidateIds, ...itemCandidateIds]) } function getCandidateTrackIds(editedTrackIds: Set): string[] { @@ -103,29 +109,41 @@ function setPreviewUpdate( }) } -function splitItemWithBookkeeping( - itemId: string, - splitFrame: number, -): { leftItem: TimelineItem; rightItem: TimelineItem } | null { - const current = useItemsStore.getState().itemById[itemId] - if (!current) { - return null +function applySplitBookkeepingByLinkedGroup(entries: SplitResultEntry[]): void { + const unlinkedEntries: SplitResultEntry[] = [] + const entriesByLinkedGroupId = new Map() + + for (const entry of entries) { + if (!entry.originalLinkedGroupId) { + unlinkedEntries.push(entry) + continue + } + + const groupEntries = entriesByLinkedGroupId.get(entry.originalLinkedGroupId) + if (groupEntries) groupEntries.push(entry) + else entriesByLinkedGroupId.set(entry.originalLinkedGroupId, [entry]) } - const result = useItemsStore.getState()._splitItem(itemId, splitFrame) - if (!result) { - return null + applySplitBookkeeping(unlinkedEntries) + for (const groupEntries of entriesByLinkedGroupId.values()) { + applySplitBookkeeping(groupEntries) } +} - applySplitBookkeeping([ - { - originalId: current.id, - originalLinkedGroupId: current.linkedGroupId, - result, - }, - ]) +function splitItemsWithBookkeeping(itemIds: string[], splitFrame: number): SplitResultEntry[] { + const store = useItemsStore.getState() + const entries = itemIds.flatMap((itemId) => { + const current = useItemsStore.getState().itemById[itemId] + if (!current) return [] + + const result = store._splitItem(itemId, splitFrame) + return result + ? [{ originalId: current.id, originalLinkedGroupId: current.linkedGroupId, result }] + : [] + }) - return result + applySplitBookkeepingByLinkedGroup(entries) + return entries } function buildRemovedIntervalPreviewUpdatesForTrack( @@ -242,11 +260,92 @@ function buildInsertedGapPreviewUpdatesForTrack( return [...updatesById.values()] } +function getAtomicCandidateTrackIds(params: { + items: TimelineItem[] + tracks: TimelineTrack[] + candidateTrackIds: string[] + updatesByTrackId: ReadonlyMap + additionalAffectedIds?: ReadonlySet +}): string[] { + const safeTrackIds = new Set(params.candidateTrackIds) + const itemById = new Map(params.items.map((item) => [item.id, item])) + + let changed = true + while (changed) { + changed = false + const affectedIds = new Set(params.additionalAffectedIds ?? []) + for (const trackId of safeTrackIds) { + for (const update of params.updatesByTrackId.get(trackId) ?? []) { + affectedIds.add(update.id) + } + } + + for (const trackId of [...safeTrackIds]) { + const trackUpdates = params.updatesByTrackId.get(trackId) ?? [] + const blocksTrack = trackUpdates.some((update) => { + const item = itemById.get(update.id) + if (!item) return true + + const linkedItems = getLinkedItems(params.items, item.id) + if (linkedItems.length <= 1) return false + + const hasLockedMember = linkedItems.some((linkedItem) => + isTimelineTrackLocked(params.tracks, linkedItem.trackId), + ) + const mutatesWholeCohort = linkedItems.every((linkedItem) => affectedIds.has(linkedItem.id)) + return hasLockedMember || !mutatesWholeCohort + }) + + if (blocksTrack) { + safeTrackIds.delete(trackId) + changed = true + } + } + } + + return params.candidateTrackIds.filter((trackId) => safeTrackIds.has(trackId)) +} + +function buildRemovedUpdatesByTrack(params: { + items: TimelineItem[] + candidateTrackIds: string[] + intervals: TimeInterval[] +}): Map { + return new Map( + params.candidateTrackIds.map((trackId) => [ + trackId, + buildRemovedIntervalPreviewUpdatesForTrack( + params.items.filter((item) => item.trackId === trackId), + params.intervals, + ), + ]), + ) +} + +function buildInsertedUpdatesByTrack(params: { + items: TimelineItem[] + candidateTrackIds: string[] + cutFrame: number + amount: number +}): Map { + return new Map( + params.candidateTrackIds.map((trackId) => [ + trackId, + buildInsertedGapPreviewUpdatesForTrack( + params.items.filter((item) => item.trackId === trackId), + params.cutFrame, + params.amount, + ), + ]), + ) +} + export function buildRemovedIntervalPreviewUpdatesForSyncLockedTracks(params: { items: TimelineItem[] tracks: TimelineTrack[] editedTrackIds: Set intervals: TimeInterval[] + additionalAffectedIds?: ReadonlySet }): PreviewItemUpdate[] { const intervals = normalizeIntervals(params.intervals) if (intervals.length === 0) { @@ -258,13 +357,20 @@ export function buildRemovedIntervalPreviewUpdatesForSyncLockedTracks(params: { params.tracks, params.editedTrackIds, ) + const updatesByTrackId = buildRemovedUpdatesByTrack({ + items: params.items, + candidateTrackIds, + intervals, + }) + const atomicTrackIds = getAtomicCandidateTrackIds({ + items: params.items, + tracks: params.tracks, + candidateTrackIds, + updatesByTrackId, + additionalAffectedIds: params.additionalAffectedIds, + }) - return candidateTrackIds.flatMap((trackId) => - buildRemovedIntervalPreviewUpdatesForTrack( - params.items.filter((item) => item.trackId === trackId), - intervals, - ), - ) + return atomicTrackIds.flatMap((trackId) => updatesByTrackId.get(trackId) ?? []) } export function buildInsertedGapPreviewUpdatesForSyncLockedTracks(params: { @@ -273,6 +379,7 @@ export function buildInsertedGapPreviewUpdatesForSyncLockedTracks(params: { editedTrackIds: Set cutFrame: number amount: number + additionalAffectedIds?: ReadonlySet }): PreviewItemUpdate[] { const cutFrame = Math.max(0, Math.round(params.cutFrame)) const amount = Math.max(0, Math.round(params.amount)) @@ -285,76 +392,73 @@ export function buildInsertedGapPreviewUpdatesForSyncLockedTracks(params: { params.tracks, params.editedTrackIds, ) + const updatesByTrackId = buildInsertedUpdatesByTrack({ + items: params.items, + candidateTrackIds, + cutFrame, + amount, + }) + const atomicTrackIds = getAtomicCandidateTrackIds({ + items: params.items, + tracks: params.tracks, + candidateTrackIds, + updatesByTrackId, + additionalAffectedIds: params.additionalAffectedIds, + }) - return candidateTrackIds.flatMap((trackId) => - buildInsertedGapPreviewUpdatesForTrack( - params.items.filter((item) => item.trackId === trackId), - cutFrame, - amount, - ), - ) + return atomicTrackIds.flatMap((trackId) => updatesByTrackId.get(trackId) ?? []) } -function removeItemsOnTrackInterval( - trackId: string, +function removeIntervalFromTracks( + trackIds: ReadonlySet, interval: TimeInterval, ): RipplePropagationResult { const store = useItemsStore.getState() const affectedIds: string[] = [] - const removedIds: string[] = [] const overlapping = useItemsStore .getState() .items.filter( (item) => - item.trackId === trackId && + trackIds.has(item.trackId) && item.from < interval.end && item.from + item.durationInFrames > interval.start, ) - .sort((left, right) => left.from - right.from) - - for (const overlappingItem of overlapping) { - const current = useItemsStore.getState().itemById[overlappingItem.id] - if (!current || current.trackId !== trackId) continue - - const itemEnd = current.from + current.durationInFrames - const startsBeforeInterval = current.from < interval.start - const endsAfterInterval = itemEnd > interval.end - - if (!startsBeforeInterval && !endsAfterInterval) { - store._removeItems([current.id]) - removedIds.push(current.id) - continue - } - - if (startsBeforeInterval && endsAfterInterval) { - const splitAtStart = splitItemWithBookkeeping(current.id, interval.start) - if (!splitAtStart) continue - affectedIds.push(splitAtStart.leftItem.id, splitAtStart.rightItem.id) - - const splitAtEnd = splitItemWithBookkeeping(splitAtStart.rightItem.id, interval.end) - if (!splitAtEnd) continue - store._removeItems([splitAtEnd.leftItem.id]) - removedIds.push(splitAtEnd.leftItem.id) - affectedIds.push(splitAtEnd.rightItem.id) - continue - } - if (startsBeforeInterval) { - const split = splitItemWithBookkeeping(current.id, interval.start) - if (!split) continue - store._removeItems([split.rightItem.id]) - removedIds.push(split.rightItem.id) - affectedIds.push(split.leftItem.id) - continue - } + const startSplitEntries = splitItemsWithBookkeeping( + overlapping.filter((item) => item.from < interval.start).map((item) => item.id), + interval.start, + ) + for (const entry of startSplitEntries) { + affectedIds.push(entry.result.leftItem.id, entry.result.rightItem.id) + } - const split = splitItemWithBookkeeping(current.id, interval.end) - if (!split) continue - store._removeItems([split.leftItem.id]) - removedIds.push(split.leftItem.id) - affectedIds.push(split.rightItem.id) + const endSplitEntries = splitItemsWithBookkeeping( + useItemsStore + .getState() + .items.filter( + (item) => + trackIds.has(item.trackId) && + item.from < interval.end && + item.from + item.durationInFrames > interval.end, + ) + .map((item) => item.id), + interval.end, + ) + for (const entry of endSplitEntries) { + affectedIds.push(entry.result.leftItem.id, entry.result.rightItem.id) } + const removedIds = useItemsStore + .getState() + .items.filter( + (item) => + trackIds.has(item.trackId) && + item.from >= interval.start && + item.from + item.durationInFrames <= interval.end, + ) + .map((item) => item.id) + if (removedIds.length > 0) store._removeItems(removedIds) + return { affectedIds: uniqueIds(affectedIds), removedIds: uniqueIds(removedIds), @@ -362,7 +466,7 @@ function removeItemsOnTrackInterval( } function shiftTrackItems( - trackId: string, + trackIds: ReadonlySet, predicate: (item: TimelineItem) => boolean, delta: number, ): string[] { @@ -373,7 +477,7 @@ function shiftTrackItems( const store = useItemsStore.getState() const updates = useItemsStore .getState() - .items.filter((item) => item.trackId === trackId && predicate(item)) + .items.filter((item) => trackIds.has(item.trackId) && predicate(item)) .map((item) => ({ id: item.id, from: Math.max(0, item.from + delta), @@ -389,35 +493,49 @@ function shiftTrackItems( export function propagateRemovedIntervalsToSyncLockedTracks(params: { editedTrackIds: Set intervals: TimeInterval[] + additionalAffectedIds?: ReadonlySet }): RipplePropagationResult { const intervals = normalizeIntervals(params.intervals) if (intervals.length === 0) { return { affectedIds: [], removedIds: [] } } + const { items, tracks } = useItemsStore.getState() const candidateTrackIds = getCandidateTrackIds(params.editedTrackIds) + const updatesByTrackId = buildRemovedUpdatesByTrack({ items, candidateTrackIds, intervals }) + const atomicTrackIds = new Set( + getAtomicCandidateTrackIds({ + items, + tracks, + candidateTrackIds, + updatesByTrackId, + additionalAffectedIds: params.additionalAffectedIds, + }), + ) const affectedIds: string[] = [] const removedIds: string[] = [] - for (const trackId of candidateTrackIds) { - let removedFrames = 0 - for (const interval of intervals) { - const currentInterval = { - start: interval.start - removedFrames, - end: interval.end - removedFrames, - } - const intervalLength = currentInterval.end - currentInterval.start - if (intervalLength <= 0) continue - - const overlapResult = removeItemsOnTrackInterval(trackId, currentInterval) - affectedIds.push(...overlapResult.affectedIds) - removedIds.push(...overlapResult.removedIds) - affectedIds.push( - ...shiftTrackItems(trackId, (item) => item.from >= currentInterval.end, -intervalLength), - ) - - removedFrames += intervalLength + let removedFrames = 0 + for (const interval of intervals) { + const currentInterval = { + start: interval.start - removedFrames, + end: interval.end - removedFrames, } + const intervalLength = currentInterval.end - currentInterval.start + if (intervalLength <= 0) continue + + const overlapResult = removeIntervalFromTracks(atomicTrackIds, currentInterval) + affectedIds.push(...overlapResult.affectedIds) + removedIds.push(...overlapResult.removedIds) + affectedIds.push( + ...shiftTrackItems( + atomicTrackIds, + (item) => item.from >= currentInterval.end, + -intervalLength, + ), + ) + + removedFrames += intervalLength } return { @@ -430,6 +548,7 @@ export function propagateInsertedGapToSyncLockedTracks(params: { editedTrackIds: Set cutFrame: number amount: number + additionalAffectedIds?: ReadonlySet }): RipplePropagationResult { const cutFrame = Math.max(0, Math.round(params.cutFrame)) const amount = Math.max(0, Math.round(params.amount)) @@ -437,31 +556,43 @@ export function propagateInsertedGapToSyncLockedTracks(params: { return { affectedIds: [], removedIds: [] } } + const { items, tracks } = useItemsStore.getState() const candidateTrackIds = getCandidateTrackIds(params.editedTrackIds) + const updatesByTrackId = buildInsertedUpdatesByTrack({ + items, + candidateTrackIds, + cutFrame, + amount, + }) + const atomicTrackIds = new Set( + getAtomicCandidateTrackIds({ + items, + tracks, + candidateTrackIds, + updatesByTrackId, + additionalAffectedIds: params.additionalAffectedIds, + }), + ) const affectedIds: string[] = [] - for (const trackId of candidateTrackIds) { - const straddledItems = useItemsStore + const splitEntries = splitItemsWithBookkeeping( + useItemsStore .getState() .items.filter( (item) => - item.trackId === trackId && + atomicTrackIds.has(item.trackId) && item.from < cutFrame && item.from + item.durationInFrames > cutFrame, ) - .sort((left, right) => left.from - right.from) - - for (const straddledItem of straddledItems) { - const current = useItemsStore.getState().itemById[straddledItem.id] - if (!current || current.trackId !== trackId) continue - const splitResult = splitItemWithBookkeeping(current.id, cutFrame) - if (!splitResult) continue - affectedIds.push(splitResult.leftItem.id, splitResult.rightItem.id) - } - - affectedIds.push(...shiftTrackItems(trackId, (item) => item.from >= cutFrame, amount)) + .map((item) => item.id), + cutFrame, + ) + for (const entry of splitEntries) { + affectedIds.push(entry.result.leftItem.id, entry.result.rightItem.id) } + affectedIds.push(...shiftTrackItems(atomicTrackIds, (item) => item.from >= cutFrame, amount)) + return { affectedIds: uniqueIds(affectedIds), removedIds: [], diff --git a/src/features/timeline/utils/track-content-drag.test.ts b/src/features/timeline/utils/track-content-drag.test.ts index 6a8213ab7..6f4549a8a 100644 --- a/src/features/timeline/utils/track-content-drag.test.ts +++ b/src/features/timeline/utils/track-content-drag.test.ts @@ -55,6 +55,10 @@ function makeAudioItem(id: string, trackId: string): AudioItem { } as AudioItem } +function makeVideoTracks(ids: string[]): TimelineTrack[] { + return ids.map((id, order) => makeTrack({ id, name: id.toUpperCase(), kind: 'video', order })) +} + describe('track content drag', () => { it('limits drag plans to the anchor section and ignores mixed A/V selections', () => { const tracks = [ @@ -100,6 +104,7 @@ describe('track content drag', () => { expect( buildTrackContentMoveUpdates({ + tracks: makeVideoTracks(['v3', 'v2', 'v1']), sectionTrackIds: ['v3', 'v2', 'v1'], draggedTrackIds: ['v1'], items, @@ -122,6 +127,7 @@ describe('track content drag', () => { expect( buildTrackContentMoveUpdates({ + tracks: makeVideoTracks(['v4', 'v3', 'v2', 'v1']), sectionTrackIds: ['v4', 'v3', 'v2', 'v1'], draggedTrackIds: ['v2', 'v1'], items, @@ -144,6 +150,7 @@ describe('track content drag', () => { expect( buildTrackContentMoveUpdates({ + tracks: makeVideoTracks(['v3', 'v2', 'v1']), sectionTrackIds: ['v3', 'v2', 'v1'], draggedTrackIds: ['v2'], items, @@ -218,4 +225,71 @@ describe('track content drag', () => { { id: 'clip-v1', from: 0, trackId: createdTracks?.[1]?.id }, ]) }) + + it('does not start a content reorder from a locked track', () => { + const tracks = [ + makeTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1, locked: true }), + ] + + expect( + resolveTrackContentDragPlan({ + tracks, + anchorTrackId: 'v1', + selectedTrackIds: ['v1'], + }), + ).toBeNull() + }) + + it('rejects a fixed-lane reorder when any affected lane is locked', () => { + const tracks = [ + makeTrack({ id: 'v3', name: 'V3', kind: 'video', order: 0 }), + makeTrack({ id: 'v2', name: 'V2', kind: 'video', order: 1, locked: true }), + makeTrack({ id: 'v1', name: 'V1', kind: 'video', order: 2 }), + ] + const items = [ + makeVideoItem('clip-v3', 'v3'), + makeVideoItem('clip-v2', 'v2'), + makeVideoItem('clip-v1', 'v1'), + ] + + expect( + buildTrackContentMoveUpdates({ + tracks, + sectionTrackIds: ['v3', 'v2', 'v1'], + draggedTrackIds: ['v1'], + items, + insertIndex: 0, + }), + ).toEqual([]) + }) + + it('rejects track-header moves when a moved item has a locked linked companion', () => { + const tracks = [ + makeTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2, locked: true }), + ] + const video = { ...makeVideoItem('clip-v1', 'v1'), linkedGroupId: 'linked-av' } + const audio = { ...makeAudioItem('clip-a1', 'a1'), linkedGroupId: 'linked-av' } + const items = [video, audio] + + expect( + buildTrackContentMoveUpdates({ + tracks, + sectionTrackIds: ['v2', 'v1'], + draggedTrackIds: ['v1'], + items, + insertIndex: 0, + }), + ).toEqual([]) + expect( + buildTrackContentCreateTrackMovePlan({ + tracks, + items, + kind: 'video', + draggedTrackIds: ['v1'], + }), + ).toBeNull() + }) }) diff --git a/src/features/timeline/utils/track-content-drag.ts b/src/features/timeline/utils/track-content-drag.ts index 603f7da63..9de3f324c 100644 --- a/src/features/timeline/utils/track-content-drag.ts +++ b/src/features/timeline/utils/track-content-drag.ts @@ -5,6 +5,7 @@ import { getTrackKind, type TrackKind, } from './classic-tracks' +import { isTimelineTrackLocked, partitionItemMutationIdsByLock } from './track-lock-invariants' export interface TrackContentDragPlan { kind: TrackKind @@ -34,7 +35,7 @@ export function resolveTrackContentDragPlan(params: { selectedTrackIds: string[] }): TrackContentDragPlan | null { const anchorTrack = params.tracks.find((track) => track.id === params.anchorTrackId) - if (!anchorTrack) { + if (!anchorTrack || isTimelineTrackLocked(params.tracks, anchorTrack.id)) { return null } @@ -49,6 +50,14 @@ export function resolveTrackContentDragPlan(params: { } const selectedTrackIds = new Set(params.selectedTrackIds) + if ( + sectionTracks.some( + (track) => selectedTrackIds.has(track.id) && isTimelineTrackLocked(params.tracks, track.id), + ) + ) { + return null + } + const draggedTrackIds = sectionTracks .filter((track) => selectedTrackIds.has(track.id)) .map((track) => track.id) @@ -69,7 +78,10 @@ export function buildTrackContentCreateTrackMovePlan(params: { const sectionTracks = getKindTracks(params.tracks, params.kind) const draggedTrackIdsSet = new Set(params.draggedTrackIds) const draggedTracks = sectionTracks.filter((track) => draggedTrackIdsSet.has(track.id)) - if (draggedTracks.length === 0) { + if ( + draggedTracks.length === 0 || + draggedTracks.some((track) => isTimelineTrackLocked(params.tracks, track.id)) + ) { return null } @@ -121,6 +133,13 @@ export function buildTrackContentCreateTrackMovePlan(params: { ] }) + const mutationPartition = partitionItemMutationIdsByLock({ + items: params.items, + tracks: params.tracks, + itemIds: updates.map((update) => update.id), + }) + if (mutationPartition.blockedIds.length > 0) return null + return { tracks: nextTracks, updates, @@ -128,6 +147,7 @@ export function buildTrackContentCreateTrackMovePlan(params: { } export function buildTrackContentMoveUpdates(params: { + tracks: TimelineTrack[] sectionTrackIds: string[] draggedTrackIds: string[] items: TimelineItem[] @@ -174,7 +194,18 @@ export function buildTrackContentMoveUpdates(params: { } }) - return params.items.flatMap((item) => { + const affectedTrackIds = new Set() + for (const [sourceTrackId, destinationTrackId] of destinationTrackIdBySourceTrackId) { + affectedTrackIds.add(sourceTrackId) + affectedTrackIds.add(destinationTrackId) + } + if ( + Array.from(affectedTrackIds).some((trackId) => isTimelineTrackLocked(params.tracks, trackId)) + ) { + return [] + } + + const updates = params.items.flatMap((item) => { const destinationTrackId = destinationTrackIdBySourceTrackId.get(item.trackId) if (!destinationTrackId) { return [] @@ -188,4 +219,12 @@ export function buildTrackContentMoveUpdates(params: { }, ] }) + + const mutationPartition = partitionItemMutationIdsByLock({ + items: params.items, + tracks: params.tracks, + itemIds: updates.map((update) => update.id), + }) + + return mutationPartition.blockedIds.length > 0 ? [] : updates } diff --git a/src/features/timeline/utils/track-lock-invariants.ts b/src/features/timeline/utils/track-lock-invariants.ts new file mode 100644 index 000000000..434c7e7f5 --- /dev/null +++ b/src/features/timeline/utils/track-lock-invariants.ts @@ -0,0 +1,74 @@ +import type { TimelineItem, TimelineTrack } from '@/types/timeline' +import { resolveEffectiveTrackStates } from './group-utils' +import { getLinkedItems } from './linked-items' + +export interface ItemMutationLockPartition { + allowedIds: string[] + blockedIds: string[] + blockedByLockedLinkedCohort: boolean +} + +function getLockedTrackIds(tracks: TimelineTrack[]): Set { + const lockedTrackIds = new Set( + resolveEffectiveTrackStates(tracks) + .filter((track) => track.locked) + .map((track) => track.id), + ) + + for (const track of tracks) { + if (track.locked) lockedTrackIds.add(track.id) + } + + return lockedTrackIds +} + +export function isTimelineTrackLocked(tracks: TimelineTrack[], trackId: string): boolean { + return getLockedTrackIds(tracks).has(trackId) +} + +/** + * Partition a proposed item mutation without ever peeling an unlocked member + * away from a linked cohort that contains a locked member. + * + * Standalone items on locked tracks are simply ineligible. A linked cohort is + * stronger: if any companion is locked, every proposed mutation in that + * cohort is rejected so an A/V pair cannot be silently desynchronized. + */ +export function partitionItemMutationIdsByLock(params: { + items: TimelineItem[] + tracks: TimelineTrack[] + itemIds: Iterable +}): ItemMutationLockPartition { + const requestedIds = Array.from(new Set(params.itemIds)) + const requestedIdSet = new Set(requestedIds) + const lockedTrackIds = getLockedTrackIds(params.tracks) + const itemById = new Map(params.items.map((item) => [item.id, item])) + const blockedIds = new Set() + let blockedByLockedLinkedCohort = false + + for (const itemId of requestedIds) { + if (blockedIds.has(itemId)) continue + + const item = itemById.get(itemId) + if (!item) { + blockedIds.add(itemId) + continue + } + + const linkedItems = getLinkedItems(params.items, item.id) + const hasLockedMember = linkedItems.some((linkedItem) => lockedTrackIds.has(linkedItem.trackId)) + + if (!hasLockedMember) continue + + blockedByLockedLinkedCohort ||= linkedItems.length > 1 + for (const linkedItem of linkedItems) { + if (requestedIdSet.has(linkedItem.id)) blockedIds.add(linkedItem.id) + } + } + + return { + allowedIds: requestedIds.filter((itemId) => !blockedIds.has(itemId)), + blockedIds: requestedIds.filter((itemId) => blockedIds.has(itemId)), + blockedByLockedLinkedCohort, + } +} From 79dda7bff35f337da95d553edd5a6d4fb371ec60 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 19:59:32 -0700 Subject: [PATCH 26/64] fix(timeline): make lock guards atomic across edit actions (cherry picked from commit af56cb4bb45523a8134ce16f6846718fb7ee8f8d) --- .../timeline/hooks/use-timeline-trim.test.tsx | 9 +- .../actions/edit/freeze-frame-actions.ts | 61 +++- .../stores/actions/edit/join-actions.ts | 76 ++-- .../actions/edit/range-removal-actions.ts | 158 +++++++- .../actions/edit/rate-stretch-actions.ts | 210 ++++++++++- .../timeline/stores/actions/edit/shared.ts | 15 + .../stores/actions/edit/split-actions.ts | 105 +++--- .../stores/actions/edit/trim-actions.ts | 306 +++++++++++++++- .../timeline/stores/actions/item-actions.ts | 10 + .../item-edit-actions.lock-invariants.test.ts | 336 ++++++++++++++++++ .../actions/source-edit-actions.test.ts | 119 ++++++- .../stores/actions/source-edit-actions.ts | 61 +++- .../timeline/utils/source-edit-targeting.ts | 29 +- .../timeline/utils/track-lock-invariants.ts | 34 ++ 14 files changed, 1402 insertions(+), 127 deletions(-) create mode 100644 src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts diff --git a/src/features/timeline/hooks/use-timeline-trim.test.tsx b/src/features/timeline/hooks/use-timeline-trim.test.tsx index 2776e4097..5de3ed242 100644 --- a/src/features/timeline/hooks/use-timeline-trim.test.tsx +++ b/src/features/timeline/hooks/use-timeline-trim.test.tsx @@ -454,7 +454,7 @@ describe('useTimelineTrim', () => { expect(getItem('video-near').durationInFrames).toBe(60) }) - it('leaves a vertically aligned selected companion unchanged on a locked track', () => { + it('rejects a vertically aligned trim cohort containing a locked linked companion', () => { const text: TextItem = { id: 'text-1', type: 'text', @@ -480,6 +480,7 @@ describe('useTimelineTrim', () => { ]) useItemsStore.getState().setItems([text, video, audio]) useSelectionStore.getState().selectItems(['text-1', 'video-1', 'audio-1']) + const undoDepthBefore = useTimelineCommandStore.getState().undoStack.length const { result } = renderTrimHook(text) startTrim(result, 'end') @@ -491,9 +492,11 @@ describe('useTimelineTrim', () => { releaseMouse() - expect(getItem('text-1').durationInFrames).toBe(50) - expect(getItem('video-1').durationInFrames).toBe(50) + expect(getItem('text-1').durationInFrames).toBe(60) + expect(getItem('video-1').durationInFrames).toBe(60) expect(getItem('audio-1').durationInFrames).toBe(60) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(undoDepthBefore) + expect(useTimelineSettingsStore.getState().isDirty).toBe(false) }) it('uses the tightest neighbor clamp across the vertical trim group', () => { diff --git a/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts b/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts index 6c8b7d7a6..01cc64671 100644 --- a/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts +++ b/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts @@ -8,7 +8,33 @@ import { importMediaLibraryService } from '@/features/timeline/deps/media-librar import { blobUrlManager } from '@/infrastructure/browser/blob-url-manager' import { execute, applyTransitionRepairs, getLogger } from '../shared' import { timelineToSourceFrames } from '../../../utils/source-calculations' -import { isInTransitionOverlap } from './shared' +import { canMutateTimelineItems, isInTransitionOverlap } from './shared' + +function canCommitFreezeFrame(itemId: string, playheadFrame: number): boolean { + const store = useItemsStore.getState() + const item = store.itemById[itemId] + if (!item || item.type !== 'video') return false + if ( + playheadFrame <= item.from || + playheadFrame >= item.from + item.durationInFrames || + isInTransitionOverlap(itemId, playheadFrame - item.from, item.durationInFrames) + ) { + return false + } + + const mutationIds = [ + itemId, + ...store.items + .filter( + (candidate) => + candidate.id !== itemId && + candidate.trackId === item.trackId && + candidate.from > playheadFrame, + ) + .map((candidate) => candidate.id), + ] + return canMutateTimelineItems(mutationIds, [item.trackId]) +} /** * Insert a freeze frame at the playhead position. @@ -33,6 +59,7 @@ export async function insertFreezeFrame(itemId: string, playheadFrame: number): if (isInTransitionOverlap(itemId, playheadFrame - itemStart, item.durationInFrames)) { return false } + if (!canCommitFreezeFrame(itemId, playheadFrame)) return false const fps = useTimelineSettingsStore.getState().fps const speed = item.speed ?? 1 @@ -144,6 +171,25 @@ export async function insertFreezeFrame(itemId: string, playheadFrame: number): const frameMediaId = mediaMetadata.id const frameBlobUrl = blobUrlManager.acquire(frameMediaId, frameBlob) + const rollbackPersistedFrame = async (): Promise => { + try { + await mediaLibraryService.deleteMediaFromProject(currentProjectId, frameMediaId) + } catch (cleanupError) { + getLogger().warn( + '[insertFreezeFrame] Failed to roll back persisted frame after rejected commit', + cleanupError, + ) + } + blobUrlManager.release(frameMediaId) + } + + // Locks can change while frame extraction and persistence are awaiting. + // Revalidate the complete split/shift cohort immediately before execute(). + if (!canCommitFreezeFrame(itemId, playheadFrame)) { + await rollbackPersistedFrame() + return false + } + // Step 4: Perform timeline mutations atomically (split + insert + shift). // Prepend the media item to the store only after execute() succeeds so a // failed _splitItem (e.g. the source clip was removed between validation @@ -229,18 +275,7 @@ export async function insertFreezeFrame(itemId: string, playheadFrame: number): // only by this project, so the reference-counted variant covers it // and preserves the global "delete everywhere" semantics for the // explicit user action. - try { - await mediaLibraryService.deleteMediaFromProject(currentProjectId, frameMediaId) - } catch (cleanupError) { - getLogger().warn( - '[insertFreezeFrame] Failed to roll back persisted frame after split failure', - cleanupError, - ) - } - // blobUrlManager.acquire above bumped the ref count for frameMediaId; - // matched release here revokes the underlying ObjectURL and frees the - // Blob so a failure path doesn't accumulate leaked frames over time. - blobUrlManager.release(frameMediaId) + await rollbackPersistedFrame() return false } diff --git a/src/features/timeline/stores/actions/edit/join-actions.ts b/src/features/timeline/stores/actions/edit/join-actions.ts index c5a60fd05..5288773dc 100644 --- a/src/features/timeline/stores/actions/edit/join-actions.ts +++ b/src/features/timeline/stores/actions/edit/join-actions.ts @@ -4,50 +4,50 @@ import { useKeyframesStore } from '../../keyframes-store' import { useTimelineSettingsStore } from '../../timeline-settings-store' import { execute, applyTransitionRepairs } from '../shared' import { getSynchronizedLinkedCounterpartPairForEdit } from '../linked-edit' -import { isLinkedSelectionEnabled } from './shared' +import { canMutateTimelineItems, isLinkedSelectionEnabled } from './shared' export function joinItems(itemIds: string[]): void { - execute( - 'JOIN_ITEMS', - () => { - const items = useItemsStore.getState().items - const itemsToJoin = items - .filter((item) => itemIds.includes(item.id)) - .toSorted((left, right) => left.from - right.from) - if (itemsToJoin.length < 2) return + const items = useItemsStore.getState().items + const itemsToJoin = items + .filter((item) => itemIds.includes(item.id)) + .toSorted((left, right) => left.from - right.from) + if (itemsToJoin.length < 2) return - const joinGroups = [itemIds] - if (itemsToJoin.length === 2) { - const [leftItem, rightItem] = itemsToJoin - if (leftItem && rightItem) { - const counterpartPair = getSynchronizedLinkedCounterpartPairForEdit( - items, - leftItem.id, - rightItem.id, - isLinkedSelectionEnabled(), - ) - if (counterpartPair) { - joinGroups.push([ - counterpartPair.leftCounterpart.id, - counterpartPair.rightCounterpart.id, - ]) - } - } + const joinGroups = [itemIds] + if (itemsToJoin.length === 2) { + const [leftItem, rightItem] = itemsToJoin + if (leftItem && rightItem) { + const counterpartPair = getSynchronizedLinkedCounterpartPairForEdit( + items, + leftItem.id, + rightItem.id, + isLinkedSelectionEnabled(), + ) + if (counterpartPair) { + joinGroups.push([counterpartPair.leftCounterpart.id, counterpartPair.rightCounterpart.id]) } + } + } - const groupDescriptors = joinGroups - .map((groupItemIds) => - items - .filter((item) => groupItemIds.includes(item.id)) - .toSorted((left, right) => left.from - right.from), - ) - .filter((groupItems) => groupItems.length >= 2) - .map((groupItems) => ({ - itemIds: groupItems.map((item) => item.id), - primaryId: groupItems[0]!.id, - removedIds: groupItems.slice(1).map((item) => item.id), - })) + const groupDescriptors = joinGroups + .map((groupItemIds) => + items + .filter((item) => groupItemIds.includes(item.id)) + .toSorted((left, right) => left.from - right.from), + ) + .filter((groupItems) => groupItems.length >= 2) + .map((groupItems) => ({ + itemIds: groupItems.map((item) => item.id), + primaryId: groupItems[0]!.id, + removedIds: groupItems.slice(1).map((item) => item.id), + })) + const mutationIds = groupDescriptors.flatMap((group) => group.itemIds) + if (groupDescriptors.length === 0 || !canMutateTimelineItems(mutationIds)) return + + execute( + 'JOIN_ITEMS', + () => { for (const group of groupDescriptors) { useItemsStore.getState()._joinItems(group.itemIds) } diff --git a/src/features/timeline/stores/actions/edit/range-removal-actions.ts b/src/features/timeline/stores/actions/edit/range-removal-actions.ts index 6bd40556c..81caab759 100644 --- a/src/features/timeline/stores/actions/edit/range-removal-actions.ts +++ b/src/features/timeline/stores/actions/edit/range-removal-actions.ts @@ -11,9 +11,13 @@ import { } from '../../../utils/media-item-frames' import { getUniqueLinkedItemAnchorIds } from '../../../utils/linked-items' import { isTrackSyncLockEnabled } from '../../../utils/track-sync-lock' -import { propagateRemovedIntervalsToSyncLockedTracks } from '../sync-lock-ripple' +import { + buildRemovedIntervalPreviewUpdatesForSyncLockedTracks, + propagateRemovedIntervalsToSyncLockedTracks, +} from '../sync-lock-ripple' import { applySplitBookkeeping, type SplitResultEntry } from '../split-bookkeeping' import { + canMutateTimelineItems, isLinkedSelectionEnabled, isInTransitionOverlap, requestPostEditWarmForItems, @@ -206,6 +210,148 @@ export function removeTranscriptRangesFromItems( return removeTimelineRangesFromItems('REMOVE_TRANSCRIPT_SELECTION', itemIds, rangesByMediaId) } +function getRangeRemovalAnchors( + itemIds: string[], + rangesByMediaId: Record, +): TimelineItem[] { + const store = useItemsStore.getState() + const anchorIds = getUniqueLinkedItemAnchorIds(store.items, itemIds) + return anchorIds + .map((id) => store.itemById[id]) + .filter( + (item): item is TimelineItem => + item !== undefined && + (item.type === 'video' || item.type === 'audio') && + !!item.mediaId && + (rangesByMediaId[item.mediaId]?.length ?? 0) > 0, + ) +} + +function getAnchorTimelineIntervals( + anchor: TimelineItem, + ranges: RemoveSilenceRange[], + timelineFps: number, +): RemoveSilenceRange[] { + return ranges.flatMap((range) => { + const firstFrame = sourceSecondsToTimelineFrame(anchor, range.start, timelineFps) + const secondFrame = sourceSecondsToTimelineFrame(anchor, range.end, timelineFps) + const start = Math.max(anchor.from, Math.min(firstFrame, secondFrame)) + const end = Math.min(anchor.from + anchor.durationInFrames, Math.max(firstFrame, secondFrame)) + return end > start ? [{ start, end }] : [] + }) +} + +interface RangeRemovalPreflightAccumulator { + mutationIds: Set + editedTrackIds: Set + earliestAffectedFrameByTrackId: Map + intervals: RemoveSilenceRange[] +} + +function addRangeAnchorPreflight(params: { + anchor: TimelineItem + ranges: RemoveSilenceRange[] + timelineFps: number + linkedSelectionEnabled: boolean + accumulator: RangeRemovalPreflightAccumulator +}): void { + const items = useItemsStore.getState().items + const splitItems = getLinkedItemsForEdit(items, params.anchor.id, params.linkedSelectionEnabled) + const anchorIntervals = getAnchorTimelineIntervals( + params.anchor, + params.ranges, + params.timelineFps, + ) + params.accumulator.intervals.push(...anchorIntervals) + + for (const splitItem of splitItems) { + params.accumulator.editedTrackIds.add(splitItem.trackId) + for (const relatedId of expandIdsWithLinkedItems( + items, + [splitItem.id], + params.linkedSelectionEnabled, + )) { + params.accumulator.mutationIds.add(relatedId) + } + + for (const interval of anchorIntervals) { + const previousStart = + params.accumulator.earliestAffectedFrameByTrackId.get(splitItem.trackId) ?? + Number.POSITIVE_INFINITY + params.accumulator.earliestAffectedFrameByTrackId.set( + splitItem.trackId, + Math.min(previousStart, interval.start), + ) + } + } +} + +function addRangeDownstreamPreflight(params: { + items: TimelineItem[] + linkedSelectionEnabled: boolean + accumulator: RangeRemovalPreflightAccumulator +}): void { + for (const item of params.items) { + const earliestAffectedFrame = params.accumulator.earliestAffectedFrameByTrackId.get( + item.trackId, + ) + if ( + earliestAffectedFrame === undefined || + item.from + item.durationInFrames <= earliestAffectedFrame + ) { + continue + } + for (const relatedId of expandIdsWithLinkedItems( + params.items, + [item.id], + params.linkedSelectionEnabled, + )) { + params.accumulator.mutationIds.add(relatedId) + } + } +} + +function buildRangeRemovalPreflight( + itemIds: string[], + rangesByMediaId: Record, +): { analyzedItemCount: number; mutationIds: string[] } { + const store = useItemsStore.getState() + const timelineFps = useTimelineSettingsStore.getState().fps + const anchors = getRangeRemovalAnchors(itemIds, rangesByMediaId) + if (anchors.length === 0) return { analyzedItemCount: 0, mutationIds: [] } + + const linkedSelectionEnabled = isLinkedSelectionEnabled() + const accumulator: RangeRemovalPreflightAccumulator = { + mutationIds: new Set(), + editedTrackIds: new Set(), + earliestAffectedFrameByTrackId: new Map(), + intervals: [], + } + + for (const anchor of anchors) { + addRangeAnchorPreflight({ + anchor, + ranges: rangesByMediaId[anchor.mediaId!] ?? [], + timelineFps, + linkedSelectionEnabled, + accumulator, + }) + } + + addRangeDownstreamPreflight({ items: store.items, linkedSelectionEnabled, accumulator }) + + const syncLockUpdates = buildRemovedIntervalPreviewUpdatesForSyncLockedTracks({ + items: store.items, + tracks: store.tracks, + editedTrackIds: accumulator.editedTrackIds, + intervals: accumulator.intervals, + additionalAffectedIds: accumulator.mutationIds, + }) + for (const update of syncLockUpdates) accumulator.mutationIds.add(update.id) + + return { analyzedItemCount: anchors.length, mutationIds: Array.from(accumulator.mutationIds) } +} + function removeTimelineRangesFromItems( commandType: 'REMOVE_SILENCE' | 'REMOVE_FILLER_WORDS' | 'REMOVE_TRANSCRIPT_SELECTION', itemIds: string[], @@ -215,6 +361,16 @@ function removeTimelineRangesFromItems( return { analyzedItemCount: 0, removedRangeCount: 0, removedItemCount: 0, splitCount: 0 } } + const preflight = buildRangeRemovalPreflight(itemIds, rangesByMediaId) + if (preflight.mutationIds.length === 0 || !canMutateTimelineItems(preflight.mutationIds)) { + return { + analyzedItemCount: preflight.analyzedItemCount, + removedRangeCount: 0, + removedItemCount: 0, + splitCount: 0, + } + } + return execute( commandType, () => { diff --git a/src/features/timeline/stores/actions/edit/rate-stretch-actions.ts b/src/features/timeline/stores/actions/edit/rate-stretch-actions.ts index 7d665bbb1..7d2cede93 100644 --- a/src/features/timeline/stores/actions/edit/rate-stretch-actions.ts +++ b/src/features/timeline/stores/actions/edit/rate-stretch-actions.ts @@ -2,11 +2,199 @@ import { useItemsStore } from '../../items-store' import { useTransitionsStore } from '../../transitions-store' import { useKeyframesStore } from '../../keyframes-store' import { useTimelineSettingsStore } from '../../timeline-settings-store' +import type { TimelineItem } from '@/types/timeline' import { execute, applyTransitionRepairs } from '../shared' import { getSynchronizedLinkedItemsForEdit } from '../linked-edit' import { timelineToSourceFrames, sourceToTimelineFrames } from '../../../utils/source-calculations' import { expandItemIdsWithAttachedCaptions, getLinkedItemIds } from '../../../utils/linked-items' -import { isLinkedSelectionEnabled, requestPostEditWarmForItems } from './shared' +import { + canMutateTimelineItems, + isLinkedSelectionEnabled, + requestPostEditWarmForItems, +} from './shared' +import { roundDuration, roundFrame } from '../../items-store-normalize' + +function addLinkedRippleCohort( + items: TimelineItem[], + itemId: string, + mutationIds: Set, +): void { + for (const relatedId of expandItemIdsWithAttachedCaptions( + items, + getLinkedItemIds(items, itemId), + )) { + mutationIds.add(relatedId) + } +} + +function collectRateStretchEndMutationIds(params: { + items: TimelineItem[] + synchronizedItems: TimelineItem[] + synchronizedIds: Set + touchedTrackIds: Set + oldEnd: number + mutationIds: Set +}): void { + for (const candidate of params.items) { + if ( + params.synchronizedIds.has(candidate.id) || + !params.touchedTrackIds.has(candidate.trackId) || + candidate.from < params.oldEnd + ) { + continue + } + addLinkedRippleCohort(params.items, candidate.id, params.mutationIds) + } + + const transitions = useTransitionsStore.getState().transitions + for (const synchronizedItem of params.synchronizedItems) { + for (const transition of transitions) { + if (transition.leftClipId !== synchronizedItem.id) continue + addLinkedRippleCohort(params.items, transition.rightClipId, params.mutationIds) + } + } +} + +function collectRateStretchStartMutationIds(params: { + items: TimelineItem[] + synchronizedItems: TimelineItem[] + synchronizedIds: Set + touchedTrackIds: Set + oldFrom: number + mutationIds: Set +}): void { + for (const candidate of params.items) { + if ( + params.synchronizedIds.has(candidate.id) || + !params.touchedTrackIds.has(candidate.trackId) || + candidate.from + candidate.durationInFrames > params.oldFrom + ) { + continue + } + addLinkedRippleCohort(params.items, candidate.id, params.mutationIds) + } + + const transitions = useTransitionsStore.getState().transitions + for (const synchronizedItem of params.synchronizedItems) { + for (const transition of transitions) { + if (transition.rightClipId !== synchronizedItem.id) continue + addLinkedRippleCohort(params.items, transition.leftClipId, params.mutationIds) + } + } +} + +function getRateStretchMutationIds(id: string, newFrom: number, newDuration: number): string[] { + const items = useItemsStore.getState().items + const synchronizedItems = getSynchronizedLinkedItemsForEdit(items, id, isLinkedSelectionEnabled()) + const anchor = synchronizedItems.find((item) => item.id === id) + if (!anchor) return [] + + const synchronizedIds = new Set(synchronizedItems.map((item) => item.id)) + const mutationIds = new Set(synchronizedIds) + const oldEnd = anchor.from + anchor.durationInFrames + const fromDelta = roundFrame(newFrom) - anchor.from + const endDelta = roundFrame(newFrom) + roundDuration(newDuration) - oldEnd + const touchedTrackIds = new Set(synchronizedItems.map((item) => item.trackId)) + + if (endDelta !== 0) { + collectRateStretchEndMutationIds({ + items, + synchronizedItems, + synchronizedIds, + touchedTrackIds, + oldEnd, + mutationIds, + }) + } + + if (fromDelta !== 0) { + collectRateStretchStartMutationIds({ + items, + synchronizedItems, + synchronizedIds, + touchedTrackIds, + oldFrom: anchor.from, + mutationIds, + }) + } + + return Array.from(mutationIds) +} + +interface ResetSpeedMutationPlan { + synchronizedItems: TimelineItem[] + oldEnd: number + growth: number +} + +function calculateResetSpeedDuration(item: TimelineItem, fps: number): number { + const currentSpeed = item.speed || 1 + const sourceFps = item.sourceFps ?? fps + const effectiveSourceFrames = + item.sourceEnd !== undefined && item.sourceStart !== undefined + ? item.sourceEnd - item.sourceStart + : timelineToSourceFrames(item.durationInFrames, currentSpeed, fps, sourceFps) + return Math.max(1, sourceToTimelineFrames(effectiveSourceFrames, 1, sourceFps, fps)) +} + +function getResetSpeedMutationPlan( + items: TimelineItem[], + id: string, + fps: number, +): ResetSpeedMutationPlan | null { + const item = items.find((candidate) => candidate.id === id) + if (!item || (item.type !== 'video' && item.type !== 'audio')) return null + const currentSpeed = item.speed || 1 + if (Math.abs(currentSpeed - 1) <= 0.01) return null + + const synchronizedItems = getSynchronizedLinkedItemsForEdit(items, id, isLinkedSelectionEnabled()) + const newDuration = calculateResetSpeedDuration(item, fps) + return { + synchronizedItems, + oldEnd: item.from + item.durationInFrames, + growth: roundDuration(newDuration) - item.durationInFrames, + } +} + +function addResetSpeedDownstreamMutationIds(params: { + items: TimelineItem[] + plan: ResetSpeedMutationPlan + processedIds: Set + mutationIds: Set +}): void { + if (params.plan.growth <= 0) return + const touchedTrackIds = new Set(params.plan.synchronizedItems.map((item) => item.trackId)) + for (const candidate of params.items) { + if ( + params.processedIds.has(candidate.id) || + !touchedTrackIds.has(candidate.trackId) || + candidate.from < params.plan.oldEnd + ) { + continue + } + addLinkedRippleCohort(params.items, candidate.id, params.mutationIds) + } +} + +function getResetSpeedMutationIds(itemIds: string[]): string[] { + const items = useItemsStore.getState().items + const fps = useTimelineSettingsStore.getState().fps + const mutationIds = new Set() + const processedIds = new Set() + + for (const id of itemIds) { + if (processedIds.has(id)) continue + const plan = getResetSpeedMutationPlan(items, id, fps) + if (!plan) continue + for (const synchronizedItem of plan.synchronizedItems) { + processedIds.add(synchronizedItem.id) + mutationIds.add(synchronizedItem.id) + } + addResetSpeedDownstreamMutationIds({ items, plan, processedIds, mutationIds }) + } + + return Array.from(mutationIds) +} export function rateStretchItemWithoutHistory( id: string, @@ -14,6 +202,9 @@ export function rateStretchItemWithoutHistory( newDuration: number, newSpeed: number, ): void { + const mutationIds = getRateStretchMutationIds(id, newFrom, newDuration) + if (mutationIds.length === 0 || !canMutateTimelineItems(mutationIds)) return + const itemsStore = useItemsStore.getState() const itemsBefore = itemsStore.items const synchronizedItems = getSynchronizedLinkedItemsForEdit( @@ -219,6 +410,9 @@ export function rateStretchItem( newDuration: number, newSpeed: number, ): void { + const mutationIds = getRateStretchMutationIds(id, newFrom, newDuration) + if (mutationIds.length === 0 || !canMutateTimelineItems(mutationIds)) return + execute( 'RATE_STRETCH_ITEM', () => { @@ -239,6 +433,9 @@ export function rateStretchItem( */ export function resetSpeedWithRipple(itemIds: string[]): void { const TOLERANCE = 0.01 + const mutationIds = getResetSpeedMutationIds(itemIds) + if (mutationIds.length === 0 || !canMutateTimelineItems(mutationIds)) return + execute( 'RESET_SPEED_WITH_RIPPLE', () => { @@ -270,16 +467,7 @@ export function resetSpeedWithRipple(itemIds: string[]): void { ) for (const si of synchronizedItems) processedIds.add(si.id) - const sourceFps = item.sourceFps ?? fps - const effectiveSourceFrames = - item.sourceEnd !== undefined && item.sourceStart !== undefined - ? item.sourceEnd - item.sourceStart - : timelineToSourceFrames(item.durationInFrames, currentSpeed, fps, sourceFps) - - const newDuration = Math.max( - 1, - sourceToTimelineFrames(effectiveSourceFrames, 1, sourceFps, fps), - ) + const newDuration = calculateResetSpeedDuration(item, fps) const oldEnd = item.from + item.durationInFrames stretchOps.push({ diff --git a/src/features/timeline/stores/actions/edit/shared.ts b/src/features/timeline/stores/actions/edit/shared.ts index f3c66d044..ce3a91136 100644 --- a/src/features/timeline/stores/actions/edit/shared.ts +++ b/src/features/timeline/stores/actions/edit/shared.ts @@ -10,11 +10,26 @@ import { usePreviewBridgeStore } from '@/shared/state/preview-bridge' import { useItemsStore } from '../../items-store' import { useTransitionsStore } from '../../transitions-store' import { calculateTransitionPortions } from '@/shared/timeline/transitions/transition-planner' +import { preflightTimelineMutation } from '../../../utils/track-lock-invariants' export function isLinkedSelectionEnabled(): boolean { return useEditorStore.getState().linkedSelectionEnabled } +/** Public compound actions call this after planning their complete cohort and before execute(). */ +export function canMutateTimelineItems( + itemIds: Iterable, + destinationTrackIds: Iterable = [], +): boolean { + const { items, tracks } = useItemsStore.getState() + return preflightTimelineMutation({ + items, + tracks, + itemIds, + destinationTrackIds, + }).allowed +} + const POST_EDIT_WARM_MAX_FRAMES = 32 function appendWarmFrame(target: number[], seen: Set, frame: number): void { diff --git a/src/features/timeline/stores/actions/edit/split-actions.ts b/src/features/timeline/stores/actions/edit/split-actions.ts index 73308a5f5..ed96dde73 100644 --- a/src/features/timeline/stores/actions/edit/split-actions.ts +++ b/src/features/timeline/stores/actions/edit/split-actions.ts @@ -7,7 +7,7 @@ import { execute, applyTransitionRepairs } from '../shared' import { getLinkedItemsForEdit } from '../linked-edit' import { getUniqueLinkedItemAnchorIds } from '../../../utils/linked-items' import { applySplitBookkeeping, type SplitResultEntry } from '../split-bookkeeping' -import { isLinkedSelectionEnabled, isInTransitionOverlap } from './shared' +import { canMutateTimelineItems, isLinkedSelectionEnabled, isInTransitionOverlap } from './shared' import { emitUiSound } from '@/shared/ui/ui-sound' export function splitItem( @@ -16,6 +16,9 @@ export function splitItem( ): { leftItem: TimelineItem; rightItem: TimelineItem } | null { const items = useItemsStore.getState().items const itemsToSplit = getLinkedItemsForEdit(items, id, isLinkedSelectionEnabled()) + if (itemsToSplit.length === 0 || !canMutateTimelineItems(itemsToSplit.map((item) => item.id))) { + return null + } for (const item of itemsToSplit) { // Bounds check first — out-of-range splits are a silent no-op (handled by _splitItem), @@ -77,53 +80,58 @@ export function splitAllItemsAtFrame(splitFrame: number): number { if (anchorIds.length === 0) return 0 - let splitCount = 0 + const splitPlans = anchorIds.flatMap((anchorId) => { + const itemsToSplit = getLinkedItemsForEdit(items, anchorId, isLinkedSelectionEnabled()) + if (itemsToSplit.length === 0) return [] - execute( - 'SPLIT_ALL_ITEMS_AT_FRAME', - () => { - for (const anchorId of anchorIds) { - const currentItems = useItemsStore.getState().items - const itemsToSplit = getLinkedItemsForEdit( - currentItems, - anchorId, - isLinkedSelectionEnabled(), - ) - if (itemsToSplit.length === 0) continue - - let blockedByTransition = false - const canSplitGroup = itemsToSplit.every((item) => { - if (splitFrame <= item.from || splitFrame >= item.from + item.durationInFrames) { - return false - } + let blockedByTransition = false + const canSplitGroup = itemsToSplit.every((item) => { + if (splitFrame <= item.from || splitFrame >= item.from + item.durationInFrames) { + return false + } - const relativeFrame = splitFrame - item.from - if (isInTransitionOverlap(item.id, relativeFrame, item.durationInFrames)) { - blockedByTransition = true - return false - } + const relativeFrame = splitFrame - item.from + if (isInTransitionOverlap(item.id, relativeFrame, item.durationInFrames)) { + blockedByTransition = true + return false + } - return true - }) + return true + }) - if (!canSplitGroup) { - if (blockedByTransition) { - toast.warning('Cannot split inside a transition zone') - emitUiSound('error') - } - continue - } + if (!canSplitGroup) { + if (blockedByTransition) { + toast.warning('Cannot split inside a transition zone') + emitUiSound('error') + } + return [] + } - const splitResults = itemsToSplit - .map((item) => ({ - originalId: item.id, - originalLinkedGroupId: item.linkedGroupId, - result: useItemsStore.getState()._splitItem(item.id, splitFrame), - })) + const itemIds = itemsToSplit.map((item) => item.id) + return canMutateTimelineItems(itemIds) ? [{ anchorId, itemIds }] : [] + }) + + if (splitPlans.length === 0) return 0 + + let splitCount = 0 + + execute( + 'SPLIT_ALL_ITEMS_AT_FRAME', + () => { + for (const plan of splitPlans) { + const splitResults = plan.itemIds + .map((itemId) => { + const item = useItemsStore.getState().itemById[itemId] + return { + originalId: itemId, + originalLinkedGroupId: item?.linkedGroupId, + result: useItemsStore.getState()._splitItem(itemId, splitFrame), + } + }) .filter((entry): entry is SplitResultEntry => entry.result !== null) const anchorResult = - splitResults.find((entry) => entry.originalId === anchorId)?.result ?? null + splitResults.find((entry) => entry.originalId === plan.anchorId)?.result ?? null if (!anchorResult) continue applySplitBookkeeping(splitResults) @@ -137,7 +145,7 @@ export function splitAllItemsAtFrame(splitFrame: number): number { useTimelineSettingsStore.getState().markDirty() } }, - { ids: anchorIds, splitFrame }, + { ids: splitPlans.map((plan) => plan.anchorId), splitFrame }, ) if (splitCount > 0) emitUiSound('confirm') @@ -154,18 +162,19 @@ export function splitItemAtFrames(id: string, splitFrames: number[]): number { if (splitFrames.length === 0) return 0 const sorted = [...splitFrames].sort((a, b) => b - a) + const itemsToSplit = getLinkedItemsForEdit( + useItemsStore.getState().items, + id, + isLinkedSelectionEnabled(), + ) + if (itemsToSplit.length === 0 || !canMutateTimelineItems(itemsToSplit.map((item) => item.id))) { + return 0 + } let splitCount = 0 execute( 'SPLIT_ITEM_MULTI', () => { - const itemsToSplit = getLinkedItemsForEdit( - useItemsStore.getState().items, - id, - isLinkedSelectionEnabled(), - ) - if (itemsToSplit.length === 0) return - const rightIdsByOriginalId = new Map(itemsToSplit.map((item) => [item.id, [] as string[]])) for (const frame of sorted) { diff --git a/src/features/timeline/stores/actions/edit/trim-actions.ts b/src/features/timeline/stores/actions/edit/trim-actions.ts index 81391ec0b..4410e8f21 100644 --- a/src/features/timeline/stores/actions/edit/trim-actions.ts +++ b/src/features/timeline/stores/actions/edit/trim-actions.ts @@ -8,7 +8,10 @@ import { getSynchronizedLinkedCounterpartPairForEdit, getSynchronizedLinkedItemsForEdit, } from '../linked-edit' -import { getAttachedCaptionItemIds } from '../../../utils/linked-items' +import { + expandItemIdsWithAttachedCaptions, + getAttachedCaptionItemIds, +} from '../../../utils/linked-items' import { computeClampedSlipDelta } from '../../../utils/slip-utils' import { computeSlideContinuitySourceDelta } from '../../../utils/slide-utils' import { clampSlideDeltaToPreserveKeyframes } from '../../../utils/slide-keyframe-constraints' @@ -24,11 +27,17 @@ import { clampSlipDeltaToPreserveTransitions, } from '../../../utils/transition-utils' import { + buildInsertedGapPreviewUpdatesForSyncLockedTracks, + buildRemovedIntervalPreviewUpdatesForSyncLockedTracks, propagateInsertedGapToSyncLockedTracks, propagateRemovedIntervalsToSyncLockedTracks, } from '../sync-lock-ripple' -import { isLinkedSelectionEnabled, requestPostEditWarmForItems } from './shared' -import type { TimelineItem } from '@/types/timeline' +import { + canMutateTimelineItems, + isLinkedSelectionEnabled, + requestPostEditWarmForItems, +} from './shared' +import type { TimelineItem, TimelineTrack } from '@/types/timeline' function keepTightestDelta(requested: number, candidate: number): number { return requested < 0 ? Math.max(requested, candidate) : Math.min(requested, candidate) @@ -73,6 +82,249 @@ function getSynchronizedTrimItems( return Array.from(synchronizedById.values()) } +function getSynchronizedTrimMutationIds( + id: string, + handle: 'start' | 'end', + trimAmount: number, + options: SynchronizedTrimOptions, +): string[] { + const items = useItemsStore.getState().items + const synchronizedItems = getSynchronizedTrimItems(items, id, options) + if (!synchronizedItems.some((item) => item.id === id)) return [] + + const synchronizedIds = synchronizedItems.map((item) => item.id) + const shrinksVisibleBounds = handle === 'start' ? trimAmount > 0 : trimAmount < 0 + return shrinksVisibleBounds + ? expandItemIdsWithAttachedCaptions(items, synchronizedIds) + : synchronizedIds +} + +function getClampedRippleTrimDelta(params: { + items: TimelineItem[] + synced: TimelineItem[] + syncedIds: Set + handle: 'start' | 'end' + trimDelta: number +}): number { + const transitions = useTransitionsStore.getState().transitions + const keyframesByItemId = useKeyframesStore.getState().keyframesByItemId + const timelineFps = useTimelineSettingsStore.getState().fps + let clampedTrimDelta = params.trimDelta + for (const syncedItem of params.synced) { + clampedTrimDelta = keepTightestDelta( + clampedTrimDelta, + clampRippleTrimDeltaToPreserveEditState( + syncedItem, + params.handle, + clampedTrimDelta, + params.items, + transitions, + keyframesByItemId, + timelineFps, + params.syncedIds, + false, + ), + ) + } + return clampedTrimDelta +} + +function addRippleTrimDownstreamMutationIds(params: { + items: TimelineItem[] + synced: TimelineItem[] + syncedIds: Set + mutationIds: Set +}): void { + const transitions = useTransitionsStore.getState().transitions + for (const syncedItem of params.synced) { + const oldSyncedEnd = syncedItem.from + syncedItem.durationInFrames + const transitionNeighbors = new Set( + transitions + .filter((transition) => transition.leftClipId === syncedItem.id) + .map((transition) => transition.rightClipId), + ) + for (const candidate of params.items) { + if (params.syncedIds.has(candidate.id) || candidate.trackId !== syncedItem.trackId) continue + if (candidate.from >= oldSyncedEnd || transitionNeighbors.has(candidate.id)) { + params.mutationIds.add(candidate.id) + } + } + } +} + +function addRippleTrimSyncLockMutationIds(params: { + items: TimelineItem[] + tracks: TimelineTrack[] + synced: TimelineItem[] + oldEnd: number + shift: number + mutationIds: Set +}): void { + const editedTrackIds = new Set(params.synced.map((candidate) => candidate.trackId)) + const additionalAffectedIds = new Set(params.mutationIds) + const previewUpdates = + params.shift < 0 + ? buildRemovedIntervalPreviewUpdatesForSyncLockedTracks({ + items: params.items, + tracks: params.tracks, + editedTrackIds, + intervals: [{ start: params.oldEnd + params.shift, end: params.oldEnd }], + additionalAffectedIds, + }) + : buildInsertedGapPreviewUpdatesForSyncLockedTracks({ + items: params.items, + tracks: params.tracks, + editedTrackIds, + cutFrame: params.oldEnd, + amount: params.shift, + additionalAffectedIds, + }) + for (const update of previewUpdates) params.mutationIds.add(update.id) +} + +function getRippleTrimMutationIds( + id: string, + handle: 'start' | 'end', + trimDelta: number, +): string[] { + const store = useItemsStore.getState() + const item = store.itemById[id] + if (!item) return [] + + const synced = getSynchronizedLinkedItemsForEdit(store.items, id, isLinkedSelectionEnabled()) + const syncedIds = new Set(synced.map((candidate) => candidate.id)) + const clampedTrimDelta = getClampedRippleTrimDelta({ + items: store.items, + synced, + syncedIds, + handle, + trimDelta, + }) + if (clampedTrimDelta === 0) return [] + + const mutationIds = new Set(syncedIds) + const shift = handle === 'end' ? clampedTrimDelta : -clampedTrimDelta + const oldEnd = item.from + item.durationInFrames + addRippleTrimDownstreamMutationIds({ items: store.items, synced, syncedIds, mutationIds }) + + const shrinksVisibleBounds = handle === 'start' ? clampedTrimDelta > 0 : clampedTrimDelta < 0 + if (shrinksVisibleBounds) { + for (const captionId of expandItemIdsWithAttachedCaptions(store.items, [...syncedIds])) { + mutationIds.add(captionId) + } + } + + if (shift !== 0) { + addRippleTrimSyncLockMutationIds({ + items: store.items, + tracks: store.tracks, + synced, + oldEnd, + shift, + mutationIds, + }) + } + + return Array.from(mutationIds) +} + +function getOptionalItem(items: TimelineItem[], itemId: string | null): TimelineItem | null { + return itemId ? (items.find((candidate) => candidate.id === itemId) ?? null) : null +} + +function getOptionalSynchronizedCounterpart(params: { + items: TimelineItem[] + neighborId: string | null + trackId: string + type: TimelineItem['type'] +}): TimelineItem | null { + if (!params.neighborId) return null + return getMatchingSynchronizedLinkedCounterpartForEdit( + params.items, + params.neighborId, + params.trackId, + params.type, + isLinkedSelectionEnabled(), + ) +} + +function getSlideCounterpartMutationIds(items: TimelineItem[], id: string): string[] { + const synchronizedCounterpart = + getSynchronizedLinkedItemsForEdit(items, id, isLinkedSelectionEnabled()).find( + (candidate) => candidate.id !== id, + ) ?? null + return synchronizedCounterpart ? [synchronizedCounterpart.id] : [] +} + +function getSlideCounterpartNeighborMutationIds(params: { + items: TimelineItem[] + id: string + leftNeighborId: string | null + rightNeighborId: string | null +}): string[] { + const synchronizedCounterpart = + getSynchronizedLinkedItemsForEdit(params.items, params.id, isLinkedSelectionEnabled()).find( + (candidate) => candidate.id !== params.id, + ) ?? null + if (!synchronizedCounterpart) return [] + + const counterpartEnd = synchronizedCounterpart.from + synchronizedCounterpart.durationInFrames + const leftCounterpart = getOptionalSynchronizedCounterpart({ + items: params.items, + neighborId: params.leftNeighborId, + trackId: synchronizedCounterpart.trackId, + type: synchronizedCounterpart.type, + }) + const rightCounterpart = getOptionalSynchronizedCounterpart({ + items: params.items, + neighborId: params.rightNeighborId, + trackId: synchronizedCounterpart.trackId, + type: synchronizedCounterpart.type, + }) + const cpLeftAdj = + params.items.find( + (candidate) => + candidate.trackId === synchronizedCounterpart.trackId && + candidate.id !== synchronizedCounterpart.id && + candidate.from + candidate.durationInFrames === synchronizedCounterpart.from, + ) ?? leftCounterpart + const cpRightAdj = + params.items.find( + (candidate) => + candidate.trackId === synchronizedCounterpart.trackId && + candidate.id !== synchronizedCounterpart.id && + candidate.from === counterpartEnd, + ) ?? rightCounterpart + return [cpLeftAdj?.id, cpRightAdj?.id].filter((itemId): itemId is string => !!itemId) +} + +function getSlideMutationIds( + id: string, + leftNeighborId: string | null, + rightNeighborId: string | null, +): string[] { + const items = useItemsStore.getState().items + if (!items.some((candidate) => candidate.id === id)) return [] + + const mutationIds = new Set([id]) + const leftNeighbor = getOptionalItem(items, leftNeighborId) + const rightNeighbor = getOptionalItem(items, rightNeighborId) + if (leftNeighbor) mutationIds.add(leftNeighbor.id) + if (rightNeighbor) mutationIds.add(rightNeighbor.id) + for (const counterpartId of getSlideCounterpartMutationIds(items, id)) { + mutationIds.add(counterpartId) + } + for (const neighborId of getSlideCounterpartNeighborMutationIds({ + items, + id, + leftNeighborId, + rightNeighborId, + })) { + mutationIds.add(neighborId) + } + return Array.from(mutationIds) +} + function clampSlideParticipantDelta( requestedDelta: number, item: TimelineItem, @@ -255,6 +507,9 @@ export function trimItemStart( trimAmount: number, options: SynchronizedTrimOptions = {}, ): void { + const mutationIds = getSynchronizedTrimMutationIds(id, 'start', trimAmount, options) + if (mutationIds.length === 0 || !canMutateTimelineItems(mutationIds)) return + execute( 'TRIM_ITEM_START', () => { @@ -269,6 +524,9 @@ export function trimItemEnd( trimAmount: number, options: SynchronizedTrimOptions = {}, ): void { + const mutationIds = getSynchronizedTrimMutationIds(id, 'end', trimAmount, options) + if (mutationIds.length === 0 || !canMutateTimelineItems(mutationIds)) return + execute( 'TRIM_ITEM_END', () => { @@ -285,6 +543,14 @@ export function trimItemBreakingTransition( transitionIdsToRemove: string[], options: Pick = {}, ): void { + const mutationIds = new Set(getSynchronizedTrimMutationIds(id, handle, trimAmount, options)) + for (const transition of useTransitionsStore.getState().transitions) { + if (!transitionIdsToRemove.includes(transition.id)) continue + mutationIds.add(transition.leftClipId) + mutationIds.add(transition.rightClipId) + } + if (mutationIds.size === 0 || !canMutateTimelineItems(mutationIds)) return + execute( handle === 'start' ? 'TRIM_ITEM_START' : 'TRIM_ITEM_END', () => { @@ -321,6 +587,9 @@ export function trimItemBreakingTransition( */ export function rippleTrimItem(id: string, handle: 'start' | 'end', trimDelta: number): void { if (trimDelta === 0) return + const mutationIds = getRippleTrimMutationIds(id, handle, trimDelta) + if (mutationIds.length === 0 || !canMutateTimelineItems(mutationIds)) return + execute( 'RIPPLE_EDIT', () => { @@ -473,6 +742,23 @@ export function rippleTrimItem(id: string, handle: 'start' | 'end', trimDelta: n export function rollingTrimItems(leftId: string, rightId: string, editPointDelta: number): void { if (editPointDelta === 0) return + const items = useItemsStore.getState().items + const leftItem = items.find((item) => item.id === leftId) + const rightItem = items.find((item) => item.id === rightId) + if (!leftItem || !rightItem) return + const mutationIds = new Set([leftId, rightId]) + const counterpartPair = getSynchronizedLinkedCounterpartPairForEdit( + items, + leftId, + rightId, + isLinkedSelectionEnabled(), + ) + if (counterpartPair) { + mutationIds.add(counterpartPair.leftCounterpart.id) + mutationIds.add(counterpartPair.rightCounterpart.id) + } + if (!canMutateTimelineItems(mutationIds)) return + execute( 'ROLLING_EDIT', () => { @@ -578,6 +864,18 @@ export function rollingTrimItems(leftId: string, rightId: string, editPointDelta export function slipItem(id: string, slipDelta: number): void { if (slipDelta === 0) return + const items = useItemsStore.getState().items + const item = items.find((candidate) => candidate.id === id) + if ( + !item || + (item.type !== 'video' && item.type !== 'audio' && item.type !== 'composition') || + item.sourceEnd === undefined + ) { + return + } + const synchronizedItems = getSynchronizedLinkedItemsForEdit(items, id, isLinkedSelectionEnabled()) + if (!canMutateTimelineItems(synchronizedItems.map((candidate) => candidate.id))) return + execute( 'SLIP_EDIT', () => { @@ -657,6 +955,8 @@ export function slideItem( rightNeighborId: string | null, ): void { if (slideDelta === 0) return + const mutationIds = getSlideMutationIds(id, leftNeighborId, rightNeighborId) + if (mutationIds.length === 0 || !canMutateTimelineItems(mutationIds)) return execute( 'SLIDE_EDIT', diff --git a/src/features/timeline/stores/actions/item-actions.ts b/src/features/timeline/stores/actions/item-actions.ts index 1c1a28729..ae4fa6b5c 100644 --- a/src/features/timeline/stores/actions/item-actions.ts +++ b/src/features/timeline/stores/actions/item-actions.ts @@ -71,6 +71,9 @@ const LOCK_PROTECTED_ITEM_FIELDS = new Set([ 'offset', 'isReversed', 'reverseConformLocalStart', + 'reversed', + 'segmentStart', + 'segmentEnd', ]) function isLinkedSelectionEnabled(): boolean { @@ -85,6 +88,12 @@ function changesLockedItemPlacement(item: TimelineItem, updates: Partial): boolean { + const updateRecord = updates as Record + const itemRecord = item as unknown as Record + return Object.keys(updateRecord).some((key) => updateRecord[key] !== itemRecord[key]) +} + function areItemMutationsUnlocked(itemIds: Iterable): boolean { const { items, tracks } = useItemsStore.getState() return partitionItemMutationIdsByLock({ items, tracks, itemIds }).blockedIds.length === 0 @@ -769,6 +778,7 @@ export function addItemsOnNewTracks(items: TimelineItem[], tracks: TimelineTrack export function updateItem(id: string, updates: Partial): void { const item = useItemsStore.getState().itemById[id] if (!item) return + if (!changesItem(item, updates)) return if (changesLockedItemPlacement(item, updates) && !areItemMutationsUnlocked([id])) return if ( updates.trackId && diff --git a/src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts b/src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts new file mode 100644 index 000000000..383764c72 --- /dev/null +++ b/src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts @@ -0,0 +1,336 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it } from 'vite-plus/test' +import type { LottieItem, TextItem, TimelineItem, TimelineTrack } from '@/types/timeline' +import type { Transition } from '@/types/transition' +import { useEditorStore } from '@/shared/state/editor' +import { makeTimelineAudioItem, makeTimelineTrack, makeTimelineVideoItem } from '../../test-helpers' +import { useItemsStore } from '../items-store' +import { useKeyframesStore } from '../keyframes-store' +import { useTimelineCommandStore } from '../timeline-command-store' +import { useTimelineSettingsStore } from '../timeline-settings-store' +import { useTransitionsStore } from '../transitions-store' +import { + insertFreezeFrame, + joinItems, + rateStretchItem, + rateStretchItemWithoutHistory, + removeSilenceFromItems, + resetSpeedWithRipple, + rippleTrimItem, + rollingTrimItems, + slideItem, + slipItem, + splitAllItemsAtFrame, + splitItem, + splitItemAtFrames, + trimItemBreakingTransition, + trimItemEnd, + trimItemStart, +} from './item-edit-actions' +import { updateItem } from './item-actions' + +function tracks(overrides: Partial = {}): TimelineTrack[] { + return [ + makeTimelineTrack({ + id: 'video-track', + name: 'V1', + kind: 'video', + order: 0, + ...overrides, + }), + makeTimelineTrack({ id: 'audio-track', name: 'A1', kind: 'audio', order: 1 }), + makeTimelineTrack({ id: 'caption-track', name: 'Captions', order: 2 }), + ] +} + +function video(overrides: Partial> = {}) { + return makeTimelineVideoItem({ + id: 'middle', + trackId: 'video-track', + from: 60, + durationInFrames: 60, + sourceStart: 30, + sourceEnd: 90, + sourceDuration: 180, + ...overrides, + }) +} + +function transition(): Transition { + return { + id: 'transition-1', + type: 'crossfade', + presentation: 'fade', + timing: 'linear', + leftClipId: 'left', + rightClipId: 'middle', + trackId: 'video-track', + durationInFrames: 10, + } +} + +function snapshot() { + return { + items: structuredClone(useItemsStore.getState().items), + tracks: structuredClone(useItemsStore.getState().tracks), + transitions: structuredClone(useTransitionsStore.getState().transitions), + keyframes: structuredClone(useKeyframesStore.getState().keyframes), + undoDepth: useTimelineCommandStore.getState().undoStack.length, + redoDepth: useTimelineCommandStore.getState().redoStack.length, + dirty: useTimelineSettingsStore.getState().isDirty, + } +} + +function expectUnchanged(before: ReturnType): void { + expect(useItemsStore.getState().items).toEqual(before.items) + expect(useItemsStore.getState().tracks).toEqual(before.tracks) + expect(useTransitionsStore.getState().transitions).toEqual(before.transitions) + expect(useKeyframesStore.getState().keyframes).toEqual(before.keyframes) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(before.undoDepth) + expect(useTimelineCommandStore.getState().redoStack).toHaveLength(before.redoDepth) + expect(useTimelineSettingsStore.getState().isDirty).toBe(before.dirty) +} + +describe('public item edit lock preflights', () => { + beforeEach(() => { + useEditorStore.setState({ linkedSelectionEnabled: true }) + useItemsStore.getState().setTracks(tracks()) + useItemsStore.getState().setItems([]) + useTransitionsStore.getState().setTransitions([]) + useKeyframesStore.getState().setKeyframes([]) + useTimelineCommandStore.getState().clearHistory() + useTimelineSettingsStore.setState({ fps: 30, isDirty: false }) + }) + + it.each([ + ['normal trim start', () => trimItemStart('middle', 10)], + ['normal trim end', () => trimItemEnd('middle', -10)], + ['ripple trim', () => rippleTrimItem('middle', 'end', -10)], + ['rolling trim', () => rollingTrimItems('left', 'middle', 10)], + ['slip', () => slipItem('middle', 10)], + ['slide', () => slideItem('middle', 10, 'left', 'right')], + ])('rejects %s before any item, transition, dirty, or history change', (_name, action) => { + useItemsStore.getState().setTracks(tracks({ locked: true })) + useItemsStore + .getState() + .setItems([ + video({ id: 'left', from: 0, sourceStart: 0, sourceEnd: 60 }), + video(), + video({ id: 'right', from: 120, sourceStart: 60, sourceEnd: 120 }), + ]) + useTransitionsStore.getState().setTransitions([transition()]) + const before = snapshot() + + action() + + expectUnchanged(before) + }) + + it('preflights a transition-breaking trim before removing the transition', () => { + useItemsStore.getState().setTracks(tracks({ locked: true })) + useItemsStore + .getState() + .setItems([video({ id: 'left', from: 0, sourceStart: 0, sourceEnd: 60 }), video()]) + useTransitionsStore.getState().setTransitions([transition()]) + const before = snapshot() + + trimItemBreakingTransition('middle', 'start', 10, ['transition-1']) + + expectUnchanged(before) + }) + + it.each([true, false])( + 'rejects the live-QA linked A/V trim and split when linked selection is %s', + (linkedSelectionEnabled) => { + useEditorStore.setState({ linkedSelectionEnabled }) + useItemsStore.getState().setTracks([ + tracks()[0]!, + makeTimelineTrack({ + id: 'audio-track', + name: 'A1', + kind: 'audio', + order: 1, + locked: true, + }), + ]) + useItemsStore.getState().setItems([ + video({ linkedGroupId: 'linked-av' }), + makeTimelineAudioItem({ + id: 'audio', + trackId: 'audio-track', + from: 60, + durationInFrames: 60, + sourceStart: 30, + sourceEnd: 90, + sourceDuration: 180, + linkedGroupId: 'linked-av', + }), + ]) + const before = snapshot() + + trimItemEnd('middle', -10) + expect(splitItem('middle', 90)).toBeNull() + + expectUnchanged(before) + }, + ) + + it('rejects every split entry point and join on an effectively locked group child', () => { + const group = makeTimelineTrack({ + id: 'group', + name: 'Locked Group', + order: 0, + isGroup: true, + locked: true, + }) + const child = makeTimelineTrack({ + id: 'video-track', + name: 'Layer', + order: 1, + kind: 'video', + parentTrackId: group.id, + }) + useItemsStore.getState().setTracks([group, child]) + useItemsStore + .getState() + .setItems([ + video({ id: 'left', from: 0, durationInFrames: 60 }), + video({ id: 'right', from: 60, durationInFrames: 60 }), + ]) + const before = snapshot() + + expect(splitItem('left', 30)).toBeNull() + expect(splitAllItemsAtFrame(30)).toBe(0) + expect(splitItemAtFrames('left', [20, 40])).toBe(0) + joinItems(['left', 'right']) + + expectUnchanged(before) + }) + + it('rejects rate stretch, reset-speed ripple, and freeze-frame insertion atomically', async () => { + useItemsStore.getState().setTracks(tracks({ locked: true })) + useItemsStore.getState().setItems([video({ speed: 2 }), video({ id: 'right', from: 120 })]) + const before = snapshot() + + rateStretchItem('middle', 60, 90, 1) + rateStretchItemWithoutHistory('middle', 60, 90, 1) + resetSpeedWithRipple(['middle']) + await expect(insertFreezeFrame('middle', 90)).resolves.toBe(false) + + expectUnchanged(before) + }) + + it('rejects range removal before its first split when a linked companion is locked', () => { + useEditorStore.setState({ linkedSelectionEnabled: false }) + useItemsStore.getState().setTracks([ + tracks()[0]!, + makeTimelineTrack({ + id: 'audio-track', + name: 'A1', + kind: 'audio', + order: 1, + locked: true, + }), + ]) + useItemsStore.getState().setItems([ + video({ id: 'video', from: 0, linkedGroupId: 'linked-av' }), + makeTimelineAudioItem({ + id: 'audio', + trackId: 'audio-track', + linkedGroupId: 'linked-av', + sourceStart: 30, + sourceEnd: 90, + sourceDuration: 180, + }), + ]) + const before = snapshot() + + const result = removeSilenceFromItems(['video'], { + 'media-1': [{ start: 1.5, end: 2 }], + }) + + expect(result).toMatchObject({ removedItemCount: 0, splitCount: 0 }) + expectUnchanged(before) + }) + + it('rejects normal trim when attached caption repair would mutate a locked caption', () => { + const caption: TextItem = { + id: 'caption', + type: 'text', + trackId: 'caption-track', + from: 100, + durationInFrames: 30, + label: 'Caption', + text: 'Caption', + color: '#fff', + textRole: 'caption', + captionSource: { type: 'transcript', clipId: 'middle', mediaId: 'media-1' }, + } + useItemsStore.getState().setTracks([ + tracks()[0]!, + makeTimelineTrack({ + id: 'caption-track', + name: 'Captions', + order: 1, + locked: true, + }), + ]) + useItemsStore.getState().setItems([video(), caption]) + const before = snapshot() + + trimItemEnd('middle', -30) + + expectUnchanged(before) + }) + + it.each([ + ['reversed', { reversed: true }], + ['segmentStart', { segmentStart: 12 }], + ['segmentEnd', { segmentEnd: 48 }], + ] as const)('protects the Lottie %s field on locked tracks', (_field, updates) => { + const lottie: LottieItem = { + id: 'lottie', + type: 'lottie', + trackId: 'video-track', + from: 0, + durationInFrames: 60, + label: 'Animation', + src: 'blob:lottie', + frameRate: 30, + totalFrames: 60, + } + useItemsStore.getState().setTracks(tracks({ locked: true })) + useItemsStore.getState().setItems([lottie]) + const before = snapshot() + + updateItem('lottie', updates) + + expectUnchanged(before) + }) + + it('allows Lottie timing controls on an unlocked standalone item', () => { + const lottie: LottieItem = { + id: 'lottie', + type: 'lottie', + trackId: 'video-track', + from: 0, + durationInFrames: 60, + label: 'Animation', + src: 'blob:lottie', + frameRate: 30, + totalFrames: 60, + } + useItemsStore.getState().setItems([lottie]) + + updateItem('lottie', { reversed: true, segmentStart: 12, segmentEnd: 48 }) + + expect(useItemsStore.getState().itemById.lottie).toMatchObject({ + reversed: true, + segmentStart: 12, + segmentEnd: 48, + }) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + expect(useTimelineSettingsStore.getState().isDirty).toBe(true) + }) +}) diff --git a/src/features/timeline/stores/actions/source-edit-actions.test.ts b/src/features/timeline/stores/actions/source-edit-actions.test.ts index 80eabd236..d4b331e64 100644 --- a/src/features/timeline/stores/actions/source-edit-actions.test.ts +++ b/src/features/timeline/stores/actions/source-edit-actions.test.ts @@ -5,6 +5,7 @@ import type { TimelineItem } from '@/types/timeline' const mocks = vi.hoisted(() => ({ mediaById: {} as Record, + resolveMediaUrl: async (): Promise => 'blob:source-media', })) vi.mock('@/features/timeline/deps/media-library-store', () => ({ @@ -38,7 +39,7 @@ vi.mock('@/features/timeline/deps/media-library-resolver', () => ({ : mimeType.startsWith('image') ? 'image' : 'unknown', - resolveMediaUrl: async () => 'blob:source-media', + resolveMediaUrl: () => mocks.resolveMediaUrl(), })) import { makeTimelineTrack, makeTimelineVideoItem } from '../../test-helpers' @@ -98,6 +99,7 @@ describe('source edit actions', () => { }) useSourcePlayerStore.setState({ inPoint: 30, outPoint: 90 }) resetPlaybackPreviewState(0) + mocks.resolveMediaUrl = async () => 'blob:source-media' setSourceMedia() }) @@ -198,6 +200,68 @@ describe('source edit actions', () => { expect(audioItems[0]?.linkedGroupId).toBe(videoItems[0]?.linkedGroupId) expect(audioItems[0]).toMatchObject({ from: 0, durationInFrames: 60 }) }) + + it('treats an inherited group lock as a locked target lane', async () => { + useItemsStore.getState().setTracks([ + makeTimelineTrack({ + id: 'group', + name: 'Locked Group', + order: 0, + isGroup: true, + locked: true, + }), + makeTimelineTrack({ + id: 'track-v1', + name: 'V1', + kind: 'video', + order: 1, + parentTrackId: 'group', + }), + ]) + usePlaybackStore.setState({ currentFrame: 0 }) + + await performInsertEdit() + + expect(trackItems('track-v1')).toHaveLength(0) + const inserted = useItemsStore.getState().items + expect(inserted).toHaveLength(1) + expect(inserted[0]?.trackId).not.toBe('track-v1') + }) + + it('revalidates target locks immediately before the async commit', async () => { + let releaseUrl!: (url: string) => void + let reportStarted!: () => void + const started = new Promise((resolve) => { + reportStarted = resolve + }) + mocks.resolveMediaUrl = () => + new Promise((resolve) => { + releaseUrl = resolve + reportStarted() + }) + usePlaybackStore.setState({ currentFrame: 0 }) + + const pendingEdit = performInsertEdit() + await started + useItemsStore.getState().setTracks([ + makeTimelineTrack({ + id: 'track-v1', + name: 'V1', + kind: 'video', + order: 0, + locked: true, + }), + makeTimelineTrack({ id: 'track-a1', name: 'A1', kind: 'audio', order: 1 }), + ]) + releaseUrl('blob:source-media') + await pendingEdit + + expect(useItemsStore.getState().items).toHaveLength(0) + expect(useItemsStore.getState().tracks[0]?.locked).toBe(true) + expect(usePlaybackStore.getState().currentFrame).toBe(0) + expect(useTimelineSettingsStore.getState().isDirty).toBe(false) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + }) }) describe('performOverwriteEdit', () => { @@ -256,4 +320,57 @@ describe('source edit actions', () => { expect(items[1]).toMatchObject({ from: 40, durationInFrames: 60, mediaId: 'media-1' }) }) }) + + it.each([ + ['insert', performInsertEdit], + ['overwrite', performOverwriteEdit], + ])( + 'rejects %s atomically when an overlapping target clip has a locked linked companion', + async (_name, action) => { + for (const linkedSelectionEnabled of [true, false]) { + useTimelineCommandStore.getState().clearHistory() + useTimelineSettingsStore.setState({ isDirty: false }) + useEditorStore.setState({ linkedSelectionEnabled }) + useItemsStore.getState().setTracks([ + makeTimelineTrack({ id: 'track-v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ + id: 'track-a1', + name: 'A1', + kind: 'audio', + order: 1, + locked: true, + }), + ]) + const linkedVideo = makeTimelineVideoItem({ + id: 'existing-video', + trackId: 'track-v1', + from: 0, + durationInFrames: 120, + sourceEnd: 120, + sourceDuration: 120, + linkedGroupId: 'linked-av', + }) + const linkedAudio: TimelineItem = { + ...linkedVideo, + id: 'existing-audio', + type: 'audio', + trackId: 'track-a1', + src: 'blob:audio', + } + useItemsStore.getState().setItems([linkedVideo, linkedAudio]) + usePlaybackStore.setState({ currentFrame: 30 }) + const itemsBefore = structuredClone(useItemsStore.getState().items) + const transitionsBefore = structuredClone(useTransitionsStore.getState().transitions) + const playheadBefore = usePlaybackStore.getState().currentFrame + + await action() + + expect(useItemsStore.getState().items).toEqual(itemsBefore) + expect(useTransitionsStore.getState().transitions).toEqual(transitionsBefore) + expect(usePlaybackStore.getState().currentFrame).toBe(playheadBefore) + expect(useTimelineSettingsStore.getState().isDirty).toBe(false) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + } + }, + ) }) diff --git a/src/features/timeline/stores/actions/source-edit-actions.ts b/src/features/timeline/stores/actions/source-edit-actions.ts index a4642f22c..589dd4eb7 100644 --- a/src/features/timeline/stores/actions/source-edit-actions.ts +++ b/src/features/timeline/stores/actions/source-edit-actions.ts @@ -19,6 +19,7 @@ import { resolveSourceEditTrackTargets } from '../../utils/source-edit-targeting import { buildMediaTimelineItems } from '../../utils/media-timeline-item-builder' import { DEFAULT_TRACK_HEIGHT } from '../../constants' import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' +import { isTimelineTrackLocked, preflightTimelineMutation } from '../../utils/track-lock-invariants' interface SourceEditContext { sourceMediaId: string @@ -138,7 +139,9 @@ async function resolveSourceEditContext(): Promise { (trackId): trackId is string => !!trackId, ) const lockedTarget = resolvedTargets.tracks.find( - (timelineTrack) => targetTrackIds.includes(timelineTrack.id) && timelineTrack.locked, + (timelineTrack) => + targetTrackIds.includes(timelineTrack.id) && + isTimelineTrackLocked(resolvedTargets.tracks, timelineTrack.id), ) if (lockedTarget) { toast.warning(`Target track ${lockedTarget.name} is locked`) @@ -230,6 +233,40 @@ function createTimelineItems(ctx: SourceEditContext) { }) } +function getSourceEditPreflightTracks(resolvedTracks: TimelineTrack[]): TimelineTrack[] { + const currentTracks = useItemsStore.getState().tracks + const currentTrackIds = new Set(currentTracks.map((track) => track.id)) + return [...currentTracks, ...resolvedTracks.filter((track) => !currentTrackIds.has(track.id))] +} + +function canCommitSourceEdit(params: { + mode: 'insert' | 'overwrite' + targetTrackIds: string[] + resolvedTracks: TimelineTrack[] + start: number + end: number +}): boolean { + const { items } = useItemsStore.getState() + const targetTrackIdSet = new Set(params.targetTrackIds) + const mutationIds = items + .filter((item) => { + if (!targetTrackIdSet.has(item.trackId)) return false + const itemEnd = item.from + item.durationInFrames + return params.mode === 'insert' + ? (item.from < params.start && itemEnd > params.start) || item.from >= params.start + : item.from < params.end && itemEnd > params.start + }) + .map((item) => item.id) + const tracks = getSourceEditPreflightTracks(params.resolvedTracks) + + return preflightTimelineMutation({ + items, + tracks, + itemIds: mutationIds, + destinationTrackIds: params.targetTrackIds, + }).allowed +} + export async function performInsertEdit(): Promise { const ctx = await resolveSourceEditContext() if (!ctx) return @@ -241,6 +278,17 @@ export async function performInsertEdit(): Promise { toast.warning('Unable to resolve source patch targets') return } + if ( + !canCommitSourceEdit({ + mode: 'insert', + targetTrackIds, + resolvedTracks: ctx.resolvedTracks, + start: insertFrame, + end: insertFrame, + }) + ) { + return + } execute( 'INSERT_EDIT', @@ -306,6 +354,17 @@ export async function performOverwriteEdit(): Promise { toast.warning('Unable to resolve source patch targets') return } + if ( + !canCommitSourceEdit({ + mode: 'overwrite', + targetTrackIds, + resolvedTracks: ctx.resolvedTracks, + start: overwriteStart, + end: overwriteEnd, + }) + ) { + return + } execute( 'OVERWRITE_EDIT', diff --git a/src/features/timeline/utils/source-edit-targeting.ts b/src/features/timeline/utils/source-edit-targeting.ts index 6df59f51e..38c569385 100644 --- a/src/features/timeline/utils/source-edit-targeting.ts +++ b/src/features/timeline/utils/source-edit-targeting.ts @@ -6,6 +6,7 @@ import { renameTrackForKind, type TrackKind, } from './classic-tracks' +import { isTimelineTrackLocked } from './track-lock-invariants' interface EnsureTrackForKindParams { tracks: TimelineTrack[] @@ -28,7 +29,12 @@ function findFirstUnlockedTrackByKind( ): TimelineTrack | null { return ( [...tracks] - .filter((track) => !track.locked && !track.isGroup && getTrackKind(track) === kind) + .filter( + (track) => + !track.isGroup && + !isTimelineTrackLocked(tracks, track.id) && + getTrackKind(track) === kind, + ) .sort((a, b) => a.order - b.order)[0] ?? null ) } @@ -39,11 +45,15 @@ function findUnlockedTrackById( ): TimelineTrack | null { if (!trackId) return null const track = tracks.find((candidate) => candidate.id === trackId) - return track && !track.locked && !track.isGroup ? track : null + return track && !track.isGroup && !isTimelineTrackLocked(tracks, track.id) ? track : null } -function canUseTrackForKind(track: TimelineTrack | null, kind: TrackKind): track is TimelineTrack { - if (!track || track.locked || track.isGroup) { +function canUseTrackForKind( + tracks: TimelineTrack[], + track: TimelineTrack | null, + kind: TrackKind, +): track is TimelineTrack { + if (!track || track.isGroup || isTimelineTrackLocked(tracks, track.id)) { return false } @@ -93,7 +103,7 @@ function resolveTargetTrackForKind(params: { } = params const preferredTrack = findUnlockedTrackById(tracks, preferredTrackId) - if (canUseTrackForKind(preferredTrack, kind)) { + if (canUseTrackForKind(tracks, preferredTrack, kind)) { return ensureTrackForKind({ tracks, targetTrack: preferredTrack, @@ -104,7 +114,7 @@ function resolveTargetTrackForKind(params: { }) } - if (canUseTrackForKind(fallbackTrack, kind)) { + if (canUseTrackForKind(tracks, fallbackTrack, kind)) { return ensureTrackForKind({ tracks, targetTrack: fallbackTrack, @@ -145,7 +155,10 @@ function findNearestUnlockedTrackByKind( direction: 'above' | 'below', ): TimelineTrack | null { const candidates = tracks - .filter((track) => !track.locked && !track.isGroup && getTrackKind(track) === kind) + .filter( + (track) => + !track.isGroup && !isTimelineTrackLocked(tracks, track.id) && getTrackKind(track) === kind, + ) .filter((track) => direction === 'above' ? track.order < targetTrack.order : track.order > targetTrack.order, ) @@ -167,7 +180,7 @@ function ensureTrackForKind(params: EnsureTrackForKindParams): { preferTarget = false, } = params - if (targetTrack.locked) { + if (isTimelineTrackLocked(tracks, targetTrack.id)) { const existingTrack = findNearestUnlockedTrackByKind( tracks, targetTrack, diff --git a/src/features/timeline/utils/track-lock-invariants.ts b/src/features/timeline/utils/track-lock-invariants.ts index 434c7e7f5..b62355308 100644 --- a/src/features/timeline/utils/track-lock-invariants.ts +++ b/src/features/timeline/utils/track-lock-invariants.ts @@ -8,6 +8,11 @@ export interface ItemMutationLockPartition { blockedByLockedLinkedCohort: boolean } +export interface TimelineMutationPreflight extends ItemMutationLockPartition { + allowed: boolean + lockedDestinationTrackIds: string[] +} + function getLockedTrackIds(tracks: TimelineTrack[]): Set { const lockedTrackIds = new Set( resolveEffectiveTrackStates(tracks) @@ -72,3 +77,32 @@ export function partitionItemMutationIdsByLock(params: { blockedByLockedLinkedCohort, } } + +/** + * Validate an entire public-action mutation cohort before its first write. + * + * Callers must provide every existing item whose timing, source window, or + * existence the action can change. Linked cohorts are intentionally checked + * independent of the linked-selection preference: opting out of synchronized + * selection must never let an unlocked member peel away from a locked one. + * Destination lanes are checked separately so cross-track moves and source + * edits cannot write into an effectively locked Layer Group child. + */ +export function preflightTimelineMutation(params: { + items: TimelineItem[] + tracks: TimelineTrack[] + itemIds: Iterable + destinationTrackIds?: Iterable +}): TimelineMutationPreflight { + const partition = partitionItemMutationIdsByLock(params) + const lockedTrackIds = getLockedTrackIds(params.tracks) + const lockedDestinationTrackIds = Array.from(new Set(params.destinationTrackIds ?? [])).filter( + (trackId) => lockedTrackIds.has(trackId), + ) + + return { + ...partition, + allowed: partition.blockedIds.length === 0 && lockedDestinationTrackIds.length === 0, + lockedDestinationTrackIds, + } +} From 0d979ddbad0a129a24c11a71c60e6e636b8c3780 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 20:47:41 -0700 Subject: [PATCH 27/64] fix(timeline): close remaining lock invariant gaps (cherry picked from commit 722cac7add3624c274d7cf360efbce7eb7027479) --- .../actions/edit/freeze-frame-actions.test.ts | 387 ++++++++++++++ .../actions/edit/freeze-frame-actions.ts | 479 +++++++++++------- .../stores/actions/edit/trim-actions.ts | 124 ++++- .../item-actions.lock-invariants.test.ts | 161 +++++- .../timeline/stores/actions/item-actions.ts | 17 +- .../item-edit-actions.lock-invariants.test.ts | 52 +- .../actions/source-edit-actions.test.ts | 232 +++++++-- .../stores/actions/source-edit-actions.ts | 416 +++++++++++---- .../timeline/utils/group-utils.test.ts | 102 ++++ src/features/timeline/utils/group-utils.ts | 124 ++++- 10 files changed, 1710 insertions(+), 384 deletions(-) create mode 100644 src/features/timeline/stores/actions/edit/freeze-frame-actions.test.ts diff --git a/src/features/timeline/stores/actions/edit/freeze-frame-actions.test.ts b/src/features/timeline/stores/actions/edit/freeze-frame-actions.test.ts new file mode 100644 index 000000000..a91d66c18 --- /dev/null +++ b/src/features/timeline/stores/actions/edit/freeze-frame-actions.test.ts @@ -0,0 +1,387 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import type { AudioItem, TimelineItem, TimelineTrack } from '@/types/timeline' + +const mocks = vi.hoisted(() => ({ + acquire: vi.fn<(mediaId: string, blob: Blob) => string>(), + release: vi.fn<(mediaId: string) => void>(), + getMediaFile: vi.fn<(mediaId: string) => Promise>(), + importGeneratedImage: vi.fn(), + deleteMediaFromProject: vi.fn<(projectId: string, mediaId: string) => Promise>(), + prependMediaItem: vi.fn(), + getPrimaryVideoTrack: vi.fn(), + getCanvas: vi.fn(), + disposeInput: vi.fn(), + disposeSink: vi.fn(), + mediaItems: [] as Array>, + mediaState: { + currentProjectId: 'project-1' as string | null, + mediaById: {} as Record>, + prependMediaItem: (media: Record) => { + mocks.prependMediaItem(media) + mocks.mediaItems.unshift(media) + }, + }, +})) + +vi.mock('@/features/timeline/deps/media-library-store', () => ({ + useMediaLibraryStore: { + getState: () => mocks.mediaState, + }, +})) + +vi.mock('@/features/timeline/deps/media-library-service', () => ({ + importMediaLibraryService: async () => ({ + mediaLibraryService: { + getMediaFile: mocks.getMediaFile, + importGeneratedImage: mocks.importGeneratedImage, + deleteMediaFromProject: mocks.deleteMediaFromProject, + }, + }), +})) + +vi.mock('@/infrastructure/browser/blob-url-manager', () => ({ + blobUrlManager: { + acquire: mocks.acquire, + release: mocks.release, + }, +})) + +vi.mock('mediabunny', () => { + class Input { + getPrimaryVideoTrack = mocks.getPrimaryVideoTrack + dispose = mocks.disposeInput + } + + class BlobSource {} + + class CanvasSink { + getCanvas = mocks.getCanvas + dispose = mocks.disposeSink + } + + return { Input, BlobSource, CanvasSink, ALL_FORMATS: [] } +}) + +import { useSelectionStore } from '@/shared/state/selection' +import { + makeTimelineAudioItem, + makeTimelineTrack, + makeTimelineVideoItem, +} from '../../../test-helpers' +import { useItemsStore } from '../../items-store' +import { useTimelineCommandStore } from '../../timeline-command-store' +import { useTimelineSettingsStore } from '../../timeline-settings-store' +import { useTransitionsStore } from '../../transitions-store' +import { insertFreezeFrame } from './freeze-frame-actions' + +const originalSplitItem = useItemsStore.getState()._splitItem +const originalAddItem = useItemsStore.getState()._addItem + +const generatedMedia = { + id: 'freeze-media', + fileName: 'freeze.png', + mimeType: 'image/png', + duration: 0, + createdAt: 1, + updatedAt: 1, +} + +function videoTrack(overrides: Partial = {}): TimelineTrack { + return makeTimelineTrack({ + id: 'video-track', + name: 'V1', + kind: 'video', + order: 0, + ...overrides, + }) +} + +function video(overrides: Partial> = {}) { + return makeTimelineVideoItem({ + id: 'video', + trackId: 'video-track', + from: 0, + durationInFrames: 120, + sourceStart: 0, + sourceEnd: 120, + sourceDuration: 120, + sourceFps: 30, + mediaId: 'media-1', + ...overrides, + }) +} + +function snapshot() { + return { + items: structuredClone(useItemsStore.getState().items), + tracks: structuredClone(useItemsStore.getState().tracks), + transitions: structuredClone(useTransitionsStore.getState().transitions), + selection: structuredClone(useSelectionStore.getState().selectedItemIds), + dirty: useTimelineSettingsStore.getState().isDirty, + undoDepth: useTimelineCommandStore.getState().undoStack.length, + redoDepth: useTimelineCommandStore.getState().redoStack.length, + mediaItems: structuredClone(mocks.mediaItems), + } +} + +function expectSnapshot(expected: ReturnType): void { + expect(useItemsStore.getState().items).toEqual(expected.items) + expect(useItemsStore.getState().tracks).toEqual(expected.tracks) + expect(useTransitionsStore.getState().transitions).toEqual(expected.transitions) + expect(useSelectionStore.getState().selectedItemIds).toEqual(expected.selection) + expect(useTimelineSettingsStore.getState().isDirty).toBe(expected.dirty) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(expected.undoDepth) + expect(useTimelineCommandStore.getState().redoStack).toHaveLength(expected.redoDepth) + expect(mocks.mediaItems).toEqual(expected.mediaItems) +} + +function deferGeneratedImageImport() { + let release!: (media: typeof generatedMedia) => void + let reportStarted!: () => void + const started = new Promise((resolve) => { + reportStarted = resolve + }) + mocks.importGeneratedImage.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + reportStarted() + }), + ) + return { started, release: () => release(generatedMedia) } +} + +describe('freeze-frame async atomicity', () => { + beforeEach(() => { + vi.clearAllMocks() + useItemsStore.setState({ _splitItem: originalSplitItem, _addItem: originalAddItem }) + useItemsStore.getState().setTracks([videoTrack()]) + useItemsStore.getState().setItems([video()]) + useTransitionsStore.getState().setTransitions([]) + useSelectionStore.getState().clearSelection() + useSelectionStore.getState().selectItems(['sentinel-selection']) + useTimelineCommandStore.getState().clearHistory() + useTimelineSettingsStore.setState({ fps: 30, isDirty: false }) + + mocks.mediaItems = [{ id: 'media-1', fileName: 'source.mp4' }] + mocks.mediaState.currentProjectId = 'project-1' + mocks.mediaState.mediaById = { + 'media-1': { + id: 'media-1', + fileName: 'source.mp4', + mimeType: 'video/mp4', + duration: 4, + fps: 30, + }, + } + mocks.getMediaFile.mockResolvedValue(new Blob(['video'], { type: 'video/mp4' })) + mocks.getPrimaryVideoTrack.mockResolvedValue({ displayWidth: 1920, displayHeight: 1080 }) + mocks.getCanvas.mockResolvedValue({ + canvas: { + convertToBlob: async () => new Blob(['frame'], { type: 'image/png' }), + }, + }) + mocks.importGeneratedImage.mockResolvedValue(generatedMedia) + mocks.deleteMediaFromProject.mockResolvedValue(undefined) + mocks.acquire.mockReturnValue('blob:freeze-media') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps a successful output and undoes the timeline mutation in one step', async () => { + await expect(insertFreezeFrame('video', 60)).resolves.toBe(true) + + expect(mocks.deleteMediaFromProject).not.toHaveBeenCalled() + expect(mocks.release).not.toHaveBeenCalled() + expect(mocks.prependMediaItem).toHaveBeenCalledWith(generatedMedia) + expect(useItemsStore.getState().items).toHaveLength(3) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + + useTimelineCommandStore.getState().undo() + expect(useItemsStore.getState().items).toEqual([video()]) + }) + + it('does not write or shift the old lane after the source item moves lanes', async () => { + useItemsStore + .getState() + .setTracks([videoTrack(), videoTrack({ id: 'video-track-2', name: 'V2', order: 1 })]) + useItemsStore.getState().setItems([video(), video({ id: 'old-lane-downstream', from: 120 })]) + const deferred = deferGeneratedImageImport() + const pending = insertFreezeFrame('video', 60) + await deferred.started + + useItemsStore.getState()._moveItem('video', 0, 'video-track-2') + useItemsStore + .getState() + .setTracks([ + videoTrack({ locked: true }), + videoTrack({ id: 'video-track-2', name: 'V2', order: 1 }), + ]) + const before = snapshot() + deferred.release() + + await expect(pending).resolves.toBe(false) + expectSnapshot(before) + expect(mocks.deleteMediaFromProject).toHaveBeenCalledWith('project-1', 'freeze-media') + expect(mocks.release).toHaveBeenCalledWith('freeze-media') + }) + + it('rejects a target track lock that appears while persistence is awaiting', async () => { + const deferred = deferGeneratedImageImport() + const pending = insertFreezeFrame('video', 60) + await deferred.started + + useItemsStore.getState().setTracks([videoTrack({ locked: true })]) + const before = snapshot() + deferred.release() + + await expect(pending).resolves.toBe(false) + expectSnapshot(before) + expect(mocks.deleteMediaFromProject).toHaveBeenCalledWith('project-1', 'freeze-media') + expect(mocks.release).toHaveBeenCalledWith('freeze-media') + }) + + it.each([ + ['deletion', () => useItemsStore.getState()._removeItems(['video'])], + ['source change', () => useItemsStore.getState()._updateItem('video', { sourceStart: 12 })], + ])( + 'rejects source item %s after persistence and cleans media/blob state', + async (_case, drift) => { + const deferred = deferGeneratedImageImport() + const pending = insertFreezeFrame('video', 60) + await deferred.started + + drift() + const before = snapshot() + deferred.release() + + await expect(pending).resolves.toBe(false) + expectSnapshot(before) + expect(mocks.deleteMediaFromProject).toHaveBeenCalledWith('project-1', 'freeze-media') + expect(mocks.release).toHaveBeenCalledWith('freeze-media') + expect(mocks.prependMediaItem).not.toHaveBeenCalled() + }, + ) + + it('rejects source media deletion after persistence and cleans media/blob state', async () => { + const deferred = deferGeneratedImageImport() + const pending = insertFreezeFrame('video', 60) + await deferred.started + + delete mocks.mediaState.mediaById['media-1'] + const before = snapshot() + deferred.release() + + await expect(pending).resolves.toBe(false) + expectSnapshot(before) + expect(mocks.deleteMediaFromProject).toHaveBeenCalledWith('project-1', 'freeze-media') + expect(mocks.release).toHaveBeenCalledWith('freeze-media') + }) + + it('rejects downstream lane cohort drift after persistence', async () => { + const deferred = deferGeneratedImageImport() + const pending = insertFreezeFrame('video', 60) + await deferred.started + + useItemsStore.getState()._addItem(video({ id: 'late-item', from: 120 })) + const before = snapshot() + deferred.release() + + await expect(pending).resolves.toBe(false) + expectSnapshot(before) + expect(mocks.deleteMediaFromProject).toHaveBeenCalledWith('project-1', 'freeze-media') + expect(mocks.release).toHaveBeenCalledWith('freeze-media') + }) + + it('rejects linked companion drift after persistence', async () => { + const audioTrack = makeTimelineTrack({ + id: 'audio-track', + name: 'A1', + kind: 'audio', + order: 1, + }) + const linkedVideo = video({ linkedGroupId: 'linked-av' }) + const linkedAudio: AudioItem = makeTimelineAudioItem({ + id: 'audio', + trackId: 'audio-track', + linkedGroupId: 'linked-av', + from: 0, + durationInFrames: 120, + sourceStart: 0, + sourceEnd: 120, + sourceDuration: 120, + mediaId: 'media-1', + }) + useItemsStore.getState().setTracks([videoTrack(), audioTrack]) + useItemsStore.getState().setItems([linkedVideo, linkedAudio]) + const deferred = deferGeneratedImageImport() + const pending = insertFreezeFrame('video', 60) + await deferred.started + + useItemsStore.getState()._moveItem('audio', 12) + const before = snapshot() + deferred.release() + + await expect(pending).resolves.toBe(false) + expectSnapshot(before) + expect(mocks.deleteMediaFromProject).toHaveBeenCalledWith('project-1', 'freeze-media') + expect(mocks.release).toHaveBeenCalledWith('freeze-media') + }) + + it('cleans persisted media when blob URL acquisition throws', async () => { + mocks.acquire.mockImplementation(() => { + throw new Error('blob acquisition failed') + }) + const before = snapshot() + + await expect(insertFreezeFrame('video', 60)).resolves.toBe(false) + + expectSnapshot(before) + expect(mocks.deleteMediaFromProject).toHaveBeenCalledWith('project-1', 'freeze-media') + expect(mocks.release).toHaveBeenCalledWith('freeze-media') + }) + + it.each([ + ['returns false', () => vi.spyOn(useItemsStore.getState(), '_splitItem').mockReturnValue(null)], + [ + 'throws', + () => + vi.spyOn(useItemsStore.getState(), '_splitItem').mockImplementation(() => { + throw new Error('split mutation failed') + }), + ], + [ + 'throws after splitting', + () => + vi.spyOn(useItemsStore.getState(), '_addItem').mockImplementation(() => { + throw new Error('add mutation failed') + }), + ], + ])('cleans persisted media when execute %s', async (_case, mockSplit) => { + mockSplit() + const before = snapshot() + + await expect(insertFreezeFrame('video', 60)).resolves.toBe(false) + + expectSnapshot(before) + expect(mocks.deleteMediaFromProject).toHaveBeenCalledWith('project-1', 'freeze-media') + expect(mocks.release).toHaveBeenCalledWith('freeze-media') + expect(mocks.prependMediaItem).not.toHaveBeenCalled() + }) + + it('does not remove successful persisted output when the media UI prepend throws', async () => { + mocks.prependMediaItem.mockImplementation(() => { + throw new Error('media UI refresh failed') + }) + + await expect(insertFreezeFrame('video', 60)).resolves.toBe(true) + + expect(useItemsStore.getState().items).toHaveLength(3) + expect(mocks.deleteMediaFromProject).not.toHaveBeenCalled() + expect(mocks.release).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts b/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts index 01cc64671..30eee8fff 100644 --- a/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts +++ b/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts @@ -1,4 +1,4 @@ -import type { ImageItem } from '@/types/timeline' +import type { ImageItem, TimelineItem, TimelineTrack, VideoItem } from '@/types/timeline' import { useItemsStore } from '../../items-store' import { useTransitionsStore } from '../../transitions-store' import { useTimelineSettingsStore } from '../../timeline-settings-store' @@ -9,31 +9,133 @@ import { blobUrlManager } from '@/infrastructure/browser/blob-url-manager' import { execute, applyTransitionRepairs, getLogger } from '../shared' import { timelineToSourceFrames } from '../../../utils/source-calculations' import { canMutateTimelineItems, isInTransitionOverlap } from './shared' +import { captureSnapshot, restoreSnapshot } from '../../commands/snapshot' + +interface FreezeFramePlan { + item: VideoItem + fps: number + media: { id: string; fps?: number } + projectId: string + downstreamItemIds: string[] + fingerprint: string +} + +function isFreezeFramePositionValid(item: VideoItem, playheadFrame: number): boolean { + if (playheadFrame <= item.from || playheadFrame >= item.from + item.durationInFrames) return false + return !isInTransitionOverlap(item.id, playheadFrame - item.from, item.durationInFrames) +} -function canCommitFreezeFrame(itemId: string, playheadFrame: number): boolean { +function getFreezeFrameDownstreamItems( + items: TimelineItem[], + item: VideoItem, + playheadFrame: number, +): TimelineItem[] { + return items.filter( + (candidate) => + candidate.id !== item.id && + candidate.trackId === item.trackId && + candidate.from >= playheadFrame, + ) +} + +function getFreezeFrameParticipants(itemId: string, playheadFrame: number) { const store = useItemsStore.getState() const item = store.itemById[itemId] - if (!item || item.type !== 'video') return false - if ( - playheadFrame <= item.from || - playheadFrame >= item.from + item.durationInFrames || - isInTransitionOverlap(itemId, playheadFrame - item.from, item.durationInFrames) - ) { - return false + if (!item || item.type !== 'video') return null + if (!isFreezeFramePositionValid(item, playheadFrame)) return null + + const downstreamItems = getFreezeFrameDownstreamItems(store.items, item, playheadFrame) + const mutationIds = [itemId, ...downstreamItems.map((candidate) => candidate.id)] + if (!canMutateTimelineItems(mutationIds, [item.trackId])) return null + return { store, item, downstreamItems, mutationIds } +} + +function getFreezeFrameScopeItems(items: TimelineItem[], mutationIds: string[]): TimelineItem[] { + const mutationIdSet = new Set(mutationIds) + const linkedGroupIds = new Set( + items + .filter((candidate) => mutationIdSet.has(candidate.id)) + .map((candidate) => candidate.linkedGroupId) + .filter((groupId): groupId is string => !!groupId), + ) + return items + .filter( + (candidate) => + mutationIdSet.has(candidate.id) || + (!!candidate.linkedGroupId && linkedGroupIds.has(candidate.linkedGroupId)), + ) + .toSorted((left, right) => left.id.localeCompare(right.id)) +} + +function getRelevantTrackStates(tracks: TimelineTrack[], trackIds: Set) { + const trackById = new Map(tracks.map((track) => [track.id, track] as const)) + const relevantTrackIds = new Set() + + for (const trackId of trackIds) { + const visited = new Set() + let currentId: string | undefined = trackId + while (currentId && !visited.has(currentId)) { + visited.add(currentId) + relevantTrackIds.add(currentId) + currentId = trackById.get(currentId)?.parentTrackId + } + if (currentId) relevantTrackIds.add(`cycle:${currentId}`) } - const mutationIds = [ - itemId, - ...store.items - .filter( - (candidate) => - candidate.id !== itemId && - candidate.trackId === item.trackId && - candidate.from > playheadFrame, - ) - .map((candidate) => candidate.id), - ] - return canMutateTimelineItems(mutationIds, [item.trackId]) + return [...relevantTrackIds].sort().map((trackId) => { + const track = trackById.get(trackId) + return track + ? { + id: track.id, + parentTrackId: track.parentTrackId, + kind: track.kind, + isGroup: track.isGroup, + locked: track.locked, + order: track.order, + height: track.height, + } + : { id: trackId, missing: true } + }) +} + +function buildFreezeFramePlan(itemId: string, playheadFrame: number): FreezeFramePlan | null { + const participants = getFreezeFrameParticipants(itemId, playheadFrame) + if (!participants) return null + const { store, item, downstreamItems, mutationIds } = participants + + const media = item.mediaId ? useMediaLibraryStore.getState().mediaById[item.mediaId] : undefined + if (!media) return null + const projectId = useMediaLibraryStore.getState().currentProjectId + if (!projectId) return null + + const scopeItems = getFreezeFrameScopeItems(store.items, mutationIds) + const scopeItemIds = new Set(scopeItems.map((candidate) => candidate.id)) + const relevantTransitions = useTransitionsStore + .getState() + .transitions.filter( + (transition) => + scopeItemIds.has(transition.leftClipId) || scopeItemIds.has(transition.rightClipId), + ) + .toSorted((left, right) => left.id.localeCompare(right.id)) + const trackIds = new Set(scopeItems.map((candidate) => candidate.trackId)) + const fps = useTimelineSettingsStore.getState().fps + + return { + item, + fps, + media: { id: media.id, fps: media.fps }, + projectId, + downstreamItemIds: downstreamItems.map((candidate) => candidate.id).toSorted(), + fingerprint: JSON.stringify({ + item, + scopeItems, + tracks: getRelevantTrackStates(store.tracks, trackIds), + transitions: relevantTransitions, + fps, + media: { id: media.id, fps: media.fps }, + projectId, + }), + } } /** @@ -46,46 +148,37 @@ function canCommitFreezeFrame(itemId: string, playheadFrame: number): boolean { * mutations are batched in a single command for undo/redo atomicity. */ export async function insertFreezeFrame(itemId: string, playheadFrame: number): Promise { - const items = useItemsStore.getState().items - const item = items.find((i) => i.id === itemId) - if (!item || item.type !== 'video') return false + const initialPlan = buildFreezeFramePlan(itemId, playheadFrame) + if (!initialPlan) return false - // Validate playhead is within item bounds (exclusive of edges — need room to split) - const itemStart = item.from - const itemEnd = item.from + item.durationInFrames - if (playheadFrame <= itemStart || playheadFrame >= itemEnd) return false - - // Block freeze frame insertion inside transition overlap zones - if (isInTransitionOverlap(itemId, playheadFrame - itemStart, item.durationInFrames)) { - return false - } - if (!canCommitFreezeFrame(itemId, playheadFrame)) return false - - const fps = useTimelineSettingsStore.getState().fps + const { item, fps } = initialPlan const speed = item.speed ?? 1 const sourceStart = item.sourceStart ?? 0 const sourceFps = item.sourceFps ?? fps // Calculate source frame at playhead in source-native FPS - const timelineOffset = playheadFrame - itemStart + const timelineOffset = playheadFrame - item.from const sourceFrame = sourceStart + timelineToSourceFrames(timelineOffset, speed, fps, sourceFps) - // Get media metadata for resolution and fps info - const media = item.mediaId ? useMediaLibraryStore.getState().mediaById[item.mediaId] : undefined - if (!media) { - getLogger().error('[insertFreezeFrame] Media not found for item:', item.mediaId) - return false - } - // Calculate timestamp in seconds for frame extraction - const mediaFps = media.fps || 30 + const mediaFps = initialPlan.media.fps || 30 const timestampSeconds = sourceFrame / mediaFps + let persistedFrame: + | { + mediaLibraryService: Awaited< + ReturnType + >['mediaLibraryService'] + projectId: string + mediaId: string + } + | undefined + let keepPersistedFrame = false try { const { mediaLibraryService } = await importMediaLibraryService() // Step 1: Get the media file blob - const blob = await mediaLibraryService.getMediaFile(media.id) + const blob = await mediaLibraryService.getMediaFile(initialPlan.media.id) if (!blob) { getLogger().error('[insertFreezeFrame] Could not access media file') return false @@ -97,47 +190,51 @@ export async function insertFreezeFrame(itemId: string, playheadFrame: number): source: new BlobSource(blob as File), formats: ALL_FORMATS, }) + let sink: InstanceType | undefined + let frameBlob: Blob + let frameWidth: number + let frameHeight: number + try { + const videoTrack = await input.getPrimaryVideoTrack() + if (!videoTrack) { + getLogger().error('[insertFreezeFrame] No video track found') + return false + } - const videoTrack = await input.getPrimaryVideoTrack() - if (!videoTrack) { - input.dispose() - getLogger().error('[insertFreezeFrame] No video track found') - return false - } - - const frameWidth = videoTrack.displayWidth - const frameHeight = videoTrack.displayHeight + frameWidth = videoTrack.displayWidth + frameHeight = videoTrack.displayHeight + sink = new CanvasSink(videoTrack, { + width: frameWidth, + height: frameHeight, + fit: 'fill', + }) - const sink = new CanvasSink(videoTrack, { - width: frameWidth, - height: frameHeight, - fit: 'fill', - }) + const wrapped = await sink.getCanvas(timestampSeconds) + if (!wrapped) { + getLogger().error('[insertFreezeFrame] Failed to extract frame') + return false + } - const wrapped = await sink.getCanvas(timestampSeconds) - if (!wrapped) { - ;(sink as unknown as { dispose?: () => void }).dispose?.() + const canvas = wrapped.canvas as OffscreenCanvas | HTMLCanvasElement + if ('convertToBlob' in canvas) { + frameBlob = await canvas.convertToBlob({ type: 'image/png' }) + } else { + frameBlob = await new Promise((resolve, reject) => { + canvas.toBlob( + (result) => (result ? resolve(result) : reject(new Error('Failed to create blob'))), + 'image/png', + ) + }) + } + } finally { + ;(sink as unknown as { dispose?: () => void } | undefined)?.dispose?.() input.dispose() - getLogger().error('[insertFreezeFrame] Failed to extract frame') - return false - } - - const canvas = wrapped.canvas as OffscreenCanvas | HTMLCanvasElement - let frameBlob: Blob - if ('convertToBlob' in canvas) { - frameBlob = await canvas.convertToBlob({ type: 'image/png' }) - } else { - frameBlob = await new Promise((resolve, reject) => { - canvas.toBlob( - (b) => (b ? resolve(b) : reject(new Error('Failed to create blob'))), - 'image/png', - ) - }) } - // Clean up mediabunny resources - ;(sink as unknown as { dispose?: () => void }).dispose?.() - input.dispose() + // Avoid persistence if extraction awaited across any relevant source, + // lane, linked-cohort, transition, track ancestry, or lock drift. + const prePersistPlan = buildFreezeFramePlan(itemId, playheadFrame) + if (!prePersistPlan || prePersistPlan.fingerprint !== initialPlan.fingerprint) return false // Step 3: Persist the frame as a media item. Delegates to the shared // import path (mediaLibraryService -> persistGeneratedMediaAsset) which @@ -146,12 +243,6 @@ export async function insertFreezeFrame(itemId: string, playheadFrame: number): // if any step throws. Hand-rolling this here previously skipped the // rollback and had to be patched repeatedly (createMedia-before-thumbnailId, // store-prepend-before-execute). - const currentProjectId = useMediaLibraryStore.getState().currentProjectId - if (!currentProjectId) { - getLogger().error('[insertFreezeFrame] No project context') - return false - } - const fileName = `freeze-frame-${item.label || 'video'}-${Math.round(timestampSeconds * 100) / 100}s.png` const frameFile = new File([frameBlob], fileName, { type: 'image/png', @@ -160,7 +251,7 @@ export async function insertFreezeFrame(itemId: string, playheadFrame: number): const mediaMetadata = await mediaLibraryService.importGeneratedImage( frameFile, - currentProjectId, + initialPlan.projectId, { width: frameWidth, height: frameHeight, @@ -169,120 +260,148 @@ export async function insertFreezeFrame(itemId: string, playheadFrame: number): }, ) const frameMediaId = mediaMetadata.id - const frameBlobUrl = blobUrlManager.acquire(frameMediaId, frameBlob) - - const rollbackPersistedFrame = async (): Promise => { - try { - await mediaLibraryService.deleteMediaFromProject(currentProjectId, frameMediaId) - } catch (cleanupError) { - getLogger().warn( - '[insertFreezeFrame] Failed to roll back persisted frame after rejected commit', - cleanupError, - ) - } - blobUrlManager.release(frameMediaId) + persistedFrame = { + mediaLibraryService, + projectId: initialPlan.projectId, + mediaId: frameMediaId, } + const frameBlobUrl = blobUrlManager.acquire(frameMediaId, frameBlob) - // Locks can change while frame extraction and persistence are awaiting. - // Revalidate the complete split/shift cohort immediately before execute(). - if (!canCommitFreezeFrame(itemId, playheadFrame)) { - await rollbackPersistedFrame() - return false - } + // Rebuild the exact plan after the final await. Any relevant drift rejects + // the operation; the finally block owns all persisted-media cleanup. + const commitPlan = buildFreezeFramePlan(itemId, playheadFrame) + if (!commitPlan || commitPlan.fingerprint !== initialPlan.fingerprint) return false // Step 4: Perform timeline mutations atomically (split + insert + shift). // Prepend the media item to the store only after execute() succeeds so a // failed _splitItem (e.g. the source clip was removed between validation // and execute) doesn't leave an orphaned entry in the media library UI. - const freezeDurationFrames = Math.round(fps * 2) // 2 seconds - - const success = execute( - 'INSERT_FREEZE_FRAME', - (): boolean => { - // Split the video at playhead - const splitResult = useItemsStore.getState()._splitItem(itemId, playheadFrame) - if (!splitResult) { - getLogger().error('[insertFreezeFrame] Split failed') - return false - } - - const { leftItem, rightItem } = splitResult + const freezeDurationFrames = Math.round(commitPlan.fps * 2) // 2 seconds + const beforeSnapshot = captureSnapshot() + const selectionBefore = useSelectionStore.getState() + const dirtyBefore = useTimelineSettingsStore.getState().isDirty + let success: boolean + try { + success = execute( + 'INSERT_FREEZE_FRAME', + (): boolean => { + // Split the video at playhead + const splitResult = useItemsStore.getState()._splitItem(itemId, playheadFrame) + if (!splitResult) { + getLogger().error('[insertFreezeFrame] Split failed') + return false + } - // Update transitions pointing to split item - const transitions = useTransitionsStore.getState().transitions - const updatedTransitions = transitions.map((t) => { - if (t.leftClipId === itemId) { - return { ...t, leftClipId: rightItem.id } + const { leftItem, rightItem } = splitResult + + // Update transitions pointing to split item + const transitions = useTransitionsStore.getState().transitions + const updatedTransitions = transitions.map((transition) => { + if (transition.leftClipId === itemId) { + return { ...transition, leftClipId: rightItem.id } + } + return transition + }) + useTransitionsStore.getState().setTransitions(updatedTransitions) + + // Create ImageItem for the freeze frame + const freezeFrameItem: ImageItem = { + id: crypto.randomUUID(), + type: 'image', + trackId: commitPlan.item.trackId, + from: playheadFrame, + durationInFrames: freezeDurationFrames, + label: fileName, + mediaId: frameMediaId, + src: frameBlobUrl, + sourceWidth: frameWidth, + sourceHeight: frameHeight, + transform: commitPlan.item.transform ? { ...commitPlan.item.transform } : undefined, } - return t - }) - useTransitionsStore.getState().setTransitions(updatedTransitions) - - // Create ImageItem for the freeze frame - const freezeFrameItem: ImageItem = { - id: crypto.randomUUID(), - type: 'image', - trackId: item.trackId, - from: playheadFrame, - durationInFrames: freezeDurationFrames, - label: fileName, - mediaId: frameMediaId, - src: frameBlobUrl, - sourceWidth: frameWidth, - sourceHeight: frameHeight, - transform: item.transform ? { ...item.transform } : undefined, - } - useItemsStore.getState()._addItem(freezeFrameItem) - - // Shift the right half forward by freeze frame duration - const newRightFrom = rightItem.from + freezeDurationFrames - useItemsStore.getState()._moveItem(rightItem.id, newRightFrom) - - // Also shift all items on same track that come after the right half - const allItems = useItemsStore.getState().items - const itemsToShift = allItems.filter( - (i) => - i.trackId === item.trackId && - i.id !== rightItem.id && - i.id !== leftItem.id && - i.id !== freezeFrameItem.id && - i.from > playheadFrame, - ) + useItemsStore.getState()._addItem(freezeFrameItem) - for (const shiftItem of itemsToShift) { - useItemsStore.getState()._moveItem(shiftItem.id, shiftItem.from + freezeDurationFrames) - } + // Shift the right half forward by freeze frame duration + const newRightFrom = rightItem.from + freezeDurationFrames + useItemsStore.getState()._moveItem(rightItem.id, newRightFrom) + + // Shift only the exact downstream cohort that was fingerprinted and + // lock-preflighted immediately before execute(). + for (const downstreamItemId of commitPlan.downstreamItemIds) { + const downstreamItem = useItemsStore.getState().itemById[downstreamItemId] + if (!downstreamItem) throw new Error('Freeze-frame downstream item drifted') + useItemsStore + .getState() + ._moveItem(downstreamItem.id, downstreamItem.from + freezeDurationFrames) + } - // Repair transitions - applyTransitionRepairs([leftItem.id, rightItem.id]) + // Repair transitions + applyTransitionRepairs([leftItem.id, rightItem.id]) - // Select the freeze frame item - useSelectionStore.getState().selectItems([freezeFrameItem.id]) + // Select the freeze frame item + useSelectionStore.getState().selectItems([freezeFrameItem.id]) - useTimelineSettingsStore.getState().markDirty() - return true - }, - { itemId, playheadFrame, freezeDurationFrames }, - ) + useTimelineSettingsStore.getState().markDirty() + return true + }, + { itemId, playheadFrame, freezeDurationFrames }, + ) + } catch (error) { + restoreSnapshot(beforeSnapshot) + useSelectionStore.setState({ + selectedItemIds: selectionBefore.selectedItemIds, + selectedItemIdSet: new Set(selectionBefore.selectedItemIds), + selectedMarkerId: selectionBefore.selectedMarkerId, + selectedTransitionId: selectionBefore.selectedTransitionId, + selectedTrackId: selectionBefore.selectedTrackId, + selectedTrackIds: selectionBefore.selectedTrackIds, + activeTrackId: selectionBefore.activeTrackId, + selectionType: selectionBefore.selectionType, + expandedKeyframeLanes: selectionBefore.expandedKeyframeLanes, + }) + useTimelineSettingsStore.setState({ isDirty: dirtyBefore }) + throw error + } if (!success) { - // Roll back the persisted media so a failed split (rare — only if the - // source clip was deleted between validation and execute) doesn't leave - // an orphan on disk or a dangling blob URL in memory. - // deleteMediaFromProject is the right call here (not deleteMedia): the - // frame was just associated with currentProjectId and is referenced - // only by this project, so the reference-counted variant covers it - // and preserves the global "delete everywhere" semantics for the - // explicit user action. - await rollbackPersistedFrame() return false } - useMediaLibraryStore.getState().prependMediaItem(mediaMetadata) + keepPersistedFrame = true + try { + useMediaLibraryStore.getState().prependMediaItem(mediaMetadata) + } catch (error) { + // Timeline and persistence already succeeded. Keep the referenced media + // instead of deleting a successful output because a UI-store refresh + // failed; the persisted entry will be rediscovered on the next reload. + getLogger().warn('[insertFreezeFrame] Failed to prepend persisted media item', error) + } return true } catch (error) { getLogger().error('[insertFreezeFrame] Failed:', error) return false + } finally { + if (persistedFrame && !keepPersistedFrame) { + try { + await persistedFrame.mediaLibraryService.deleteMediaFromProject( + persistedFrame.projectId, + persistedFrame.mediaId, + ) + } catch (cleanupError) { + getLogger().warn( + '[insertFreezeFrame] Failed to roll back persisted frame after rejected commit', + cleanupError, + ) + } finally { + try { + blobUrlManager.release(persistedFrame.mediaId) + } catch (cleanupError) { + getLogger().warn( + '[insertFreezeFrame] Failed to release persisted frame URL', + cleanupError, + ) + } + } + } } } diff --git a/src/features/timeline/stores/actions/edit/trim-actions.ts b/src/features/timeline/stores/actions/edit/trim-actions.ts index 4410e8f21..2951217d6 100644 --- a/src/features/timeline/stores/actions/edit/trim-actions.ts +++ b/src/features/timeline/stores/actions/edit/trim-actions.ts @@ -82,6 +82,85 @@ function getSynchronizedTrimItems( return Array.from(synchronizedById.values()) } +function getClampedSynchronizedTrimAmount( + synchronizedItems: TimelineItem[], + items: TimelineItem[], + handle: 'start' | 'end', + trimAmount: number, +): number { + const timelineFps = useTimelineSettingsStore.getState().fps + let synchronizedTrimAmount = trimAmount + for (const synchronizedItem of synchronizedItems) { + const sourceClampedAmount = clampTrimAmount( + synchronizedItem, + handle, + synchronizedTrimAmount, + timelineFps, + ).clampedAmount + synchronizedTrimAmount = keepTightestDelta( + synchronizedTrimAmount, + clampToAdjacentItems( + synchronizedItem, + handle, + sourceClampedAmount, + items, + getTransitionLinkedIds(synchronizedItem.id), + ), + ) + } + return synchronizedTrimAmount +} + +function getAttachedCaptionTrimMutationIds( + items: TimelineItem[], + synchronizedItems: TimelineItem[], + handle: 'start' | 'end', + trimAmount: number, +): string[] { + const captionMutationIds = new Set() + const itemById = new Map(items.map((item) => [item.id, item] as const)) + + for (const clip of synchronizedItems) { + if (clip.type === 'text') continue + const finalBounds = getFinalTrimmedClipBounds(clip, handle, trimAmount) + + for (const captionId of getAttachedCaptionItemIds(items, clip.id)) { + const caption = itemById.get(captionId) + if (caption?.type !== 'text') continue + if (captionChangesWithinBounds(caption, finalBounds)) captionMutationIds.add(caption.id) + } + } + + return Array.from(captionMutationIds) +} + +function getFinalTrimmedClipBounds( + clip: TimelineItem, + handle: 'start' | 'end', + trimAmount: number, +): { start: number; end: number } { + return { + start: handle === 'start' ? clip.from + trimAmount : clip.from, + end: + handle === 'start' + ? clip.from + clip.durationInFrames + : clip.from + clip.durationInFrames + trimAmount, + } +} + +function captionChangesWithinBounds( + caption: TimelineItem, + bounds: { start: number; end: number }, +): boolean { + const finalStart = Math.max(caption.from, bounds.start) + const finalEnd = Math.min(caption.from + caption.durationInFrames, bounds.end) + return ( + finalEnd <= finalStart || + finalStart !== caption.from || + finalEnd - finalStart !== caption.durationInFrames + ) +} + function getSynchronizedTrimMutationIds( id: string, handle: 'start' | 'end', @@ -93,9 +172,24 @@ function getSynchronizedTrimMutationIds( if (!synchronizedItems.some((item) => item.id === id)) return [] const synchronizedIds = synchronizedItems.map((item) => item.id) - const shrinksVisibleBounds = handle === 'start' ? trimAmount > 0 : trimAmount < 0 + const synchronizedTrimAmount = getClampedSynchronizedTrimAmount( + synchronizedItems, + items, + handle, + trimAmount, + ) + const shrinksVisibleBounds = + handle === 'start' ? synchronizedTrimAmount > 0 : synchronizedTrimAmount < 0 return shrinksVisibleBounds - ? expandItemIdsWithAttachedCaptions(items, synchronizedIds) + ? [ + ...synchronizedIds, + ...getAttachedCaptionTrimMutationIds( + items, + synchronizedItems, + handle, + synchronizedTrimAmount, + ), + ] : synchronizedIds } @@ -441,26 +535,12 @@ function applySynchronizedTrim( const anchorBefore = synchronizedItems.find((item) => item.id === id) if (!anchorBefore) return - const timelineFps = useTimelineSettingsStore.getState().fps - let synchronizedTrimAmount = trimAmount - for (const synchronizedItem of synchronizedItems) { - const sourceClampedAmount = clampTrimAmount( - synchronizedItem, - handle, - synchronizedTrimAmount, - timelineFps, - ).clampedAmount - synchronizedTrimAmount = keepTightestDelta( - synchronizedTrimAmount, - clampToAdjacentItems( - synchronizedItem, - handle, - sourceClampedAmount, - itemsBefore, - getTransitionLinkedIds(synchronizedItem.id), - ), - ) - } + const synchronizedTrimAmount = getClampedSynchronizedTrimAmount( + synchronizedItems, + itemsBefore, + handle, + trimAmount, + ) if (handle === 'start') { itemsStore._trimItemStart(id, synchronizedTrimAmount, { skipAdjacentClamp: true }) diff --git a/src/features/timeline/stores/actions/item-actions.lock-invariants.test.ts b/src/features/timeline/stores/actions/item-actions.lock-invariants.test.ts index 482115da9..90faa350a 100644 --- a/src/features/timeline/stores/actions/item-actions.lock-invariants.test.ts +++ b/src/features/timeline/stores/actions/item-actions.lock-invariants.test.ts @@ -3,18 +3,23 @@ import { beforeEach, describe, expect, it } from 'vite-plus/test' import type { AudioItem, TimelineTrack, VideoItem } from '@/types/timeline' import { useEditorStore } from '@/shared/state/editor' +import { useSelectionStore } from '@/shared/state/selection' import { useItemsStore } from '../items-store' import { useKeyframesStore } from '../keyframes-store' import { useTimelineCommandStore } from '../timeline-command-store' import { useTimelineSettingsStore } from '../timeline-settings-store' import { useTransitionsStore } from '../transitions-store' +import { useReverseConformDialogStore } from '../reverse-conform-dialog-store' import { closeAllGapsOnTrack, closeGapAtPosition, moveItem, moveItems, + linkItems, + commitPreparedReverseItems, removeItems, rippleDeleteItems, + reverseItems, unlinkItems, updateItem, } from './item-actions' @@ -73,18 +78,21 @@ function makeAudioItem(overrides: Partial = {}): AudioItem { function expectNoHistory(): void { expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + expect(useTimelineCommandStore.getState().redoStack).toHaveLength(0) expect(useTimelineSettingsStore.getState().isDirty).toBe(false) } describe('track lock mutation invariants', () => { beforeEach(() => { useEditorStore.setState({ linkedSelectionEnabled: true }) + useSelectionStore.getState().clearSelection() useItemsStore.getState().setItems([]) useItemsStore.getState().setTracks([]) useTransitionsStore.getState().setTransitions([]) useKeyframesStore.getState().setKeyframes([]) useTimelineCommandStore.getState().clearHistory() useTimelineSettingsStore.setState({ fps: 30, isDirty: false }) + useReverseConformDialogStore.setState({ request: null }) }) it('rejects direct timing, track, source-placement, and delete mutations on a locked item', () => { @@ -145,33 +153,142 @@ describe('track lock mutation invariants', () => { expectNoHistory() }) - it('requires explicit unlink before deleting away from a locked companion', () => { - useEditorStore.setState({ linkedSelectionEnabled: false }) - useItemsStore.getState().setTracks([ - makeTrack({ id: 'video-track', name: 'V1', kind: 'video', order: 0 }), - makeTrack({ - id: 'audio-track', - name: 'A1', - kind: 'audio', - order: 1, - locked: true, - }), + it.each([true, false])( + 'rejects unlinking and deleting away from a locked companion when linked selection is %s', + (linkedSelectionEnabled) => { + useEditorStore.setState({ linkedSelectionEnabled }) + useItemsStore.getState().setTracks([ + makeTrack({ id: 'video-track', name: 'V1', kind: 'video', order: 0 }), + makeTrack({ + id: 'audio-track', + name: 'A1', + kind: 'audio', + order: 1, + locked: true, + }), + ]) + const video = makeVideoItem({ linkedGroupId: 'linked-av' }) + const audio = makeAudioItem({ linkedGroupId: 'linked-av' }) + useItemsStore.getState().setItems([video, audio]) + + useSelectionStore.getState().selectItems([video.id]) + const selectionBefore = useSelectionStore.getState().selectedItemIds + + removeItems([video.id]) + unlinkItems([video.id]) + removeItems([video.id]) + + expect(useItemsStore.getState().items).toEqual([video, audio]) + expect(useSelectionStore.getState().selectedItemIds).toEqual(selectionBefore) + expectNoHistory() + }, + ) + + it('rejects a prepared reverse commit when a nested linked track locks during conforming', () => { + const group = makeTrack({ + id: 'group', + name: 'Group', + kind: 'audio', + order: 1, + isGroup: true, + }) + const videoTrack = makeTrack({ id: 'video-track', name: 'V1', kind: 'video', order: 0 }) + const audioTrack = makeTrack({ + id: 'audio-track', + name: 'A1', + kind: 'audio', + order: 2, + parentTrackId: group.id, + }) + const linkedVideo = makeVideoItem({ linkedGroupId: 'linked-av' }) + const linkedAudio = makeAudioItem({ linkedGroupId: 'linked-av' }) + useItemsStore.getState().setTracks([videoTrack, group, audioTrack]) + useItemsStore.getState().setItems([linkedVideo, linkedAudio]) + + reverseItems([linkedVideo.id]) + const request = useReverseConformDialogStore.getState().request + expect(request).not.toBeNull() + useItemsStore.getState().setTracks([videoTrack, { ...group, locked: true }, audioTrack]) + const itemsBefore = structuredClone(useItemsStore.getState().items) + + commitPreparedReverseItems(request?.items ?? [], [ + { + itemId: linkedVideo.id, + src: 'blob:reverse', + path: 'reverse/video.mp4', + key: 'reverse-key', + quality: 'preview', + usesProxy: true, + isSourceLevel: true, + }, ]) - const video = makeVideoItem({ linkedGroupId: 'linked-av' }) - const audio = makeAudioItem({ linkedGroupId: 'linked-av' }) - useItemsStore.getState().setItems([video, audio]) - removeItems([video.id]) - expect(useItemsStore.getState().items).toHaveLength(2) + expect(useItemsStore.getState().items).toEqual(itemsBefore) expectNoHistory() + }) - unlinkItems([video.id]) - removeItems([video.id]) + it.each([true, false])( + 'rejects mixed locked/unlocked delete requests atomically when linked selection is %s', + (linkedSelectionEnabled) => { + useEditorStore.setState({ linkedSelectionEnabled }) + useItemsStore.getState().setTracks([ + makeTrack({ id: 'video-track', name: 'V1', kind: 'video', order: 0 }), + makeTrack({ + id: 'audio-track', + name: 'A1', + kind: 'audio', + order: 1, + locked: true, + }), + ]) + const unlocked = makeVideoItem({ id: 'unlocked' }) + const locked = makeAudioItem({ id: 'locked' }) + useItemsStore.getState().setItems([unlocked, locked]) + useSelectionStore.getState().selectItems([unlocked.id, locked.id]) - expect(useItemsStore.getState().itemById[video.id]).toBeUndefined() - expect(useItemsStore.getState().itemById[audio.id]).toBeDefined() - expect(useTimelineCommandStore.getState().undoStack).toHaveLength(2) - }) + for (const action of [removeItems, rippleDeleteItems]) { + action([unlocked.id, locked.id]) + expect(useItemsStore.getState().items).toEqual([unlocked, locked]) + expect(useSelectionStore.getState().selectedItemIds).toEqual([unlocked.id, locked.id]) + expectNoHistory() + } + }, + ) + + it.each([true, false])( + 'preflights every link membership mutation when linked selection is %s', + (linkedSelectionEnabled) => { + useEditorStore.setState({ linkedSelectionEnabled }) + useItemsStore.getState().setTracks([ + makeTrack({ + id: 'group', + name: 'Group', + kind: 'video', + order: 0, + isGroup: true, + locked: true, + }), + makeTrack({ + id: 'video-track', + name: 'V1', + kind: 'video', + order: 1, + parentTrackId: 'group', + }), + makeTrack({ id: 'audio-track', name: 'A1', kind: 'audio', order: 2 }), + ]) + const lockedVideo = makeVideoItem({ linkedGroupId: 'video-1' }) + const audio = makeAudioItem({ linkedGroupId: 'audio-1' }) + useItemsStore.getState().setItems([lockedVideo, audio]) + useSelectionStore.getState().selectItems([lockedVideo.id]) + + expect(linkItems([lockedVideo.id, audio.id])).toBe(false) + + expect(useItemsStore.getState().items).toEqual([lockedVideo, audio]) + expect(useSelectionStore.getState().selectedItemIds).toEqual([lockedVideo.id]) + expectNoHistory() + }, + ) it('allows ripple delete on unlocked tracks while a locked sync-lock track stays byte-for-byte fixed', () => { const videoTrack = makeTrack({ diff --git a/src/features/timeline/stores/actions/item-actions.ts b/src/features/timeline/stores/actions/item-actions.ts index ae4fa6b5c..245cea6b4 100644 --- a/src/features/timeline/stores/actions/item-actions.ts +++ b/src/features/timeline/stores/actions/item-actions.ts @@ -818,6 +818,7 @@ export function unlinkItems(ids: string[]): void { const linkedItems = items.filter((item) => unlinkIds.has(item.id) && item.linkedGroupId) if (linkedItems.length === 0) return + if (!areItemMutationsUnlocked(linkedItems.map((item) => item.id))) return // Detect video items that have a linked audio companion — their embedded audio // should be muted after unlinking so it doesn't start playing when the audio is deleted. @@ -858,6 +859,7 @@ export function linkItems(ids: string[]): boolean { if (!canLinkSelection(items, ids) || selectedItems.length < 2) { return false } + if (!areItemMutationsUnlocked(selectedItems.map((item) => item.id))) return false const linkedGroupId = crypto.randomUUID() execute( @@ -990,30 +992,31 @@ export function commitPreparedReverseItems( export function removeItems(ids: string[]): void { const { items, tracks } = useItemsStore.getState() const expandedIds = expandIdsWithLinkedItems(items, ids, isLinkedSelectionEnabled()) - const { allowedIds } = partitionItemMutationIdsByLock({ + const partition = partitionItemMutationIdsByLock({ items, tracks, itemIds: expandedIds, }) - if (allowedIds.length === 0) return + if (partition.allowedIds.length === 0 || partition.blockedIds.length > 0) return + const removalIds = partition.allowedIds execute( 'REMOVE_ITEMS', () => { // Remove items - useItemsStore.getState()._removeItems(allowedIds) + useItemsStore.getState()._removeItems(removalIds) // Cascade: Remove transitions referencing deleted items - useTransitionsStore.getState()._removeTransitionsForItems(allowedIds) + useTransitionsStore.getState()._removeTransitionsForItems(removalIds) // Cascade: Remove keyframes for deleted items - useKeyframesStore.getState()._removeKeyframesForItems(allowedIds) + useKeyframesStore.getState()._removeKeyframesForItems(removalIds) pruneLayerGroupsAfterItemRemoval() useTimelineSettingsStore.getState().markDirty() }, - { ids: allowedIds }, + { ids: removalIds }, ) emitUiSound('delete') @@ -1171,7 +1174,7 @@ export function rippleDeleteItems(ids: string[]): void { tracks, itemIds: expandedIds, }) - if (deletionPartition.allowedIds.length === 0) return + if (deletionPartition.allowedIds.length === 0 || deletionPartition.blockedIds.length > 0) return const plan = buildRippleDeletePlan({ items, tracks, diff --git a/src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts b/src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts index 383764c72..e0640f809 100644 --- a/src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts +++ b/src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from 'vite-plus/test' import type { LottieItem, TextItem, TimelineItem, TimelineTrack } from '@/types/timeline' import type { Transition } from '@/types/transition' import { useEditorStore } from '@/shared/state/editor' +import { useSelectionStore } from '@/shared/state/selection' import { makeTimelineAudioItem, makeTimelineTrack, makeTimelineVideoItem } from '../../test-helpers' import { useItemsStore } from '../items-store' import { useKeyframesStore } from '../keyframes-store' @@ -79,6 +80,7 @@ function snapshot() { undoDepth: useTimelineCommandStore.getState().undoStack.length, redoDepth: useTimelineCommandStore.getState().redoStack.length, dirty: useTimelineSettingsStore.getState().isDirty, + selection: structuredClone(useSelectionStore.getState().selectedItemIds), } } @@ -90,11 +92,13 @@ function expectUnchanged(before: ReturnType): void { expect(useTimelineCommandStore.getState().undoStack).toHaveLength(before.undoDepth) expect(useTimelineCommandStore.getState().redoStack).toHaveLength(before.redoDepth) expect(useTimelineSettingsStore.getState().isDirty).toBe(before.dirty) + expect(useSelectionStore.getState().selectedItemIds).toEqual(before.selection) } describe('public item edit lock preflights', () => { beforeEach(() => { useEditorStore.setState({ linkedSelectionEnabled: true }) + useSelectionStore.getState().clearSelection() useItemsStore.getState().setTracks(tracks()) useItemsStore.getState().setItems([]) useTransitionsStore.getState().setTransitions([]) @@ -254,13 +258,13 @@ describe('public item edit lock preflights', () => { expectUnchanged(before) }) - it('rejects normal trim when attached caption repair would mutate a locked caption', () => { + it('allows normal trim when a locked attached caption remains wholly within final bounds', () => { const caption: TextItem = { id: 'caption', type: 'text', trackId: 'caption-track', - from: 100, - durationInFrames: 30, + from: 70, + durationInFrames: 10, label: 'Caption', text: 'Caption', color: '#fff', @@ -277,13 +281,51 @@ describe('public item edit lock preflights', () => { }), ]) useItemsStore.getState().setItems([video(), caption]) - const before = snapshot() trimItemEnd('middle', -30) - expectUnchanged(before) + expect(useItemsStore.getState().itemById.middle).toMatchObject({ durationInFrames: 30 }) + expect(useItemsStore.getState().itemById.caption).toEqual(caption) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + expect(useTimelineSettingsStore.getState().isDirty).toBe(true) }) + it.each([ + ['crossing', 80, 20], + ['removed', 100, 30], + ] as const)( + 'rejects normal trim when a locked attached caption would be %s', + (_case, from, durationInFrames) => { + const caption: TextItem = { + id: 'caption', + type: 'text', + trackId: 'caption-track', + from, + durationInFrames, + label: 'Caption', + text: 'Caption', + color: '#fff', + textRole: 'caption', + captionSource: { type: 'transcript', clipId: 'middle', mediaId: 'media-1' }, + } + useItemsStore.getState().setTracks([ + tracks()[0]!, + makeTimelineTrack({ + id: 'caption-track', + name: 'Captions', + order: 1, + locked: true, + }), + ]) + useItemsStore.getState().setItems([video(), caption]) + const before = snapshot() + + trimItemEnd('middle', -30) + + expectUnchanged(before) + }, + ) + it.each([ ['reversed', { reversed: true }], ['segmentStart', { segmentStart: 12 }], diff --git a/src/features/timeline/stores/actions/source-edit-actions.test.ts b/src/features/timeline/stores/actions/source-edit-actions.test.ts index d4b331e64..aeaa2e8b2 100644 --- a/src/features/timeline/stores/actions/source-edit-actions.test.ts +++ b/src/features/timeline/stores/actions/source-edit-actions.test.ts @@ -77,6 +77,46 @@ function trackItems(trackId: string): TimelineItem[] { .sort((a, b) => a.from - b.from) } +function deferSourceUrl() { + let release!: (url: string) => void + let reportStarted!: () => void + const started = new Promise((resolve) => { + reportStarted = resolve + }) + mocks.resolveMediaUrl = () => + new Promise((resolve) => { + release = resolve + reportStarted() + }) + return { started, release: (url = 'blob:source-media') => release(url) } +} + +function rejectionSnapshot() { + return { + items: structuredClone(useItemsStore.getState().items), + tracks: structuredClone(useItemsStore.getState().tracks), + transitions: structuredClone(useTransitionsStore.getState().transitions), + selection: structuredClone(useSelectionStore.getState().selectedItemIds), + playhead: usePlaybackStore.getState().currentFrame, + dirty: useTimelineSettingsStore.getState().isDirty, + undoDepth: useTimelineCommandStore.getState().undoStack.length, + redoDepth: useTimelineCommandStore.getState().redoStack.length, + mediaById: structuredClone(mocks.mediaById), + } +} + +function expectRejectedEditToPreserve(snapshot: ReturnType): void { + expect(useItemsStore.getState().items).toEqual(snapshot.items) + expect(useItemsStore.getState().tracks).toEqual(snapshot.tracks) + expect(useTransitionsStore.getState().transitions).toEqual(snapshot.transitions) + expect(useSelectionStore.getState().selectedItemIds).toEqual(snapshot.selection) + expect(usePlaybackStore.getState().currentFrame).toBe(snapshot.playhead) + expect(useTimelineSettingsStore.getState().isDirty).toBe(snapshot.dirty) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(snapshot.undoDepth) + expect(useTimelineCommandStore.getState().redoStack).toHaveLength(snapshot.redoDepth) + expect(mocks.mediaById).toEqual(snapshot.mediaById) +} + describe('source edit actions', () => { beforeEach(() => { useTimelineCommandStore.getState().clearHistory() @@ -227,41 +267,6 @@ describe('source edit actions', () => { expect(inserted).toHaveLength(1) expect(inserted[0]?.trackId).not.toBe('track-v1') }) - - it('revalidates target locks immediately before the async commit', async () => { - let releaseUrl!: (url: string) => void - let reportStarted!: () => void - const started = new Promise((resolve) => { - reportStarted = resolve - }) - mocks.resolveMediaUrl = () => - new Promise((resolve) => { - releaseUrl = resolve - reportStarted() - }) - usePlaybackStore.setState({ currentFrame: 0 }) - - const pendingEdit = performInsertEdit() - await started - useItemsStore.getState().setTracks([ - makeTimelineTrack({ - id: 'track-v1', - name: 'V1', - kind: 'video', - order: 0, - locked: true, - }), - makeTimelineTrack({ id: 'track-a1', name: 'A1', kind: 'audio', order: 1 }), - ]) - releaseUrl('blob:source-media') - await pendingEdit - - expect(useItemsStore.getState().items).toHaveLength(0) - expect(useItemsStore.getState().tracks[0]?.locked).toBe(true) - expect(usePlaybackStore.getState().currentFrame).toBe(0) - expect(useTimelineSettingsStore.getState().isDirty).toBe(false) - expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) - }) }) describe('performOverwriteEdit', () => { @@ -321,6 +326,163 @@ describe('source edit actions', () => { }) }) + it.each([ + ['insert', performInsertEdit], + ['overwrite', performOverwriteEdit], + ])('rebuilds the %s item plan after async media resolution', async (mode, action) => { + usePlaybackStore.setState({ currentFrame: 0 }) + useSelectionStore.getState().selectItems(['sentinel-selection']) + const deferred = deferSourceUrl() + + const pendingEdit = action() + await deferred.started + const unrelatedTrack = makeTimelineTrack({ + id: 'track-v2', + name: 'V2', + kind: 'video', + order: 2, + }) + useItemsStore.getState().setTracks([...useItemsStore.getState().tracks, unrelatedTrack]) + useItemsStore.getState().setItems([ + makeTimelineVideoItem({ + id: 'late-item', + trackId: 'track-v1', + from: 20, + durationInFrames: 20, + sourceEnd: 20, + sourceDuration: 120, + }), + ]) + deferred.release() + await pendingEdit + + expect(useItemsStore.getState().tracks.some((track) => track.id === unrelatedTrack.id)).toBe( + true, + ) + if (mode === 'insert') { + expect(useItemsStore.getState().itemById['late-item']).toMatchObject({ from: 80 }) + } else { + expect(useItemsStore.getState().itemById['late-item']).toBeUndefined() + } + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) + + it.each([ + ['insert', performInsertEdit], + ['overwrite', performOverwriteEdit], + ])('does not resurrect an item removed during the %s await', async (_mode, action) => { + useItemsStore.getState().setItems([ + makeTimelineVideoItem({ + id: 'removed-during-await', + trackId: 'track-v1', + from: 0, + durationInFrames: 120, + sourceEnd: 120, + sourceDuration: 120, + }), + ]) + const deferred = deferSourceUrl() + const pendingEdit = action() + await deferred.started + + useItemsStore.getState()._removeItems(['removed-during-await']) + deferred.release() + await pendingEdit + + expect(useItemsStore.getState().itemById['removed-during-await']).toBeUndefined() + expect(useItemsStore.getState().items).toHaveLength(1) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) + + it.each([ + ['insert', performInsertEdit], + ['overwrite', performOverwriteEdit], + ])( + 'fails the %s edit closed when its target is removed during an await', + async (_mode, action) => { + const deferred = deferSourceUrl() + const pendingEdit = action() + await deferred.started + + useItemsStore + .getState() + .setTracks([makeTimelineTrack({ id: 'track-a1', name: 'A1', kind: 'audio', order: 1 })]) + const before = rejectionSnapshot() + deferred.release() + await pendingEdit + + expectRejectedEditToPreserve(before) + expect(useItemsStore.getState().tracks.some((track) => track.id === 'track-v1')).toBe(false) + }, + ) + + it.each([ + ['insert', performInsertEdit], + ['overwrite', performOverwriteEdit], + ])( + 'fails the %s edit closed when its target is nested under a newly locked ancestor', + async (_mode, action) => { + const deferred = deferSourceUrl() + const pendingEdit = action() + await deferred.started + + useItemsStore.getState().setTracks([ + makeTimelineTrack({ + id: 'grandparent', + name: 'Locked Grandparent', + order: 0, + isGroup: true, + locked: true, + }), + makeTimelineTrack({ + id: 'parent', + name: 'Parent', + order: 1, + isGroup: true, + parentTrackId: 'grandparent', + }), + makeTimelineTrack({ + id: 'track-v1', + name: 'V1', + kind: 'video', + order: 2, + parentTrackId: 'parent', + }), + makeTimelineTrack({ id: 'track-a1', name: 'A1', kind: 'audio', order: 3 }), + ]) + const before = rejectionSnapshot() + deferred.release() + await pendingEdit + + expectRejectedEditToPreserve(before) + }, + ) + + it.each([ + ['insert', performInsertEdit], + ['overwrite', performOverwriteEdit], + ])('fails the %s edit closed when its target locks during an await', async (_mode, action) => { + const deferred = deferSourceUrl() + const pendingEdit = action() + await deferred.started + + useItemsStore.getState().setTracks([ + makeTimelineTrack({ + id: 'track-v1', + name: 'V1', + kind: 'video', + order: 0, + locked: true, + }), + makeTimelineTrack({ id: 'track-a1', name: 'A1', kind: 'audio', order: 1 }), + ]) + const before = rejectionSnapshot() + deferred.release() + await pendingEdit + + expectRejectedEditToPreserve(before) + }) + it.each([ ['insert', performInsertEdit], ['overwrite', performOverwriteEdit], diff --git a/src/features/timeline/stores/actions/source-edit-actions.ts b/src/features/timeline/stores/actions/source-edit-actions.ts index 589dd4eb7..dc729fb71 100644 --- a/src/features/timeline/stores/actions/source-edit-actions.ts +++ b/src/features/timeline/stores/actions/source-edit-actions.ts @@ -15,7 +15,10 @@ import { importMediaLibraryService } from '@/features/timeline/deps/media-librar import { getMediaType, resolveMediaUrl } from '@/features/timeline/deps/media-library-resolver' import { toast } from 'sonner' import { execute, applyTransitionRepairs, getLogger } from './shared' -import { resolveSourceEditTrackTargets } from '../../utils/source-edit-targeting' +import { + resolveSourceEditTrackTargets, + type SourceEditTrackTargets, +} from '../../utils/source-edit-targeting' import { buildMediaTimelineItems } from '../../utils/media-timeline-item-builder' import { DEFAULT_TRACK_HEIGHT } from '../../constants' import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' @@ -47,67 +50,183 @@ interface SourceEditContext { resolvedTracks: TimelineTrack[] } -async function resolveSourceEditContext(): Promise { - const { - sourcePreviewMediaId: sourceMediaId, - sourcePatchVideoEnabled, - sourcePatchAudioEnabled, - sourcePatchVideoTrackId, - sourcePatchAudioTrackId, - } = useEditorStore.getState() - if (!sourceMediaId) { - toast.warning('Open a source in the source monitor first') - return null +function getSourceMediaFingerprint(media: { + duration: number + fps?: number + width?: number + height?: number + mimeType: string + fileName: string + audioCodec?: string +}): string { + return JSON.stringify([ + media.duration, + media.fps, + media.width, + media.height, + media.mimeType, + media.fileName, + media.audioCodec, + ]) +} + +function getUnchangedSourceMedia(sourceMediaId: string, mediaFingerprint: string) { + const media = useMediaLibraryStore.getState().mediaById[sourceMediaId] + if (!media) return null + return getSourceMediaFingerprint(media) === mediaFingerprint ? media : null +} + +function sourceMediaNeedsVideoPatch(mediaType: SourceEditContext['mediaType']): boolean { + return mediaType === 'video' || mediaType === 'image' || mediaType === 'lottie' +} + +function sourceMediaHasAudio( + mediaType: SourceEditContext['mediaType'], + audioCodec: string | undefined, +): boolean { + return mediaType === 'video' ? Boolean(audioCodec) : false +} + +function findTrackById(tracks: TimelineTrack[], trackId: string | null): TimelineTrack | null { + if (!trackId) return null + return tracks.find((track) => track.id === trackId) ?? null +} + +function getSourceEditTrackInputs(params: { + tracks: TimelineTrack[] + activeTrackId: string | null + preferredVideoTrackId: string | null + preferredAudioTrackId: string | null +}) { + const activeTrack = findTrackById(params.tracks, params.activeTrackId) + const preferredVideoTrack = findTrackById(params.tracks, params.preferredVideoTrackId) + const preferredAudioTrack = findTrackById(params.tracks, params.preferredAudioTrackId) + return { + activeTrack, + referenceTrack: activeTrack ?? preferredVideoTrack ?? preferredAudioTrack, } +} - const { inPoint, outPoint } = useSourcePlayerStore.getState() - const { activeTrackId } = useSelectionStore.getState() - const tracks = useItemsStore.getState().tracks - const activeTrack = activeTrackId - ? (tracks.find((track) => track.id === activeTrackId) ?? null) - : null - const preferredVideoTrack = sourcePatchVideoTrackId - ? (tracks.find((track) => track.id === sourcePatchVideoTrackId) ?? null) - : null - const preferredAudioTrack = sourcePatchAudioTrackId - ? (tracks.find((track) => track.id === sourcePatchAudioTrackId) ?? null) - : null - const referenceTrack = activeTrack ?? preferredVideoTrack ?? preferredAudioTrack ?? null +function getSourceEditTiming(params: { + mediaType: SourceEditContext['mediaType'] + media: { duration: number; fps?: number } + projectFps: number + inPoint: number | null + outPoint: number | null +}) { + const sourceFps = params.media.fps || 30 + const sourceDurationFrames = + params.mediaType === 'image' + ? params.projectFps * 3 + : Math.max(1, Math.round(params.media.duration * sourceFps)) + const effectiveIn = params.inPoint ?? 0 + const effectiveOut = params.outPoint ?? sourceDurationFrames + const sourceRangeFrames = effectiveOut - effectiveIn + const clipDurationFrames = + sourceFps === params.projectFps + ? sourceRangeFrames + : Math.max(1, Math.round((sourceRangeFrames * params.projectFps) / sourceFps)) + return { effectiveIn, effectiveOut, clipDurationFrames } +} - const media = useMediaLibraryStore.getState().mediaById[sourceMediaId] - if (!media) { - getLogger().warn('Source edit: Source media not found') - return null +function warnSourceEditTargetFailure(params: { + mediaType: SourceEditContext['mediaType'] + hasAudio: boolean + patchVideo: boolean + patchAudio: boolean +}): void { + if (!params.patchVideo && !params.patchAudio) { + toast.warning('Enable V and/or A source patch targets first') + return + } + if (params.mediaType === 'audio' && !params.patchAudio) { + toast.warning('Enable the A source patch target to edit audio') + return } + if (sourceMediaNeedsVideoPatch(params.mediaType) && !params.patchVideo && !params.hasAudio) { + toast.warning('Enable the V source patch target to edit this source') + return + } + toast.warning('Unable to resolve source patch targets') +} - const mediaType = getMediaType(media.mimeType) - if (mediaType === 'unknown') { - getLogger().warn('Source edit: Unknown media type') +function resolveCurrentSourceEditTargets(params: { + tracks: TimelineTrack[] + activeTrackId: string | null + preferredVideoTrackId: string | null + preferredAudioTrackId: string | null + mediaType: SourceEditContext['mediaType'] + hasAudio: boolean + patchVideo: boolean + patchAudio: boolean + preferredTrackHeight: number +}): SourceEditTrackTargets | null { + const resolvedTargets = resolveSourceEditTrackTargets({ + tracks: params.tracks, + activeTrackId: params.activeTrackId, + preferredVideoTrackId: params.preferredVideoTrackId, + preferredAudioTrackId: params.preferredAudioTrackId, + mediaType: params.mediaType, + hasAudio: params.hasAudio, + patchVideo: params.patchVideo, + patchAudio: params.patchAudio, + preferredTrackHeight: params.preferredTrackHeight, + }) + if (!resolvedTargets) { + warnSourceEditTargetFailure(params) return null } - const sourceFps = media.fps || 30 - const projectFps = useTimelineSettingsStore.getState().fps - const sourceDurationFrames = - mediaType === 'image' ? projectFps * 3 : Math.max(1, Math.round(media.duration * sourceFps)) + const targetTrackIds = new Set( + [resolvedTargets.videoTrackId, resolvedTargets.audioTrackId].filter( + (trackId): trackId is string => !!trackId, + ), + ) + const lockedTarget = resolvedTargets.tracks.find( + (track) => + targetTrackIds.has(track.id) && isTimelineTrackLocked(resolvedTargets.tracks, track.id), + ) + if (!lockedTarget) return resolvedTargets + toast.warning(`Target track ${lockedTarget.name} is locked`) + return null +} - const effectiveIn = inPoint ?? 0 - const effectiveOut = outPoint ?? sourceDurationFrames +function buildCurrentSourceEditContext(params: { + sourceMediaId: string + mediaFingerprint: string + blobUrl: string + thumbnailUrl?: string +}): SourceEditContext | null { + const { sourceMediaId, mediaFingerprint, blobUrl, thumbnailUrl } = params + const editorState = useEditorStore.getState() + if (editorState.sourcePreviewMediaId !== sourceMediaId) return null - // Convert source frames to project frames - const sourceRangeFrames = effectiveOut - effectiveIn - const clipDurationFrames = - sourceFps === projectFps - ? sourceRangeFrames - : Math.max(1, Math.round((sourceRangeFrames * projectFps) / sourceFps)) + const media = getUnchangedSourceMedia(sourceMediaId, mediaFingerprint) + if (!media) return null - const insertFrame = usePlaybackStore.getState().currentFrame + const mediaType = getMediaType(media.mimeType) + if (mediaType === 'unknown') return null + const { + sourcePatchVideoEnabled, + sourcePatchAudioEnabled, + sourcePatchVideoTrackId, + sourcePatchAudioTrackId, + } = editorState + const { inPoint, outPoint } = useSourcePlayerStore.getState() + const { activeTrackId } = useSelectionStore.getState() + const tracks = useItemsStore.getState().tracks + const projectFps = useTimelineSettingsStore.getState().fps + const { referenceTrack } = getSourceEditTrackInputs({ + tracks, + activeTrackId, + preferredVideoTrackId: sourcePatchVideoTrackId, + preferredAudioTrackId: sourcePatchAudioTrackId, + }) + const timing = getSourceEditTiming({ mediaType, media, projectFps, inPoint, outPoint }) const currentProject = useProjectStore.getState().currentProject - const canvasWidth = currentProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH - const canvasHeight = currentProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT - const hasAudio = mediaType === 'video' && !!media.audioCodec - const resolvedTargets = resolveSourceEditTrackTargets({ + const hasAudio = sourceMediaHasAudio(mediaType, media.audioCodec) + const resolvedTargets = resolveCurrentSourceEditTargets({ tracks, activeTrackId, preferredVideoTrackId: sourcePatchVideoTrackId, @@ -118,53 +237,16 @@ async function resolveSourceEditContext(): Promise { patchAudio: sourcePatchAudioEnabled, preferredTrackHeight: referenceTrack?.height ?? DEFAULT_TRACK_HEIGHT, }) - if (!resolvedTargets) { - if (!sourcePatchVideoEnabled && !sourcePatchAudioEnabled) { - toast.warning('Enable V and/or A source patch targets first') - } else if (mediaType === 'audio' && !sourcePatchAudioEnabled) { - toast.warning('Enable the A source patch target to edit audio') - } else if ( - (mediaType === 'video' || mediaType === 'image' || mediaType === 'lottie') && - !sourcePatchVideoEnabled && - !hasAudio - ) { - toast.warning('Enable the V source patch target to edit this source') - } else { - toast.warning('Unable to resolve source patch targets') - } - return null - } - - const targetTrackIds = [resolvedTargets.videoTrackId, resolvedTargets.audioTrackId].filter( - (trackId): trackId is string => !!trackId, - ) - const lockedTarget = resolvedTargets.tracks.find( - (timelineTrack) => - targetTrackIds.includes(timelineTrack.id) && - isTimelineTrackLocked(resolvedTargets.tracks, timelineTrack.id), - ) - if (lockedTarget) { - toast.warning(`Target track ${lockedTarget.name} is locked`) - return null - } - - // Resolve blob URLs before execute (async not allowed inside execute) - const blobUrl = await resolveMediaUrl(sourceMediaId) - if (!blobUrl) { - toast.error('Failed to load source media') - return null - } - const { mediaLibraryService } = await importMediaLibraryService() - const thumbnailUrl = (await mediaLibraryService.getThumbnailBlobUrl(sourceMediaId)) || undefined + if (!resolvedTargets) return null return { sourceMediaId, videoTrackId: resolvedTargets.videoTrackId, audioTrackId: resolvedTargets.audioTrackId, - effectiveIn, - effectiveOut, - clipDurationFrames, - insertFrame, + effectiveIn: timing.effectiveIn, + effectiveOut: timing.effectiveOut, + clipDurationFrames: timing.clipDurationFrames, + insertFrame: usePlaybackStore.getState().currentFrame, blobUrl, thumbnailUrl, media: { @@ -177,13 +259,151 @@ async function resolveSourceEditContext(): Promise { }, mediaType, hasAudio, - canvasWidth, - canvasHeight, + canvasWidth: currentProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH, + canvasHeight: currentProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT, projectFps, resolvedTracks: resolvedTargets.tracks, } } +function getTrackAncestryFingerprint(tracks: TimelineTrack[], trackId: string): string | null { + if (!tracks.some((track) => track.id === trackId)) return null + + const trackById = new Map(tracks.map((track) => [track.id, track] as const)) + const visited = new Set() + const ancestry: Array< + Pick + > = [] + let currentId: string | undefined = trackId + + while (currentId) { + if (visited.has(currentId)) { + ancestry.push({ + id: `cycle:${currentId}`, + locked: true, + order: 0, + height: 0, + }) + break + } + visited.add(currentId) + + const track = trackById.get(currentId) + if (!track) { + ancestry.push({ + id: `missing:${currentId}`, + locked: true, + order: 0, + height: 0, + }) + break + } + + ancestry.push({ + id: track.id, + parentTrackId: track.parentTrackId, + kind: track.kind, + isGroup: track.isGroup, + locked: track.locked, + order: track.order, + height: track.height, + }) + currentId = track.parentTrackId + } + + return JSON.stringify(ancestry) +} + +interface ExistingSourceTargetBaseline { + video?: { id: string; ancestryFingerprint: string } + audio?: { id: string; ancestryFingerprint: string } +} + +function captureExistingTargetBaseline( + context: SourceEditContext, + tracks: TimelineTrack[], +): ExistingSourceTargetBaseline { + const capture = (trackId: string | undefined) => { + if (!trackId) return undefined + const ancestryFingerprint = getTrackAncestryFingerprint(tracks, trackId) + return ancestryFingerprint ? { id: trackId, ancestryFingerprint } : undefined + } + + return { + video: capture(context.videoTrackId), + audio: capture(context.audioTrackId), + } +} + +function sourceTargetsDrifted( + baseline: ExistingSourceTargetBaseline, + context: SourceEditContext, + currentTracks: TimelineTrack[], +): boolean { + return ( + (!!baseline.video && + (context.videoTrackId !== baseline.video.id || + getTrackAncestryFingerprint(currentTracks, baseline.video.id) !== + baseline.video.ancestryFingerprint)) || + (!!baseline.audio && + (context.audioTrackId !== baseline.audio.id || + getTrackAncestryFingerprint(currentTracks, baseline.audio.id) !== + baseline.audio.ancestryFingerprint)) + ) +} + +async function resolveSourceEditContext(): Promise { + const sourceMediaId = useEditorStore.getState().sourcePreviewMediaId + if (!sourceMediaId) { + toast.warning('Open a source in the source monitor first') + return null + } + + const initialMedia = useMediaLibraryStore.getState().mediaById[sourceMediaId] + if (!initialMedia) { + getLogger().warn('Source edit: Source media not found') + return null + } + if (getMediaType(initialMedia.mimeType) === 'unknown') { + getLogger().warn('Source edit: Unknown media type') + return null + } + + const mediaFingerprint = getSourceMediaFingerprint(initialMedia) + const initialTracks = useItemsStore.getState().tracks + const initialContext = buildCurrentSourceEditContext({ + sourceMediaId, + mediaFingerprint, + blobUrl: '', + }) + if (!initialContext) return null + const targetBaseline = captureExistingTargetBaseline(initialContext, initialTracks) + + // Resolve every async asset first. The complete edit plan is deliberately + // rebuilt from live stores only after these awaits, so concurrent timeline + // changes cannot be overwritten by an earlier tracks/items snapshot. + const blobUrl = await resolveMediaUrl(sourceMediaId) + if (!blobUrl) { + toast.error('Failed to load source media') + return null + } + const { mediaLibraryService } = await importMediaLibraryService() + const thumbnailUrl = (await mediaLibraryService.getThumbnailBlobUrl(sourceMediaId)) || undefined + + const context = buildCurrentSourceEditContext({ + sourceMediaId, + mediaFingerprint, + blobUrl, + thumbnailUrl, + }) + if (!context) return null + + const currentTracks = useItemsStore.getState().tracks + if (sourceTargetsDrifted(targetBaseline, context, currentTracks)) return null + + return context +} + function createTimelineItems(ctx: SourceEditContext) { if (ctx.mediaType === 'audio' && !ctx.audioTrackId) { return [] @@ -233,12 +453,6 @@ function createTimelineItems(ctx: SourceEditContext) { }) } -function getSourceEditPreflightTracks(resolvedTracks: TimelineTrack[]): TimelineTrack[] { - const currentTracks = useItemsStore.getState().tracks - const currentTrackIds = new Set(currentTracks.map((track) => track.id)) - return [...currentTracks, ...resolvedTracks.filter((track) => !currentTrackIds.has(track.id))] -} - function canCommitSourceEdit(params: { mode: 'insert' | 'overwrite' targetTrackIds: string[] @@ -257,11 +471,9 @@ function canCommitSourceEdit(params: { : item.from < params.end && itemEnd > params.start }) .map((item) => item.id) - const tracks = getSourceEditPreflightTracks(params.resolvedTracks) - return preflightTimelineMutation({ items, - tracks, + tracks: params.resolvedTracks, itemIds: mutationIds, destinationTrackIds: params.targetTrackIds, }).allowed diff --git a/src/features/timeline/utils/group-utils.test.ts b/src/features/timeline/utils/group-utils.test.ts index c9ab55af6..578e716e8 100644 --- a/src/features/timeline/utils/group-utils.test.ts +++ b/src/features/timeline/utils/group-utils.test.ts @@ -61,6 +61,108 @@ describe('group-utils', () => { }) }) + it('propagates every effective state through a nested grandparent group', () => { + const [effectiveChild] = resolveEffectiveTrackStates([ + makeTrack({ + id: 'grandparent', + isGroup: true, + locked: true, + visible: false, + }), + makeTrack({ + id: 'parent', + isGroup: true, + parentTrackId: 'grandparent', + muted: true, + solo: true, + }), + makeTrack({ id: 'child', parentTrackId: 'parent' }), + ]) + + expect(effectiveChild).toMatchObject({ + id: 'child', + locked: true, + muted: true, + visible: false, + solo: true, + }) + }) + + it('resolves a deep tree without depending on input order', () => { + const depth = 1_000 + const groups = Array.from({ length: depth }, (_, index) => + makeTrack({ + id: `group-${index}`, + isGroup: true, + parentTrackId: index === 0 ? undefined : `group-${index - 1}`, + locked: index === 17, + muted: index === 217, + visible: index !== 617, + solo: index === 917, + }), + ) + const child = makeTrack({ id: 'deep-child', parentTrackId: `group-${depth - 1}` }) + + const [effectiveChild] = resolveEffectiveTrackStates([child, ...groups.toReversed()]) + + expect(effectiveChild).toMatchObject({ + id: 'deep-child', + locked: true, + muted: true, + visible: false, + solo: true, + }) + }) + + it('fails closed when a parent track is missing', () => { + const [effectiveChild] = resolveEffectiveTrackStates([ + makeTrack({ + id: 'orphan', + parentTrackId: 'missing-parent', + solo: true, + }), + ]) + + expect(effectiveChild).toMatchObject({ + locked: true, + muted: true, + visible: false, + solo: false, + }) + }) + + it('fails closed for a self-parent cycle', () => { + const [effectiveChild] = resolveEffectiveTrackStates([ + makeTrack({ id: 'self-cycle', parentTrackId: 'self-cycle', solo: true }), + ]) + + expect(effectiveChild).toMatchObject({ + locked: true, + muted: true, + visible: false, + solo: false, + }) + }) + + it('fails closed for every lane whose ancestry reaches a multi-node cycle', () => { + const effectiveTracks = resolveEffectiveTrackStates([ + makeTrack({ id: 'group-a', isGroup: true, parentTrackId: 'group-b' }), + makeTrack({ id: 'group-b', isGroup: true, parentTrackId: 'group-a' }), + makeTrack({ id: 'child-a', parentTrackId: 'group-a', solo: true }), + makeTrack({ id: 'child-b', parentTrackId: 'group-b' }), + ]) + + expect(effectiveTracks).toHaveLength(2) + for (const effectiveTrack of effectiveTracks) { + expect(effectiveTrack).toMatchObject({ + locked: true, + muted: true, + visible: false, + solo: false, + }) + } + }) + it('uses propagated visibility when collecting visible track ids', () => { const visibleTrackIds = getVisibleTrackIds([ makeTrack({ id: 'group-1', isGroup: true, visible: false }), diff --git a/src/features/timeline/utils/group-utils.ts b/src/features/timeline/utils/group-utils.ts index beab29495..1bc06192a 100644 --- a/src/features/timeline/utils/group-utils.ts +++ b/src/features/timeline/utils/group-utils.ts @@ -45,29 +45,131 @@ export function pruneEmptyLayerGroupHierarchy( return pruneEmptyLayerGroups(tracksWithPopulatedGroupChildren) } +type EffectiveTrackState = Pick & { + valid: boolean +} + +const ROOT_TRACK_STATE: EffectiveTrackState = { + locked: false, + muted: false, + visible: true, + solo: false, + valid: true, +} + +const MALFORMED_TRACK_STATE: EffectiveTrackState = { + locked: true, + muted: true, + visible: false, + solo: false, + valid: false, +} + +function inheritTrackState( + track: TimelineTrack, + inheritedState: EffectiveTrackState, +): EffectiveTrackState { + if (!inheritedState.valid) return MALFORMED_TRACK_STATE + return { + locked: track.locked || inheritedState.locked, + muted: track.muted || inheritedState.muted, + visible: track.visible !== false && inheritedState.visible, + solo: track.solo || inheritedState.solo, + valid: true, + } +} + +function collectTrackResolutionPath(params: { + track: TimelineTrack + trackById: Map + duplicateTrackIds: Set + stateById: Map +}): { path: TimelineTrack[]; inheritedState: EffectiveTrackState } { + const path: TimelineTrack[] = [] + const pathIds = new Set() + let cursor: TimelineTrack | undefined = params.track + + while (cursor) { + const cached = params.stateById.get(cursor.id) + if (cached) return { path, inheritedState: cached } + if (params.duplicateTrackIds.has(cursor.id)) { + return { path, inheritedState: MALFORMED_TRACK_STATE } + } + if (pathIds.has(cursor.id)) return { path, inheritedState: MALFORMED_TRACK_STATE } + + pathIds.add(cursor.id) + path.push(cursor) + if (!cursor.parentTrackId) return { path, inheritedState: ROOT_TRACK_STATE } + + cursor = params.trackById.get(cursor.parentTrackId) + if (!cursor) return { path, inheritedState: MALFORMED_TRACK_STATE } + } + + return { path, inheritedState: MALFORMED_TRACK_STATE } +} + +function resolveTrackState(params: { + track: TimelineTrack + trackById: Map + duplicateTrackIds: Set + stateById: Map +}): EffectiveTrackState { + const cached = params.stateById.get(params.track.id) + if (cached) return cached + if (params.duplicateTrackIds.has(params.track.id)) return MALFORMED_TRACK_STATE + + const { path, inheritedState } = collectTrackResolutionPath(params) + let state = inheritedState + for (let index = path.length - 1; index >= 0; index -= 1) { + const pathTrack = path[index]! + state = inheritTrackState(pathTrack, state) + params.stateById.set(pathTrack.id, state) + } + return params.stateById.get(params.track.id) ?? MALFORMED_TRACK_STATE +} + /** - * Return active timeline lanes with inherited Layer Group state and without - * the organizational container rows themselves. + * Return active timeline lanes with inherited state from their complete + * Layer Group ancestry and without the organizational container rows. + * + * Lock, mute, and solo are enabled by any ancestor; visibility must remain + * enabled at every level. Malformed ancestry (a missing parent or a cycle) is + * resolved fail-closed so every consumer sees the lane as locked, muted, and + * hidden rather than making a different partial guess. Solo is disabled for + * malformed ancestry because promoting an invalid lane into the solo set + * would make it more audible/visible, not less. */ export function resolveEffectiveTrackStates(tracks: TimelineTrack[]): TimelineTrack[] { - const groupsById = new Map( - tracks.filter((track) => track.isGroup).map((track) => [track.id, track] as const), - ) + const trackById = new Map() + const duplicateTrackIds = new Set() + for (const track of tracks) { + if (trackById.has(track.id)) { + duplicateTrackIds.add(track.id) + } else { + trackById.set(track.id, track) + } + } + const stateById = new Map() return tracks .filter((track) => !track.isGroup) .map((track) => { - const parentGroup = track.parentTrackId ? groupsById.get(track.parentTrackId) : undefined - if (!parentGroup) { + const state = resolveTrackState({ track, trackById, duplicateTrackIds, stateById }) + if ( + state.locked === track.locked && + state.muted === track.muted && + state.visible === track.visible && + state.solo === track.solo + ) { return track } return { ...track, - locked: track.locked || parentGroup.locked, - muted: track.muted || parentGroup.muted, - visible: track.visible !== false && parentGroup.visible !== false, - solo: track.solo || parentGroup.solo, + locked: state.locked, + muted: state.muted, + visible: state.visible, + solo: state.solo, } }) } From 8bb7305f94770a855ab4ae37b37050dbab0be8f6 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:13:55 -0700 Subject: [PATCH 28/64] fix(timeline): preserve nested group ancestry (cherry picked from commit d731f64f4e573053edd831031649c5219d7a6a6d) --- .../actions/edit/freeze-frame-actions.test.ts | 41 ++++ .../actions/source-edit-actions.test.ts | 105 ++++++++++ .../items-store.track-hierarchy.test.ts | 184 ++++++++++++++++++ .../timeline/utils/group-utils.test.ts | 50 +++++ src/features/timeline/utils/group-utils.ts | 52 ++++- 5 files changed, 423 insertions(+), 9 deletions(-) create mode 100644 src/features/timeline/stores/items-store.track-hierarchy.test.ts diff --git a/src/features/timeline/stores/actions/edit/freeze-frame-actions.test.ts b/src/features/timeline/stores/actions/edit/freeze-frame-actions.test.ts index a91d66c18..6465eedaf 100644 --- a/src/features/timeline/stores/actions/edit/freeze-frame-actions.test.ts +++ b/src/features/timeline/stores/actions/edit/freeze-frame-actions.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import type { AudioItem, TimelineItem, TimelineTrack } from '@/types/timeline' +import type { Transition } from '@/types/transition' const mocks = vi.hoisted(() => ({ acquire: vi.fn<(mediaId: string, blob: Blob) => string>(), @@ -71,6 +72,7 @@ import { makeTimelineVideoItem, } from '../../../test-helpers' import { useItemsStore } from '../../items-store' +import { useKeyframesStore } from '../../keyframes-store' import { useTimelineCommandStore } from '../../timeline-command-store' import { useTimelineSettingsStore } from '../../timeline-settings-store' import { useTransitionsStore } from '../../transitions-store' @@ -118,6 +120,7 @@ function snapshot() { items: structuredClone(useItemsStore.getState().items), tracks: structuredClone(useItemsStore.getState().tracks), transitions: structuredClone(useTransitionsStore.getState().transitions), + keyframes: structuredClone(useKeyframesStore.getState().keyframes), selection: structuredClone(useSelectionStore.getState().selectedItemIds), dirty: useTimelineSettingsStore.getState().isDirty, undoDepth: useTimelineCommandStore.getState().undoStack.length, @@ -130,6 +133,7 @@ function expectSnapshot(expected: ReturnType): void { expect(useItemsStore.getState().items).toEqual(expected.items) expect(useItemsStore.getState().tracks).toEqual(expected.tracks) expect(useTransitionsStore.getState().transitions).toEqual(expected.transitions) + expect(useKeyframesStore.getState().keyframes).toEqual(expected.keyframes) expect(useSelectionStore.getState().selectedItemIds).toEqual(expected.selection) expect(useTimelineSettingsStore.getState().isDirty).toBe(expected.dirty) expect(useTimelineCommandStore.getState().undoStack).toHaveLength(expected.undoDepth) @@ -160,6 +164,17 @@ describe('freeze-frame async atomicity', () => { useItemsStore.getState().setTracks([videoTrack()]) useItemsStore.getState().setItems([video()]) useTransitionsStore.getState().setTransitions([]) + useKeyframesStore.getState().setKeyframes([ + { + itemId: 'video', + properties: [ + { + property: 'opacity', + keyframes: [{ id: 'sentinel-keyframe', frame: 10, value: 0.75, easing: 'linear' }], + }, + ], + }, + ]) useSelectionStore.getState().clearSelection() useSelectionStore.getState().selectItems(['sentinel-selection']) useTimelineCommandStore.getState().clearHistory() @@ -297,6 +312,32 @@ describe('freeze-frame async atomicity', () => { expect(mocks.release).toHaveBeenCalledWith('freeze-media') }) + it('rejects relevant transition drift after persistence without touching keyframes', async () => { + useItemsStore.getState().setItems([video(), video({ id: 'right', from: 120 })]) + const deferred = deferGeneratedImageImport() + const pending = insertFreezeFrame('video', 60) + await deferred.started + + const transition: Transition = { + id: 'late-transition', + type: 'crossfade', + presentation: 'fade', + timing: 'linear', + leftClipId: 'video', + rightClipId: 'right', + trackId: 'video-track', + durationInFrames: 10, + } + useTransitionsStore.getState().setTransitions([transition]) + const before = snapshot() + deferred.release() + + await expect(pending).resolves.toBe(false) + expectSnapshot(before) + expect(mocks.deleteMediaFromProject).toHaveBeenCalledWith('project-1', 'freeze-media') + expect(mocks.release).toHaveBeenCalledWith('freeze-media') + }) + it('rejects linked companion drift after persistence', async () => { const audioTrack = makeTimelineTrack({ id: 'audio-track', diff --git a/src/features/timeline/stores/actions/source-edit-actions.test.ts b/src/features/timeline/stores/actions/source-edit-actions.test.ts index aeaa2e8b2..148a5aac2 100644 --- a/src/features/timeline/stores/actions/source-edit-actions.test.ts +++ b/src/features/timeline/stores/actions/source-edit-actions.test.ts @@ -49,9 +49,11 @@ import { useSelectionStore } from '@/shared/state/selection' import { useSourcePlayerStore } from '@/shared/state/source-player' import { usePlaybackStore } from '@/shared/state/playback' import { useItemsStore } from '../items-store' +import { useKeyframesStore } from '../keyframes-store' import { useTransitionsStore } from '../transitions-store' import { useTimelineCommandStore } from '../timeline-command-store' import { useTimelineSettingsStore } from '../timeline-settings-store' +import { resolveEffectiveTrackStates } from '../../utils/group-utils' import { performInsertEdit, performOverwriteEdit } from './source-edit-actions' function setSourceMedia(overrides: Record = {}) { @@ -96,6 +98,7 @@ function rejectionSnapshot() { items: structuredClone(useItemsStore.getState().items), tracks: structuredClone(useItemsStore.getState().tracks), transitions: structuredClone(useTransitionsStore.getState().transitions), + keyframes: structuredClone(useKeyframesStore.getState().keyframes), selection: structuredClone(useSelectionStore.getState().selectedItemIds), playhead: usePlaybackStore.getState().currentFrame, dirty: useTimelineSettingsStore.getState().isDirty, @@ -109,6 +112,7 @@ function expectRejectedEditToPreserve(snapshot: ReturnType { ]) useItemsStore.getState().setItems([]) useTransitionsStore.getState().setTransitions([]) + useKeyframesStore.getState().setKeyframes([ + { + itemId: 'sentinel-keyframe-owner', + properties: [ + { + property: 'opacity', + keyframes: [{ id: 'sentinel-keyframe', frame: 0, value: 1, easing: 'linear' }], + }, + ], + }, + ]) useSelectionStore.getState().setActiveTrack(null) useEditorStore.setState({ sourcePreviewMediaId: 'media-1', @@ -367,6 +382,77 @@ describe('source edit actions', () => { expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) }) + it.each([ + ['insert', performInsertEdit], + ['overwrite', performOverwriteEdit], + ])('preserves an unrelated track removal during the %s await', async (_mode, action) => { + const unrelatedTrack = makeTimelineTrack({ + id: 'track-v2', + name: 'V2', + kind: 'video', + order: 2, + }) + useItemsStore.getState().setTracks([...useItemsStore.getState().tracks, unrelatedTrack]) + const deferred = deferSourceUrl() + const pendingEdit = action() + await deferred.started + + useItemsStore + .getState() + .setTracks(useItemsStore.getState().tracks.filter((track) => track.id !== unrelatedTrack.id)) + deferred.release() + await pendingEdit + + expect(useItemsStore.getState().tracks.some((track) => track.id === unrelatedTrack.id)).toBe( + false, + ) + expect(useItemsStore.getState().items).toHaveLength(1) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) + + it.each([ + ['insert', performInsertEdit], + ['overwrite', performOverwriteEdit], + ])('preserves an unrelated track reparent during the %s await', async (_mode, action) => { + useItemsStore.getState().setTracks([ + makeTimelineTrack({ id: 'old-group', name: 'Old group', order: 0, isGroup: true }), + makeTimelineTrack({ id: 'track-v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'track-a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ + id: 'unrelated-lane', + name: 'V2', + kind: 'video', + order: 3, + parentTrackId: 'old-group', + }), + ]) + const deferred = deferSourceUrl() + const pendingEdit = action() + await deferred.started + + useItemsStore.getState().setTracks([ + makeTimelineTrack({ id: 'new-group', name: 'New group', order: 0, isGroup: true }), + makeTimelineTrack({ id: 'track-v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'track-a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ + id: 'unrelated-lane', + name: 'V2', + kind: 'video', + order: 3, + parentTrackId: 'new-group', + }), + ]) + deferred.release() + await pendingEdit + + const tracks = useItemsStore.getState().tracks + expect(tracks.some((track) => track.id === 'old-group')).toBe(false) + expect(tracks.some((track) => track.id === 'new-group')).toBe(true) + expect(tracks.find((track) => track.id === 'unrelated-lane')?.parentTrackId).toBe('new-group') + expect(useItemsStore.getState().items).toHaveLength(1) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) + it.each([ ['insert', performInsertEdit], ['overwrite', performOverwriteEdit], @@ -450,6 +536,25 @@ describe('source edit actions', () => { }), makeTimelineTrack({ id: 'track-a1', name: 'A1', kind: 'audio', order: 3 }), ]) + const currentTracks = useItemsStore.getState().tracks + expect(currentTracks.map((track) => track.id)).toEqual([ + 'grandparent', + 'parent', + 'track-v1', + 'track-a1', + ]) + expect( + currentTracks.every( + (track) => + !track.parentTrackId || + currentTracks.some((parent) => parent.id === track.parentTrackId), + ), + ).toBe(true) + expect( + resolveEffectiveTrackStates(currentTracks).find((track) => track.id === 'track-v1'), + ).toMatchObject({ + locked: true, + }) const before = rejectionSnapshot() deferred.release() await pendingEdit diff --git a/src/features/timeline/stores/items-store.track-hierarchy.test.ts b/src/features/timeline/stores/items-store.track-hierarchy.test.ts new file mode 100644 index 000000000..317de4fd2 --- /dev/null +++ b/src/features/timeline/stores/items-store.track-hierarchy.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it } from 'vite-plus/test' +import { makeTimelineTrack } from '../test-helpers' +import { useItemsStore } from './items-store' +import { resolveEffectiveTrackStates } from '../utils/group-utils' + +function storedTrackIds(): string[] { + return useItemsStore.getState().tracks.map((track) => track.id) +} + +describe('items-store track hierarchy normalization', () => { + beforeEach(() => { + useItemsStore.getState().setItems([]) + useItemsStore.getState().setTracks([]) + }) + + it('preserves every populated group ancestor and prunes an empty sibling branch', () => { + useItemsStore.getState().setTracks([ + makeTimelineTrack({ id: 'outer', name: 'Outer', order: 0, isGroup: true }), + makeTimelineTrack({ + id: 'middle', + name: 'Middle', + order: 1, + isGroup: true, + parentTrackId: 'outer', + }), + makeTimelineTrack({ + id: 'inner', + name: 'Inner', + order: 2, + isGroup: true, + parentTrackId: 'middle', + }), + makeTimelineTrack({ + id: 'empty-sibling', + name: 'Empty sibling', + order: 3, + isGroup: true, + parentTrackId: 'outer', + }), + makeTimelineTrack({ + id: 'lane', + name: 'Lane', + kind: 'video', + order: 4, + parentTrackId: 'inner', + }), + ]) + + expect(storedTrackIds()).toEqual(['outer', 'middle', 'inner', 'lane']) + }) + + it('preserves a valid nested ancestry so an outer lock is inherited after setTracks', () => { + useItemsStore.getState().setTracks([ + makeTimelineTrack({ + id: 'locked-outer', + name: 'Locked outer', + order: 0, + isGroup: true, + locked: true, + }), + makeTimelineTrack({ + id: 'inner', + name: 'Inner', + order: 1, + isGroup: true, + parentTrackId: 'locked-outer', + }), + makeTimelineTrack({ + id: 'lane', + name: 'Lane', + kind: 'video', + order: 2, + parentTrackId: 'inner', + }), + ]) + + expect(storedTrackIds()).toEqual(['locked-outer', 'inner', 'lane']) + expect(resolveEffectiveTrackStates(useItemsStore.getState().tracks)).toEqual([ + expect.objectContaining({ id: 'lane', locked: true }), + ]) + }) + + it('retains orphan lanes and resolves their missing ancestry fail-closed', () => { + useItemsStore.getState().setTracks([ + makeTimelineTrack({ + id: 'orphan', + name: 'Orphan', + kind: 'video', + order: 0, + parentTrackId: 'missing-group', + }), + ]) + + expect(storedTrackIds()).toEqual(['orphan']) + expect(resolveEffectiveTrackStates(useItemsStore.getState().tracks)).toEqual([ + expect.objectContaining({ + id: 'orphan', + locked: true, + muted: true, + visible: false, + solo: false, + }), + ]) + }) + + it('retains self and multi-node cycles reached by lanes and resolves them fail-closed', () => { + useItemsStore.getState().setTracks([ + makeTimelineTrack({ + id: 'self-group', + name: 'Self group', + order: 0, + isGroup: true, + parentTrackId: 'self-group', + }), + makeTimelineTrack({ + id: 'self-lane', + name: 'Self lane', + kind: 'video', + order: 1, + parentTrackId: 'self-group', + }), + makeTimelineTrack({ + id: 'cycle-a', + name: 'Cycle A', + order: 2, + isGroup: true, + parentTrackId: 'cycle-b', + }), + makeTimelineTrack({ + id: 'cycle-b', + name: 'Cycle B', + order: 3, + isGroup: true, + parentTrackId: 'cycle-a', + }), + makeTimelineTrack({ + id: 'cycle-lane', + name: 'Cycle lane', + kind: 'video', + order: 4, + parentTrackId: 'cycle-a', + }), + ]) + + expect(storedTrackIds()).toEqual([ + 'self-group', + 'self-lane', + 'cycle-a', + 'cycle-b', + 'cycle-lane', + ]) + expect(resolveEffectiveTrackStates(useItemsStore.getState().tracks)).toEqual([ + expect.objectContaining({ id: 'self-lane', locked: true, visible: false }), + expect.objectContaining({ id: 'cycle-lane', locked: true, visible: false }), + ]) + }) + + it('retains duplicate parent definitions so ambiguous ancestry remains fail-closed', () => { + useItemsStore.getState().setTracks([ + makeTimelineTrack({ id: 'duplicate', name: 'Duplicate A', order: 0, isGroup: true }), + makeTimelineTrack({ id: 'duplicate', name: 'Duplicate B', order: 1, isGroup: true }), + makeTimelineTrack({ + id: 'lane', + name: 'Lane', + kind: 'video', + order: 2, + parentTrackId: 'duplicate', + }), + ]) + + expect(storedTrackIds()).toEqual(['duplicate', 'duplicate', 'lane']) + expect(resolveEffectiveTrackStates(useItemsStore.getState().tracks)).toEqual([ + expect.objectContaining({ + id: 'lane', + locked: true, + muted: true, + visible: false, + solo: false, + }), + ]) + }) +}) diff --git a/src/features/timeline/utils/group-utils.test.ts b/src/features/timeline/utils/group-utils.test.ts index 578e716e8..f1a957fc1 100644 --- a/src/features/timeline/utils/group-utils.test.ts +++ b/src/features/timeline/utils/group-utils.test.ts @@ -163,6 +163,22 @@ describe('group-utils', () => { } }) + it('fails closed when a lane reaches a duplicate track id', () => { + const [effectiveTrack] = resolveEffectiveTrackStates([ + makeTrack({ id: 'duplicate-parent', isGroup: true }), + makeTrack({ id: 'duplicate-parent', isGroup: true, locked: false }), + makeTrack({ id: 'child', parentTrackId: 'duplicate-parent', solo: true }), + ]) + + expect(effectiveTrack).toMatchObject({ + id: 'child', + locked: true, + muted: true, + visible: false, + solo: false, + }) + }) + it('uses propagated visibility when collecting visible track ids', () => { const visibleTrackIds = getVisibleTrackIds([ makeTrack({ id: 'group-1', isGroup: true, visible: false }), @@ -184,6 +200,40 @@ describe('group-utils', () => { ]) }) + it('retains every transitive group ancestor in input order and prunes empty branches', () => { + const outer = makeTrack({ id: 'outer', isGroup: true }) + const inner = makeTrack({ id: 'inner', isGroup: true, parentTrackId: outer.id }) + const emptySibling = makeTrack({ + id: 'empty-sibling', + isGroup: true, + parentTrackId: outer.id, + }) + const child = makeTrack({ id: 'child', parentTrackId: inner.id }) + + expect(pruneEmptyLayerGroups([child, emptySibling, inner, outer])).toEqual([ + child, + inner, + outer, + ]) + }) + + it('retains malformed group ancestry only when a lane reaches it', () => { + const cycleA = makeTrack({ id: 'cycle-a', isGroup: true, parentTrackId: 'cycle-b' }) + const cycleB = makeTrack({ id: 'cycle-b', isGroup: true, parentTrackId: 'cycle-a' }) + const unreferencedCycle = makeTrack({ + id: 'unreferenced-cycle', + isGroup: true, + parentTrackId: 'unreferenced-cycle', + }) + const child = makeTrack({ id: 'child', parentTrackId: cycleA.id }) + + expect(pruneEmptyLayerGroups([cycleA, cycleB, unreferencedCycle, child])).toEqual([ + cycleA, + cycleB, + child, + ]) + }) + it('prunes empty child lanes without removing empty top-level classic tracks', () => { const group = makeTrack({ id: 'group', isGroup: true }) const populatedChild = makeTrack({ id: 'child-populated', parentTrackId: group.id }) diff --git a/src/features/timeline/utils/group-utils.ts b/src/features/timeline/utils/group-utils.ts index 1bc06192a..605439b49 100644 --- a/src/features/timeline/utils/group-utils.ts +++ b/src/features/timeline/utils/group-utils.ts @@ -11,20 +11,54 @@ export function getVisibleTrackIds(tracks: TimelineTrack[]): Set { ) } +function indexLayerGroups(tracks: TimelineTrack[]): Map { + const groupsById = new Map() + for (const track of tracks) { + if (!track.isGroup) continue + const definitions = groupsById.get(track.id) + if (definitions) definitions.push(track) + else groupsById.set(track.id, [track]) + } + return groupsById +} + +function collectRetainedGroupIds( + tracks: TimelineTrack[], + groupsById: Map, +): Set { + const retainedGroupIds = new Set( + tracks.flatMap((track) => (!track.isGroup && track.parentTrackId ? [track.parentTrackId] : [])), + ) + const pendingGroupIds = [...retainedGroupIds] + + for (let index = 0; index < pendingGroupIds.length; index += 1) { + const definitions = groupsById.get(pendingGroupIds[index]!) ?? [] + for (const group of definitions) { + const parentId = group.parentTrackId + if (!parentId || retainedGroupIds.has(parentId)) continue + retainedGroupIds.add(parentId) + pendingGroupIds.push(parentId) + } + } + + return retainedGroupIds +} + /** - * Remove layer-group containers that no longer own any child tracks. + * Remove layer-group containers that no longer own a descendant lane. * * A layer group is an organizational timeline container, not an item lane of - * its own, so retaining an empty container only leaves an orphaned UI row. + * its own, so retaining a branch with no lane only leaves orphaned UI rows. + * Starting from every non-group lane makes the traversal independent of input + * order and retains its complete group ancestry. Missing parents terminate a + * branch, while visited IDs make self/multi-node cycles finite. If an ID has + * duplicate group definitions, all of them and all of their possible parents + * are retained so normalization does not erase the ambiguity that effective + * state resolution must handle fail-closed. */ export function pruneEmptyLayerGroups(tracks: TimelineTrack[]): TimelineTrack[] { - const populatedGroupIds = new Set( - tracks - .filter((track) => !track.isGroup && track.parentTrackId) - .map((track) => track.parentTrackId as string), - ) - - const nextTracks = tracks.filter((track) => !track.isGroup || populatedGroupIds.has(track.id)) + const retainedGroupIds = collectRetainedGroupIds(tracks, indexLayerGroups(tracks)) + const nextTracks = tracks.filter((track) => !track.isGroup || retainedGroupIds.has(track.id)) return nextTracks.length === tracks.length ? tracks : nextTracks } From e4f2bbf37c275411f3b7cc684afd34a74b06582d Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:45:38 -0700 Subject: [PATCH 29/64] fix(timeline): fail closed on malformed track hierarchy (cherry picked from commit 861d348f38886a02ba8982afea760e5a350c67cd) --- .../item-edit-actions.lock-invariants.test.ts | 119 +++++++++++++++++ .../items-store.track-hierarchy.test.ts | 122 +++++++++++++++++- .../timeline/utils/group-utils.test.ts | 94 ++++++++++++++ src/features/timeline/utils/group-utils.ts | 114 ++++++++++------ 4 files changed, 406 insertions(+), 43 deletions(-) diff --git a/src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts b/src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts index e0640f809..467468bbd 100644 --- a/src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts +++ b/src/features/timeline/stores/actions/item-edit-actions.lock-invariants.test.ts @@ -30,6 +30,7 @@ import { trimItemStart, } from './item-edit-actions' import { updateItem } from './item-actions' +import { preflightTimelineMutation } from '../../utils/track-lock-invariants' function tracks(overrides: Partial = {}): TimelineTrack[] { return [ @@ -144,6 +145,124 @@ describe('public item edit lock preflights', () => { expectUnchanged(before) }) + it.each([ + [ + 'mixed group/lane duplicate id', + [ + makeTimelineTrack({ + id: 'video-track', + name: 'Ambiguous group', + order: 0, + isGroup: true, + }), + makeTimelineTrack({ + id: 'video-track', + name: 'Ambiguous lane', + kind: 'video', + order: 1, + }), + ], + ], + [ + 'duplicate groups', + [ + makeTimelineTrack({ id: 'group', name: 'Group A', order: 0, isGroup: true }), + makeTimelineTrack({ id: 'group', name: 'Group B', order: 1, isGroup: true }), + makeTimelineTrack({ + id: 'video-track', + name: 'Lane', + kind: 'video', + order: 2, + parentTrackId: 'group', + }), + ], + ], + [ + 'duplicate lanes', + [ + makeTimelineTrack({ id: 'video-track', name: 'Lane A', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'video-track', name: 'Lane B', kind: 'video', order: 1 }), + ], + ], + [ + 'missing parent', + [ + makeTimelineTrack({ + id: 'video-track', + name: 'Lane', + kind: 'video', + order: 0, + parentTrackId: 'missing', + }), + ], + ], + [ + 'non-group parent', + [ + makeTimelineTrack({ id: 'ordinary-parent', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ + id: 'video-track', + name: 'V2', + kind: 'video', + order: 1, + parentTrackId: 'ordinary-parent', + }), + ], + ], + [ + 'self-parent', + [ + makeTimelineTrack({ + id: 'video-track', + name: 'Lane', + kind: 'video', + order: 0, + parentTrackId: 'video-track', + }), + ], + ], + [ + 'multi-node cycle', + [ + makeTimelineTrack({ + id: 'group-a', + name: 'Group A', + order: 0, + isGroup: true, + parentTrackId: 'group-b', + }), + makeTimelineTrack({ + id: 'group-b', + name: 'Group B', + order: 1, + isGroup: true, + parentTrackId: 'group-a', + }), + makeTimelineTrack({ + id: 'video-track', + name: 'Lane', + kind: 'video', + order: 2, + parentTrackId: 'group-a', + }), + ], + ], + ] as const)('rejects trim atomically for malformed ancestry: %s', (_name, malformedTracks) => { + useItemsStore.getState().setTracks([...malformedTracks]) + useItemsStore.getState().setItems([video()]) + useSelectionStore.getState().selectItems(['middle']) + const before = snapshot() + const { items, tracks: storedTracks } = useItemsStore.getState() + + expect( + preflightTimelineMutation({ items, tracks: storedTracks, itemIds: ['middle'] }), + ).toMatchObject({ allowed: false, allowedIds: [], blockedIds: ['middle'] }) + + trimItemStart('middle', 10) + + expectUnchanged(before) + }) + it.each([true, false])( 'rejects the live-QA linked A/V trim and split when linked selection is %s', (linkedSelectionEnabled) => { diff --git a/src/features/timeline/stores/items-store.track-hierarchy.test.ts b/src/features/timeline/stores/items-store.track-hierarchy.test.ts index 317de4fd2..5085574a2 100644 --- a/src/features/timeline/stores/items-store.track-hierarchy.test.ts +++ b/src/features/timeline/stores/items-store.track-hierarchy.test.ts @@ -1,9 +1,10 @@ // @vitest-environment node import { beforeEach, describe, expect, it } from 'vite-plus/test' -import { makeTimelineTrack } from '../test-helpers' +import { makeTimelineTrack, makeTimelineVideoItem } from '../test-helpers' import { useItemsStore } from './items-store' import { resolveEffectiveTrackStates } from '../utils/group-utils' +import { preflightTimelineMutation } from '../utils/track-lock-invariants' function storedTrackIds(): string[] { return useItemsStore.getState().tracks.map((track) => track.id) @@ -82,6 +83,50 @@ describe('items-store track hierarchy normalization', () => { ]) }) + it('preserves a valid unlocked grandparent-to-group-to-lane ancestry after setTracks', () => { + const item = makeTimelineVideoItem({ id: 'item', trackId: 'lane' }) + useItemsStore.getState().setTracks([ + makeTimelineTrack({ + id: 'grandparent', + name: 'Grandparent', + order: 0, + isGroup: true, + }), + makeTimelineTrack({ + id: 'group', + name: 'Group', + order: 1, + isGroup: true, + parentTrackId: 'grandparent', + }), + makeTimelineTrack({ + id: 'lane', + name: 'Lane', + kind: 'video', + order: 2, + parentTrackId: 'group', + }), + ]) + useItemsStore.getState().setItems([item]) + + const { items, tracks } = useItemsStore.getState() + expect(storedTrackIds()).toEqual(['grandparent', 'group', 'lane']) + expect(resolveEffectiveTrackStates(tracks)).toEqual([ + expect.objectContaining({ + id: 'lane', + locked: false, + muted: false, + visible: true, + solo: false, + }), + ]) + expect(preflightTimelineMutation({ items, tracks, itemIds: [item.id] })).toMatchObject({ + allowed: true, + allowedIds: [item.id], + blockedIds: [], + }) + }) + it('retains orphan lanes and resolves their missing ancestry fail-closed', () => { useItemsStore.getState().setTracks([ makeTimelineTrack({ @@ -181,4 +226,79 @@ describe('items-store track hierarchy normalization', () => { }), ]) }) + + it('retains a mixed group/lane duplicate id so setTracks cannot unlock the lane', () => { + const item = makeTimelineVideoItem({ id: 'item', trackId: 'mixed' }) + useItemsStore + .getState() + .setTracks([ + makeTimelineTrack({ id: 'mixed', name: 'Mixed group', order: 0, isGroup: true }), + makeTimelineTrack({ id: 'mixed', name: 'Mixed lane', kind: 'video', order: 1 }), + ]) + useItemsStore.getState().setItems([item]) + + const { items, tracks } = useItemsStore.getState() + expect(tracks.map((track) => ({ id: track.id, isGroup: track.isGroup === true }))).toEqual([ + { id: 'mixed', isGroup: true }, + { id: 'mixed', isGroup: false }, + ]) + expect(resolveEffectiveTrackStates(tracks)).toEqual([ + expect.objectContaining({ id: 'mixed', locked: true, muted: true, visible: false }), + ]) + expect(preflightTimelineMutation({ items, tracks, itemIds: [item.id] })).toMatchObject({ + allowed: false, + allowedIds: [], + blockedIds: [item.id], + }) + }) + + it('keeps duplicate lanes fail-closed through setTracks and mutation preflight', () => { + const item = makeTimelineVideoItem({ id: 'item', trackId: 'duplicate-lane' }) + useItemsStore.getState().setTracks([ + makeTimelineTrack({ + id: 'duplicate-lane', + name: 'Duplicate lane A', + kind: 'video', + order: 0, + }), + makeTimelineTrack({ + id: 'duplicate-lane', + name: 'Duplicate lane B', + kind: 'video', + order: 1, + }), + ]) + useItemsStore.getState().setItems([item]) + + const { items, tracks } = useItemsStore.getState() + expect(storedTrackIds()).toEqual(['duplicate-lane', 'duplicate-lane']) + expect(resolveEffectiveTrackStates(tracks)).toEqual([ + expect.objectContaining({ id: 'duplicate-lane', locked: true, visible: false }), + expect.objectContaining({ id: 'duplicate-lane', locked: true, visible: false }), + ]) + expect(preflightTimelineMutation({ items, tracks, itemIds: [item.id] }).allowed).toBe(false) + }) + + it('fails a child lane closed when its parent id belongs to a non-group lane', () => { + const item = makeTimelineVideoItem({ id: 'item', trackId: 'child' }) + useItemsStore.getState().setTracks([ + makeTimelineTrack({ id: 'ordinary-parent', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ + id: 'child', + name: 'V2', + kind: 'video', + order: 1, + parentTrackId: 'ordinary-parent', + }), + ]) + useItemsStore.getState().setItems([item]) + + const { items, tracks } = useItemsStore.getState() + expect(storedTrackIds()).toEqual(['ordinary-parent', 'child']) + expect(resolveEffectiveTrackStates(tracks)).toEqual([ + expect.objectContaining({ id: 'ordinary-parent', locked: false, visible: true }), + expect.objectContaining({ id: 'child', locked: true, muted: true, visible: false }), + ]) + expect(preflightTimelineMutation({ items, tracks, itemIds: [item.id] }).allowed).toBe(false) + }) }) diff --git a/src/features/timeline/utils/group-utils.test.ts b/src/features/timeline/utils/group-utils.test.ts index f1a957fc1..8800e15b2 100644 --- a/src/features/timeline/utils/group-utils.test.ts +++ b/src/features/timeline/utils/group-utils.test.ts @@ -179,6 +179,46 @@ describe('group-utils', () => { }) }) + it('fails closed for every duplicate lane definition', () => { + const effectiveTracks = resolveEffectiveTrackStates([ + makeTrack({ id: 'duplicate-lane', name: 'Duplicate lane A', order: 0 }), + makeTrack({ id: 'duplicate-lane', name: 'Duplicate lane B', order: 1 }), + ]) + + expect(effectiveTracks).toHaveLength(2) + for (const effectiveTrack of effectiveTracks) { + expect(effectiveTrack).toMatchObject({ + id: 'duplicate-lane', + locked: true, + muted: true, + visible: false, + solo: false, + }) + } + }) + + it('fails only the child closed when its parent id resolves to an ordinary lane', () => { + const [ordinaryParent, malformedChild] = resolveEffectiveTrackStates([ + makeTrack({ id: 'ordinary-parent', order: 0 }), + makeTrack({ id: 'child', order: 1, parentTrackId: 'ordinary-parent', solo: true }), + ]) + + expect(ordinaryParent).toMatchObject({ + id: 'ordinary-parent', + locked: false, + muted: false, + visible: true, + solo: false, + }) + expect(malformedChild).toMatchObject({ + id: 'child', + locked: true, + muted: true, + visible: false, + solo: false, + }) + }) + it('uses propagated visibility when collecting visible track ids', () => { const visibleTrackIds = getVisibleTrackIds([ makeTrack({ id: 'group-1', isGroup: true, visible: false }), @@ -234,6 +274,60 @@ describe('group-utils', () => { ]) }) + it('retains duplicate definitions and their possible ancestors without reordering lanes', () => { + const ancestor = makeTrack({ id: 'ancestor', isGroup: true }) + const mixedGroup = makeTrack({ id: 'mixed', isGroup: true, parentTrackId: ancestor.id }) + const mixedLane = makeTrack({ id: 'mixed', order: 2 }) + const duplicateGroupA = makeTrack({ id: 'duplicate-group', isGroup: true, order: 3 }) + const duplicateGroupB = makeTrack({ id: 'duplicate-group', isGroup: true, order: 4 }) + const duplicateLaneA = makeTrack({ id: 'duplicate-lane', order: 5 }) + const emptyGroup = makeTrack({ id: 'empty-group', isGroup: true, order: 6 }) + const unrelatedLane = makeTrack({ id: 'unrelated-lane', order: 7 }) + const duplicateLaneB = makeTrack({ id: 'duplicate-lane', order: 8 }) + + expect( + pruneEmptyLayerGroups([ + mixedLane, + emptyGroup, + ancestor, + duplicateGroupA, + unrelatedLane, + mixedGroup, + duplicateLaneA, + duplicateGroupB, + duplicateLaneB, + ]), + ).toEqual([ + mixedLane, + ancestor, + duplicateGroupA, + unrelatedLane, + mixedGroup, + duplicateLaneA, + duplicateGroupB, + duplicateLaneB, + ]) + }) + + it('does not erase an empty duplicate lane before pruning its mixed-id hierarchy', () => { + const outer = makeTrack({ id: 'outer', isGroup: true, order: 0 }) + const mixedGroup = makeTrack({ + id: 'mixed', + isGroup: true, + order: 1, + parentTrackId: outer.id, + }) + const emptyMixedLane = makeTrack({ id: 'mixed', order: 2, parentTrackId: outer.id }) + const populatedChild = makeTrack({ id: 'child', order: 3, parentTrackId: mixedGroup.id }) + + expect( + pruneEmptyLayerGroupHierarchy( + [outer, mixedGroup, emptyMixedLane, populatedChild], + [{ trackId: populatedChild.id }], + ), + ).toEqual([outer, mixedGroup, emptyMixedLane, populatedChild]) + }) + it('prunes empty child lanes without removing empty top-level classic tracks', () => { const group = makeTrack({ id: 'group', isGroup: true }) const populatedChild = makeTrack({ id: 'child-populated', parentTrackId: group.id }) diff --git a/src/features/timeline/utils/group-utils.ts b/src/features/timeline/utils/group-utils.ts index 605439b49..378d874ff 100644 --- a/src/features/timeline/utils/group-utils.ts +++ b/src/features/timeline/utils/group-utils.ts @@ -11,33 +11,64 @@ export function getVisibleTrackIds(tracks: TimelineTrack[]): Set { ) } -function indexLayerGroups(tracks: TimelineTrack[]): Map { - const groupsById = new Map() +interface TrackHierarchyIndex { + tracksById: Map + trackById: Map + duplicateTrackIds: Set +} + +function indexTrackHierarchy(tracks: TimelineTrack[]): TrackHierarchyIndex { + const tracksById = new Map() + const trackById = new Map() + const duplicateTrackIds = new Set() + for (const track of tracks) { - if (!track.isGroup) continue - const definitions = groupsById.get(track.id) - if (definitions) definitions.push(track) - else groupsById.set(track.id, [track]) + const definitions = tracksById.get(track.id) + if (definitions) { + definitions.push(track) + duplicateTrackIds.add(track.id) + } else { + tracksById.set(track.id, [track]) + trackById.set(track.id, track) + } } - return groupsById + + return { tracksById, trackById, duplicateTrackIds } +} + +function collectRetainedGroupRootIds( + tracks: TimelineTrack[], + hierarchy: TrackHierarchyIndex, +): Set { + const retainedGroupIds = new Set(hierarchy.duplicateTrackIds) + for (const track of tracks) { + if (!track.isGroup && track.parentTrackId) retainedGroupIds.add(track.parentTrackId) + } + return retainedGroupIds +} + +function retainGroupParent( + track: TimelineTrack, + retainedGroupIds: Set, + pendingGroupIds: string[], +): void { + const parentId = track.isGroup ? track.parentTrackId : undefined + if (!parentId || retainedGroupIds.has(parentId)) return + retainedGroupIds.add(parentId) + pendingGroupIds.push(parentId) } function collectRetainedGroupIds( tracks: TimelineTrack[], - groupsById: Map, + hierarchy: TrackHierarchyIndex, ): Set { - const retainedGroupIds = new Set( - tracks.flatMap((track) => (!track.isGroup && track.parentTrackId ? [track.parentTrackId] : [])), - ) + const retainedGroupIds = collectRetainedGroupRootIds(tracks, hierarchy) const pendingGroupIds = [...retainedGroupIds] for (let index = 0; index < pendingGroupIds.length; index += 1) { - const definitions = groupsById.get(pendingGroupIds[index]!) ?? [] - for (const group of definitions) { - const parentId = group.parentTrackId - if (!parentId || retainedGroupIds.has(parentId)) continue - retainedGroupIds.add(parentId) - pendingGroupIds.push(parentId) + const definitions = hierarchy.tracksById.get(pendingGroupIds[index]!) ?? [] + for (const track of definitions) { + retainGroupParent(track, retainedGroupIds, pendingGroupIds) } } @@ -51,13 +82,14 @@ function collectRetainedGroupIds( * its own, so retaining a branch with no lane only leaves orphaned UI rows. * Starting from every non-group lane makes the traversal independent of input * order and retains its complete group ancestry. Missing parents terminate a - * branch, while visited IDs make self/multi-node cycles finite. If an ID has - * duplicate group definitions, all of them and all of their possible parents - * are retained so normalization does not erase the ambiguity that effective - * state resolution must handle fail-closed. + * branch, while visited IDs make self/multi-node cycles finite. Every duplicate + * ID is also a root: group/group and mixed group/lane definitions, plus every + * possible group ancestor, remain in place so normalization cannot sanitize an + * ambiguous topology into unlocked authorization. Lane/lane duplicates already + * survive because pruning never removes ordinary lanes. */ export function pruneEmptyLayerGroups(tracks: TimelineTrack[]): TimelineTrack[] { - const retainedGroupIds = collectRetainedGroupIds(tracks, indexLayerGroups(tracks)) + const retainedGroupIds = collectRetainedGroupIds(tracks, indexTrackHierarchy(tracks)) const nextTracks = tracks.filter((track) => !track.isGroup || retainedGroupIds.has(track.id)) return nextTracks.length === tracks.length ? tracks : nextTracks } @@ -71,9 +103,14 @@ export function pruneEmptyLayerGroupHierarchy( tracks: TimelineTrack[], items: ReadonlyArray>, ): TimelineTrack[] { + const hierarchy = indexTrackHierarchy(tracks) const populatedTrackIds = new Set(items.map((item) => item.trackId)) const tracksWithPopulatedGroupChildren = tracks.filter( - (track) => track.isGroup || !track.parentTrackId || populatedTrackIds.has(track.id), + (track) => + track.isGroup || + hierarchy.duplicateTrackIds.has(track.id) || + !track.parentTrackId || + populatedTrackIds.has(track.id), ) return pruneEmptyLayerGroups(tracksWithPopulatedGroupChildren) @@ -115,8 +152,7 @@ function inheritTrackState( function collectTrackResolutionPath(params: { track: TimelineTrack - trackById: Map - duplicateTrackIds: Set + hierarchy: TrackHierarchyIndex stateById: Map }): { path: TimelineTrack[]; inheritedState: EffectiveTrackState } { const path: TimelineTrack[] = [] @@ -126,7 +162,7 @@ function collectTrackResolutionPath(params: { while (cursor) { const cached = params.stateById.get(cursor.id) if (cached) return { path, inheritedState: cached } - if (params.duplicateTrackIds.has(cursor.id)) { + if (params.hierarchy.duplicateTrackIds.has(cursor.id)) { return { path, inheritedState: MALFORMED_TRACK_STATE } } if (pathIds.has(cursor.id)) return { path, inheritedState: MALFORMED_TRACK_STATE } @@ -135,8 +171,9 @@ function collectTrackResolutionPath(params: { path.push(cursor) if (!cursor.parentTrackId) return { path, inheritedState: ROOT_TRACK_STATE } - cursor = params.trackById.get(cursor.parentTrackId) - if (!cursor) return { path, inheritedState: MALFORMED_TRACK_STATE } + const parent = params.hierarchy.trackById.get(cursor.parentTrackId) + if (!parent?.isGroup) return { path, inheritedState: MALFORMED_TRACK_STATE } + cursor = parent } return { path, inheritedState: MALFORMED_TRACK_STATE } @@ -144,13 +181,12 @@ function collectTrackResolutionPath(params: { function resolveTrackState(params: { track: TimelineTrack - trackById: Map - duplicateTrackIds: Set + hierarchy: TrackHierarchyIndex stateById: Map }): EffectiveTrackState { const cached = params.stateById.get(params.track.id) if (cached) return cached - if (params.duplicateTrackIds.has(params.track.id)) return MALFORMED_TRACK_STATE + if (params.hierarchy.duplicateTrackIds.has(params.track.id)) return MALFORMED_TRACK_STATE const { path, inheritedState } = collectTrackResolutionPath(params) let state = inheritedState @@ -171,24 +207,18 @@ function resolveTrackState(params: { * resolved fail-closed so every consumer sees the lane as locked, muted, and * hidden rather than making a different partial guess. Solo is disabled for * malformed ancestry because promoting an invalid lane into the solo set - * would make it more audible/visible, not less. + * would make it more audible/visible, not less. Duplicate IDs of every shape, + * missing parents, non-group parents, self-parenting, and longer cycles are all + * malformed. Only a unique chain of Layer Group parents may authorize a lane. */ export function resolveEffectiveTrackStates(tracks: TimelineTrack[]): TimelineTrack[] { - const trackById = new Map() - const duplicateTrackIds = new Set() - for (const track of tracks) { - if (trackById.has(track.id)) { - duplicateTrackIds.add(track.id) - } else { - trackById.set(track.id, track) - } - } + const hierarchy = indexTrackHierarchy(tracks) const stateById = new Map() return tracks .filter((track) => !track.isGroup) .map((track) => { - const state = resolveTrackState({ track, trackById, duplicateTrackIds, stateById }) + const state = resolveTrackState({ track, hierarchy, stateById }) if ( state.locked === track.locked && state.muted === track.muted && From 8a18a88d5abbc2341eddb6057f47da4ebade4d98 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 18:59:50 -0700 Subject: [PATCH 30/64] feat(timeline): support linked cohort drag targeting (cherry picked from commit f45ca6980cca8c67c428c07741d68c855e9dd187) --- .../timeline/hooks/use-timeline-drag.test.tsx | 377 ++++++++++++++++++ .../timeline/hooks/use-timeline-drag.ts | 298 +++++++++----- .../utils/linked-drag-targeting.test.ts | 143 +++++++ .../timeline/utils/linked-drag-targeting.ts | 280 +++++++++++++ 4 files changed, 1003 insertions(+), 95 deletions(-) create mode 100644 src/features/timeline/hooks/use-timeline-drag.test.tsx diff --git a/src/features/timeline/hooks/use-timeline-drag.test.tsx b/src/features/timeline/hooks/use-timeline-drag.test.tsx new file mode 100644 index 000000000..01d67cf7f --- /dev/null +++ b/src/features/timeline/hooks/use-timeline-drag.test.tsx @@ -0,0 +1,377 @@ +import type React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { act, renderHook } from '@testing-library/react' +import type { TextItem, TimelineItem, TimelineTrack } from '@/types/timeline' +import { useEditorStore } from '@/shared/state/editor' +import { useSelectionStore } from '@/shared/state/selection' +import { + makeTimelineAudioItem, + makeTimelineTrack, + makeTimelineVideoItem, + resetTimelineCompositionTestState, +} from '../test-helpers' +import { useItemsStore } from '../stores/items-store' +import { useLinkedEditPreviewStore } from '../stores/linked-edit-preview-store' +import { useTimelineCommandStore } from '../stores/timeline-command-store' +import { useTimelineSettingsStore } from '../stores/timeline-settings-store' +import { useTransitionsStore } from '../stores/transitions-store' +import { useZoomStore } from '../stores/zoom-store' +import { useTimelineDrag } from './use-timeline-drag' + +const TIMELINE_DURATION = 600 +const TRACK_HEIGHT = 80 +let rafCallbacks = new Map() +let nextRafId = 1 + +function makeRect(top: number, bottom: number): DOMRect { + return { + x: 0, + y: top, + top, + left: 0, + right: 1000, + bottom, + width: 1000, + height: bottom - top, + toJSON: () => ({}), + } +} + +function mountTimelineTracks(tracks: TimelineTrack[]): Map { + const container = document.createElement('div') + container.className = 'timeline-container' + const trackContainer = document.createElement('div') + trackContainer.className = 'timeline-tracks' + container.appendChild(trackContainer) + document.body.appendChild(container) + + const orderedTracks = [...tracks].sort((left, right) => left.order - right.order) + const centerYByTrackId = new Map() + orderedTracks.forEach((track, index) => { + const top = index * TRACK_HEIGHT + const row = document.createElement('div') + row.dataset.trackId = track.id + row.getBoundingClientRect = () => makeRect(top, top + TRACK_HEIGHT) + trackContainer.appendChild(row) + centerYByTrackId.set(track.id, top + TRACK_HEIGHT / 2) + }) + + trackContainer.getBoundingClientRect = () => + makeRect(-TRACK_HEIGHT, orderedTracks.length * TRACK_HEIGHT + TRACK_HEIGHT) + container.getBoundingClientRect = trackContainer.getBoundingClientRect + return centerYByTrackId +} + +function setupStores(tracks: TimelineTrack[], items: TimelineItem[]) { + resetTimelineCompositionTestState() + useTimelineSettingsStore.setState({ fps: 30, isDirty: false, snapEnabled: false }) + useZoomStore.setState({ level: 0.3, pixelsPerSecond: 30 }) + useItemsStore.getState().setTracks(tracks) + useItemsStore.getState().setItems(items) + useTransitionsStore.getState().setTransitions([]) + useEditorStore.setState({ linkedSelectionEnabled: true }) + useSelectionStore.getState().clearSelection() + useSelectionStore.getState().setDragState(null) + useSelectionStore.getState().setActiveSnapTarget(null) + useSelectionStore.getState().setActiveLinkedDropTarget(null) + useLinkedEditPreviewStore.getState().clear() +} + +function flushAnimationFrames() { + const callbacks = Array.from(rafCallbacks.values()) + rafCallbacks.clear() + for (const callback of callbacks) { + callback(performance.now()) + } +} + +function beginDrag( + result: { current: ReturnType }, + startX: number, + startY: number, +) { + const target = document.createElement('div') + const event = { + target, + clientX: startX, + clientY: startY, + ctrlKey: false, + metaKey: false, + stopPropagation: vi.fn(), + } as unknown as React.MouseEvent + + act(() => { + result.current.handleDragStart(event) + }) + act(() => { + window.dispatchEvent(new MouseEvent('mousemove', { clientX: startX + 4, clientY: startY })) + }) +} + +function moveDrag(clientX: number, clientY: number) { + act(() => { + window.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY })) + flushAnimationFrames() + }) +} + +function releaseDrag() { + act(() => { + window.dispatchEvent(new MouseEvent('mouseup')) + }) +} + +function getItem(id: string): TimelineItem { + const item = useItemsStore.getState().itemById[id] + expect(item).toBeDefined() + return item as TimelineItem +} + +function makeThreeSectionTracks(): TimelineTrack[] { + return [ + makeTimelineTrack({ id: 'v3', name: 'V3', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 2 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 3 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 4 }), + makeTimelineTrack({ id: 'a3', name: 'A3', kind: 'audio', order: 5 }), + ] +} + +describe('useTimelineDrag linked cohorts', () => { + beforeEach(() => { + rafCallbacks = new Map() + nextRafId = 1 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = nextRafId + nextRafId += 1 + rafCallbacks.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + rafCallbacks.delete(id) + }) + }) + + afterEach(() => { + document.body.innerHTML = '' + vi.unstubAllGlobals() + }) + + it('moves two linked pairs together and applies one collision correction to the cohort', () => { + const tracks = makeThreeSectionTracks() + const video1 = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const audio1 = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const video2 = makeTimelineVideoItem({ + id: 'video-2', + trackId: 'v2', + from: 40, + durationInFrames: 10, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + const audio2 = makeTimelineAudioItem({ + id: 'audio-2', + trackId: 'a2', + from: 40, + durationInFrames: 10, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + const blocker = makeTimelineVideoItem({ + id: 'blocker', + trackId: 'v2', + from: 20, + durationInFrames: 10, + mediaId: 'blocker-media', + }) + setupStores(tracks, [video1, audio1, video2, audio2, blocker]) + useSelectionStore.getState().selectItems(['video-1', 'video-2']) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video1, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(getItem('video-1')).toMatchObject({ trackId: 'v2', from: 10 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a2', from: 10 }) + expect(getItem('video-2')).toMatchObject({ trackId: 'v3', from: 50 }) + expect(getItem('audio-2')).toMatchObject({ trackId: 'a3', from: 50 }) + expect(getItem('video-2').from - getItem('video-1').from).toBe(40) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) + + it('moves an attached caption on its visual section without losing its frame offset', () => { + const tracks = makeThreeSectionTracks() + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + const caption: TextItem = { + id: 'caption-1', + type: 'text', + trackId: 'v2', + from: 5, + durationInFrames: 20, + label: 'Caption', + text: 'Caption', + textRole: 'caption', + captionSource: { type: 'transcript', clipId: 'video-1', mediaId: 'media-1' }, + color: '#ffffff', + } + setupStores(tracks, [video, audio, caption]) + useSelectionStore.getState().selectItems(['video-1']) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(10, yByTrackId.get('v2')!) + releaseDrag() + + expect(getItem('video-1')).toMatchObject({ trackId: 'v2', from: 10 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a2', from: 10 }) + expect(getItem('caption-1')).toMatchObject({ trackId: 'v3', from: 15 }) + }) + + it('creates corresponding outer lanes and undoes the whole cohort atomically', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const video1 = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio1 = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + const video2 = makeTimelineVideoItem({ + id: 'video-2', + trackId: 'v2', + from: 80, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + const audio2 = makeTimelineAudioItem({ + id: 'audio-2', + trackId: 'a2', + from: 80, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + setupStores(tracks, [video1, audio1, video2, audio2]) + useSelectionStore.getState().selectItems(['video-1', 'video-2']) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video1, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(10, -TRACK_HEIGHT / 2) + releaseDrag() + + const movedVideo2Track = useItemsStore + .getState() + .tracks.find((track) => track.id === getItem('video-2').trackId) + const movedAudio2Track = useItemsStore + .getState() + .tracks.find((track) => track.id === getItem('audio-2').trackId) + expect(getItem('video-1')).toMatchObject({ trackId: 'v2', from: 10 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a2', from: 10 }) + expect(movedVideo2Track).toMatchObject({ kind: 'video', name: 'V3' }) + expect(movedAudio2Track).toMatchObject({ kind: 'audio', name: 'A3' }) + expect(useItemsStore.getState().tracks).toHaveLength(6) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + + act(() => { + useTimelineCommandStore.getState().undo() + }) + + expect(useItemsStore.getState().tracks).toHaveLength(4) + expect(getItem('video-1')).toMatchObject({ trackId: 'v1', from: 0 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a1', from: 0 }) + expect(getItem('video-2')).toMatchObject({ trackId: 'v2', from: 80 }) + expect(getItem('audio-2')).toMatchObject({ trackId: 'a2', from: 80 }) + }) + + it('rejects the whole cohort when an implicitly linked companion is locked', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2, locked: true }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + setupStores(tracks, [video, audio]) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(result.current.isDragging).toBe(false) + expect(useSelectionStore.getState().dragState).toBeNull() + expect(getItem('video-1')).toMatchObject({ trackId: 'v1', from: 0 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a1', from: 0 }) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + }) + + it('keeps unlinked multi-select lock filtering behavior unchanged', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0, locked: true }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + ] + const unlocked = makeTimelineVideoItem({ id: 'unlocked', trackId: 'v1', durationInFrames: 10 }) + const locked = makeTimelineVideoItem({ + id: 'locked', + trackId: 'v2', + from: 40, + durationInFrames: 10, + mediaId: 'media-2', + }) + setupStores(tracks, [unlocked, locked]) + useSelectionStore.getState().selectItems(['unlocked', 'locked']) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(unlocked, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(10, yByTrackId.get('v1')!) + releaseDrag() + + expect(getItem('unlocked')).toMatchObject({ trackId: 'v1', from: 10 }) + expect(getItem('locked')).toMatchObject({ trackId: 'v2', from: 40 }) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) +}) diff --git a/src/features/timeline/hooks/use-timeline-drag.ts b/src/features/timeline/hooks/use-timeline-drag.ts index 6c9a31634..d59a8c069 100644 --- a/src/features/timeline/hooks/use-timeline-drag.ts +++ b/src/features/timeline/hooks/use-timeline-drag.ts @@ -22,7 +22,7 @@ import { import { findCompatibleTrackForItemType } from '../utils/track-item-compatibility' import { resolveCreateNewDragTrackTargets, - resolveLinkedDragTrackTargets, + resolveLinkedCohortDragTrackTargets, type LinkedDragDropZone, } from '../utils/linked-drag-targeting' import { useLinkedEditPreviewStore } from '../stores/linked-edit-preview-store' @@ -206,76 +206,139 @@ const DRAG_CURSOR_CLASSES = Object.values(DRAG_CURSOR_CLASS_BY_MODE) const TRACK_SECTION_DIVIDER_GAP = 0 const CROSS_TRACK_SNAP_THRESHOLD_PX = 18 -function getDraggedLinkedPair( - items: TimelineItem[], - draggedItemIds: string[], -): { visualItemId: string; audioItemId: string } | null { - if (draggedItemIds.length !== 2) { - return null - } +function isLinkedDragCohort(items: TimelineItem[], draggedItemIds: readonly string[]): boolean { + const draggedIdSet = new Set(draggedItemIds) - const draggedItems = draggedItemIds - .map((id) => items.find((item) => item.id === id)) - .filter((item): item is TimelineItem => item !== undefined) - if (draggedItems.length !== 2) { - return null - } + for (const itemId of draggedItemIds) { + if ( + getLinkedItemIds(items, itemId).some( + (linkedId) => linkedId !== itemId && draggedIdSet.has(linkedId), + ) + ) { + return true + } - // Any visual item (non-audio) paired with an audio item counts as a linked pair - const visualItem = draggedItems.find((draggedItem) => draggedItem.type !== 'audio') - const audioItem = draggedItems.find((draggedItem) => draggedItem.type === 'audio') - if (!visualItem || !audioItem) { - return null + const draggedItem = items.find((item) => item.id === itemId) + if ( + draggedItem?.type === 'text' && + draggedItem.captionSource && + draggedIdSet.has(draggedItem.captionSource.clipId) + ) { + return true + } } - const linkedIds = new Set(getLinkedItemIds(items, visualItem.id)) - if (!linkedIds.has(audioItem.id)) { - return null - } + return false +} - return { - visualItemId: visualItem.id, - audioItemId: audioItem.id, +function getDragAnchorRelatedItemIds(items: TimelineItem[], anchorItemId: string): string[] { + const relatedIds = new Set(getLinkedItemIds(items, anchorItemId)) + const anchorItem = items.find((item) => item.id === anchorItemId) + + if (anchorItem?.type === 'text' && anchorItem.captionSource) { + relatedIds.add(anchorItem.captionSource.clipId) + for (const linkedId of getLinkedItemIds(items, anchorItem.captionSource.clipId)) { + relatedIds.add(linkedId) + } } + + return Array.from(relatedIds) +} + +interface DraggedTrackTargets { + tracks: TimelineTrack[] + trackAssignments: Map +} + +function resolveMultiDragTrackId(params: { + draggedItem: { id: string; initialTrackId: string } + trackTargets: DraggedTrackTargets | null + isLinkedCohort: boolean + dropZone: LinkedDragDropZone | null + trackIndexById: ReadonlyMap + tracks: readonly TimelineTrack[] + anchorTrackId: string + targetAnchorTrackId: string +}): string | null { + const assignedTrackId = params.trackTargets?.trackAssignments.get(params.draggedItem.id) + if (assignedTrackId) return assignedTrackId + if (params.isLinkedCohort && params.dropZone) return null + + const anchorTrackIndex = params.trackIndexById.get(params.anchorTrackId) ?? -1 + const itemTrackIndex = params.trackIndexById.get(params.draggedItem.initialTrackId) ?? -1 + const targetAnchorTrackIndex = params.trackIndexById.get(params.targetAnchorTrackId) ?? -1 + const trackOffset = itemTrackIndex - anchorTrackIndex + const targetTrackIndex = Math.max( + 0, + Math.min(params.tracks.length - 1, targetAnchorTrackIndex + trackOffset), + ) + + return params.tracks[targetTrackIndex]?.id ?? params.draggedItem.initialTrackId } function resolveDraggedTrackTargets(params: { items: TimelineItem[] draggedItems: Array<{ id: string; initialTrackId: string }> + anchorItemId: string + isLinkedCohort: boolean tracks: TimelineTrack[] dropTarget: { trackId: string; zone: LinkedDragDropZone | null; createNew?: boolean } preferredTrackHeight: number -}): { tracks: TimelineTrack[]; trackAssignments: Map } | null { - const { items, draggedItems, tracks, dropTarget, preferredTrackHeight } = params +}): { trackTargets: DraggedTrackTargets | null; isLinkedCohort: boolean } { + const { + items, + draggedItems, + anchorItemId, + isLinkedCohort, + tracks, + dropTarget, + preferredTrackHeight, + } = params + if (!dropTarget.zone) { - return null + return { trackTargets: null, isLinkedCohort } } - const draggedItemIds = draggedItems.map((draggedItem) => draggedItem.id) - const linkedPair = getDraggedLinkedPair(items, draggedItemIds) - if (linkedPair) { - const linkedTrackTargets = resolveLinkedDragTrackTargets({ + if (isLinkedCohort) { + const sourceItemById = new Map(items.map((item) => [item.id, item])) + const linkedTrackTargets = resolveLinkedCohortDragTrackTargets({ tracks, + draggedItems: draggedItems + .map((draggedItem) => { + const sourceItem = sourceItemById.get(draggedItem.id) + return sourceItem + ? { + id: sourceItem.id, + initialTrackId: draggedItem.initialTrackId, + type: sourceItem.type, + } + : null + }) + .filter( + ( + draggedItem, + ): draggedItem is { + id: string + initialTrackId: string + type: TimelineItem['type'] + } => draggedItem !== null, + ), + anchorItemId, + anchorRelatedItemIds: getDragAnchorRelatedItemIds(items, anchorItemId), hoveredTrackId: dropTarget.trackId, zone: dropTarget.zone, createNew: dropTarget.createNew, preferredTrackHeight, }) - if (!linkedTrackTargets) { - return null - } return { - tracks: linkedTrackTargets.tracks, - trackAssignments: new Map([ - [linkedPair.visualItemId, linkedTrackTargets.videoTrackId], - [linkedPair.audioItemId, linkedTrackTargets.audioTrackId], - ]), + trackTargets: linkedTrackTargets, + isLinkedCohort, } } if (!dropTarget.createNew) { - return null + return { trackTargets: null, isLinkedCohort } } const createNewTrackTargets = resolveCreateNewDragTrackTargets({ @@ -302,12 +365,15 @@ function resolveDraggedTrackTargets(params: { }) if (!createNewTrackTargets) { - return null + return { trackTargets: null, isLinkedCohort } } return { - tracks: createNewTrackTargets.tracks, - trackAssignments: createNewTrackTargets.trackAssignments, + trackTargets: { + tracks: createNewTrackTargets.tracks, + trackAssignments: createNewTrackTargets.trackAssignments, + }, + isLinkedCohort, } } @@ -393,7 +459,8 @@ interface DraggedItemState { * Resolve the full set of items a drag should move and their initial positions: * expand the base selection (linked items when enabled, else the raw selection * or the just-clicked clip), attach captions, drop locked items, and snapshot - * each survivor's starting frame + track. + * each survivor's starting frame + track. Linked cohorts reject the entire + * gesture if any explicit or implicit member is on a locked track. */ function resolveDraggedItemStates( allItems: TimelineItem[], @@ -402,14 +469,24 @@ function resolveDraggedItemStates( isInSelection: boolean, linkedIds: string[], linkedSelectionEnabled: boolean, -): { baseItemsToDrag: string[]; draggableItemIds: string[]; draggedItems: DraggedItemState[] } { +): { + baseItemsToDrag: string[] + draggableItemIds: string[] + draggedItems: DraggedItemState[] + isLinkedCohort: boolean + isBlockedByLockedLinkedItem: boolean +} { const baseItemsToDrag = isInSelection ? linkedSelectionEnabled ? expandSelectionWithLinkedItems(allItems, currentSelectedIds) : currentSelectedIds : linkedIds const itemsToDrag = expandItemIdsWithAttachedCaptions(allItems, baseItemsToDrag) - const draggableItemIds = filterUnlockedItemIds(allItems, currentTracks, itemsToDrag) + const unlockedItemIds = filterUnlockedItemIds(allItems, currentTracks, itemsToDrag) + const isLinkedCohort = isLinkedDragCohort(allItems, itemsToDrag) + const isBlockedByLockedLinkedItem = + isLinkedCohort && unlockedItemIds.length !== itemsToDrag.length + const draggableItemIds = isBlockedByLockedLinkedItem ? [] : unlockedItemIds const draggedItems = draggableItemIds .map((id) => { const dragItem = allItems.find((i) => i.id === id) @@ -421,7 +498,13 @@ function resolveDraggedItemStates( } }) .filter((i): i is DraggedItemState => i !== null) - return { baseItemsToDrag, draggableItemIds, draggedItems } + return { + baseItemsToDrag, + draggableItemIds, + draggedItems, + isLinkedCohort, + isBlockedByLockedLinkedItem, + } } /** @@ -447,6 +530,7 @@ export function useTimelineDrag( const [isDragging, setIsDragging] = useState(false) const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) const dragStateRef = useRef(null) + const isLinkedCohortDragRef = useRef(false) const dragVisualTopByTrackIdRef = useRef>(new Map()) const linkedMovePreviewSignatureRef = useRef('') @@ -775,14 +859,15 @@ export function useTimelineDrag( } // Determine which items to drag and snapshot their initial positions - const { baseItemsToDrag, draggedItems } = resolveDraggedItemStates( - allItems, - currentTracks, - currentSelectedIds, - isInSelection, - linkedIds, - linkedSelectionEnabled, - ) + const { baseItemsToDrag, draggedItems, isLinkedCohort, isBlockedByLockedLinkedItem } = + resolveDraggedItemStates( + allItems, + currentTracks, + currentSelectedIds, + isInSelection, + linkedIds, + linkedSelectionEnabled, + ) // Compare cohort *contents*, not just lengths: a same-size but // differently-composed drag cohort (e.g. linked items swapped in) must // still re-sync the selection. @@ -794,6 +879,13 @@ export function useTimelineDrag( selectItems(baseItemsToDrag) } + if (isBlockedByLockedLinkedItem || draggedItems.length === 0) { + isLinkedCohortDragRef.current = false + return + } + + isLinkedCohortDragRef.current = isLinkedCohort + // Initialize drag state dragStateRef.current = { itemId: item.id, // Anchor item @@ -942,9 +1034,11 @@ export function useTimelineDrag( tracksRef.current.map((currentTrack, index) => [currentTrack.id, index]), ) const dropTarget = getTrackDropTarget(e.clientY, dragStateRef.current.startTrackId) - const previewTrackTargets = resolveDraggedTrackTargets({ + const previewTrackResolution = resolveDraggedTrackTargets({ items: currentItems, draggedItems: dragStateRef.current.draggedItems, + anchorItemId: dragStateRef.current.itemId, + isLinkedCohort: isLinkedCohortDragRef.current, tracks: tracksRef.current, dropTarget, preferredTrackHeight: @@ -953,20 +1047,25 @@ export function useTimelineDrag( ?.height ?? 64, }) + const previewTrackTargets = previewTrackResolution.trackTargets const hoveredCompatibleTrackId = getCompatibleTrackIdFromMouseY( e.clientY, dragStateRef.current.startTrackId, item.type, ) const hasInvalidExplicitDropTarget = - dropTarget.zone !== null && !previewTrackTargets && hoveredCompatibleTrackId === null + dropTarget.zone !== null && + !previewTrackTargets && + (previewTrackResolution.isLinkedCohort || hoveredCompatibleTrackId === null) const linkedDropTarget = dropTarget.zone && !hasInvalidExplicitDropTarget ? { trackId: dropTarget.trackId, zone: dropTarget.zone, createNew: dropTarget.createNew } : null const previewAnchorTrackId = previewTrackTargets?.trackAssignments.get(dragStateRef.current.itemId) ?? - hoveredCompatibleTrackId ?? + (previewTrackResolution.isLinkedCohort && dropTarget.zone + ? null + : hoveredCompatibleTrackId) ?? dragStateRef.current.startTrackId dragStateRef.current.currentMouseX = e.clientX dragStateRef.current.currentMouseY = e.clientY @@ -1039,19 +1138,17 @@ export function useTimelineDrag( const sourceItem = currentItemById.get(draggedItem.id) if (!sourceItem) return null - let itemNewTrackId = previewTrackTargets?.trackAssignments.get(draggedItem.id) - if (!itemNewTrackId) { - const anchorTrackIndex = trackIndexById.get(dragStateRef.current!.startTrackId) ?? -1 - const itemTrackIndex = trackIndexById.get(draggedItem.initialTrackId) ?? -1 - const newAnchorTrackIndex = trackIndexById.get(previewAnchorTrackId) ?? -1 - const trackOffset = itemTrackIndex - anchorTrackIndex - const newItemTrackIndex = Math.max( - 0, - Math.min(tracksRef.current.length - 1, newAnchorTrackIndex + trackOffset), - ) - itemNewTrackId = - tracksRef.current[newItemTrackIndex]?.id || draggedItem.initialTrackId - } + const itemNewTrackId = resolveMultiDragTrackId({ + draggedItem, + trackTargets: previewTrackTargets, + isLinkedCohort: previewTrackResolution.isLinkedCohort, + dropZone: dropTarget.zone, + trackIndexById, + tracks: tracksRef.current, + anchorTrackId: dragStateRef.current!.startTrackId, + targetAnchorTrackId: previewAnchorTrackId, + }) + if (!itemNewTrackId) return null return { id: draggedItem.id, @@ -1216,9 +1313,11 @@ export function useTimelineDrag( const currentItems = getItems() const dropTarget = getTrackDropTarget(dragState.currentMouseY, dragState.startTrackId) - const resolvedTrackTargets = resolveDraggedTrackTargets({ + const resolvedTrackResolution = resolveDraggedTrackTargets({ items: currentItems, draggedItems: dragState.draggedItems, + anchorItemId: dragState.itemId, + isLinkedCohort: isLinkedCohortDragRef.current, tracks: tracksRef.current, dropTarget, preferredTrackHeight: @@ -1226,11 +1325,24 @@ export function useTimelineDrag( tracksRef.current.find((track) => track.id === dragState.startTrackId)?.height ?? 64, }) + const resolvedTrackTargets = resolvedTrackResolution.trackTargets + const hasIncompleteLinkedTrackTargets = + resolvedTrackResolution.isLinkedCohort && + dropTarget.zone !== null && + (!resolvedTrackTargets || + dragState.draggedItems.some( + (draggedItem) => !resolvedTrackTargets.trackAssignments.has(draggedItem.id), + )) // Calculate new track for anchor item - const newTrackId = - resolvedTrackTargets?.trackAssignments.get(dragState.itemId) ?? - getCompatibleTrackIdFromMouseY(dragState.currentMouseY, dragState.startTrackId, item.type) + const newTrackId = hasIncompleteLinkedTrackTargets + ? null + : (resolvedTrackTargets?.trackAssignments.get(dragState.itemId) ?? + getCompatibleTrackIdFromMouseY( + dragState.currentMouseY, + dragState.startTrackId, + item.type, + )) // Multi-item drag or single? if (newTrackId === null) { @@ -1275,6 +1387,9 @@ export function useTimelineDrag( // Calculate group clamp offset - if any item would go below 0, shift the whole group const groupClampOffset = minProposedFrame < 0 ? -minProposedFrame : 0 + const resolvedTrackIndexById = new Map( + tracksRef.current.map((track, index) => [track.id, index]), + ) // Multi-item drag: calculate new positions for all items const movedItems = dragState.draggedItems @@ -1286,24 +1401,17 @@ export function useTimelineDrag( // Apply frame delta, snap adjustment, AND group clamp offset to all items uniformly const newFrom = draggedItem.initialFrame + deltaFrames + snapDelta + groupClampOffset - let itemNewTrackId = resolvedTrackTargets?.trackAssignments.get(draggedItem.id) - if (!itemNewTrackId) { - const anchorTrackIndex = tracksRef.current.findIndex( - (t) => t.id === dragState.startTrackId, - ) - const itemTrackIndex = tracksRef.current.findIndex( - (t) => t.id === draggedItem.initialTrackId, - ) - const newAnchorTrackIndex = tracksRef.current.findIndex((t) => t.id === newTrackId) - const trackOffset = itemTrackIndex - anchorTrackIndex - const newItemTrackIndex = Math.max( - 0, - Math.min(tracksRef.current.length - 1, newAnchorTrackIndex + trackOffset), - ) - - itemNewTrackId = - tracksRef.current[newItemTrackIndex]?.id || draggedItem.initialTrackId - } + const itemNewTrackId = resolveMultiDragTrackId({ + draggedItem, + trackTargets: resolvedTrackTargets, + isLinkedCohort: resolvedTrackResolution.isLinkedCohort, + dropZone: dropTarget.zone, + trackIndexById: resolvedTrackIndexById, + tracks: tracksRef.current, + anchorTrackId: dragState.startTrackId, + targetAnchorTrackId: newTrackId, + }) + if (!itemNewTrackId) return null return { id: draggedItem.id, diff --git a/src/features/timeline/utils/linked-drag-targeting.test.ts b/src/features/timeline/utils/linked-drag-targeting.test.ts index b6ae1fda5..8abedb650 100644 --- a/src/features/timeline/utils/linked-drag-targeting.test.ts +++ b/src/features/timeline/utils/linked-drag-targeting.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vite-plus/test' import type { TimelineTrack } from '@/types/timeline' import { resolveCreateNewDragTrackTargets, + resolveLinkedCohortDragTrackTargets, resolveLinkedDragTrackTargets, } from './linked-drag-targeting' @@ -187,3 +188,145 @@ describe('resolveCreateNewDragTrackTargets', () => { expect(result).toBeNull() }) }) + +describe('resolveLinkedCohortDragTrackTargets', () => { + const sectionTracks = [ + makeTrack({ id: 'v3', name: 'V3', kind: 'video', order: 0 }), + makeTrack({ id: 'v2', name: 'V2', kind: 'video', order: 1 }), + makeTrack({ id: 'v1', name: 'V1', kind: 'video', order: 2 }), + makeTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 3 }), + makeTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 4 }), + makeTrack({ id: 'a3', name: 'A3', kind: 'audio', order: 5 }), + ] + + it('moves two linked A/V pairs by one shared media-section delta', () => { + const result = resolveLinkedCohortDragTrackTargets({ + tracks: sectionTracks, + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + { id: 'video-2', initialTrackId: 'v2', type: 'video' }, + { id: 'audio-2', initialTrackId: 'a2', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(Object.fromEntries(result?.trackAssignments ?? [])).toEqual({ + 'video-1': 'v2', + 'audio-1': 'a2', + 'video-2': 'v3', + 'audio-2': 'a3', + }) + }) + + it('keeps an attached caption on its relative visual section', () => { + const result = resolveLinkedCohortDragTrackTargets({ + tracks: sectionTracks, + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + { id: 'caption-1', initialTrackId: 'v2', type: 'text' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(Object.fromEntries(result?.trackAssignments ?? [])).toEqual({ + 'video-1': 'v2', + 'audio-1': 'a2', + 'caption-1': 'v3', + }) + }) + + it('anchors a mixed-kind move through the dragged item linked companion section', () => { + const tracks = [ + makeTrack({ id: 'v4', name: 'V4', kind: 'video', order: 0 }), + ...sectionTracks.map((track) => ({ ...track, order: track.order + 1 })), + makeTrack({ id: 'a4', name: 'A4', kind: 'audio', order: 7 }), + ] + const result = resolveLinkedCohortDragTrackTargets({ + tracks, + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a2', type: 'audio' }, + { id: 'visual-extra', initialTrackId: 'v2', type: 'image' }, + { id: 'audio-extra', initialTrackId: 'a3', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'a3', + zone: 'audio', + preferredTrackHeight: 80, + }) + + expect(Object.fromEntries(result?.trackAssignments ?? [])).toEqual({ + 'video-1': 'v2', + 'audio-1': 'a3', + 'visual-extra': 'v3', + 'audio-extra': 'a4', + }) + }) + + it('creates corresponding outer video and audio lanes for multiple pairs', () => { + const tracks = [ + makeTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const result = resolveLinkedCohortDragTrackTargets({ + tracks, + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + { id: 'video-2', initialTrackId: 'v2', type: 'video' }, + { id: 'audio-2', initialTrackId: 'a2', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + createNew: true, + preferredTrackHeight: 80, + }) + + const assignments = result?.trackAssignments + expect(assignments?.get('video-1')).toBe('v2') + expect(assignments?.get('audio-1')).toBe('a2') + expect(result?.tracks.find((track) => track.id === assignments?.get('video-2'))).toMatchObject({ + kind: 'video', + name: 'V3', + }) + expect(result?.tracks.find((track) => track.id === assignments?.get('audio-2'))).toMatchObject({ + kind: 'audio', + name: 'A3', + }) + expect(result?.tracks).toHaveLength(6) + }) + + it('rejects the cohort when an implicit companion source track is locked', () => { + const result = resolveLinkedCohortDragTrackTargets({ + tracks: sectionTracks.map((track) => + track.id === 'a1' ? { ...track, locked: true } : track, + ), + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(result).toBeNull() + }) +}) diff --git a/src/features/timeline/utils/linked-drag-targeting.ts b/src/features/timeline/utils/linked-drag-targeting.ts index b1333b35e..23976753e 100644 --- a/src/features/timeline/utils/linked-drag-targeting.ts +++ b/src/features/timeline/utils/linked-drag-targeting.ts @@ -32,6 +32,17 @@ export interface CreateNewDragTrackTargetResult { trackAssignments: Map } +export interface LinkedDragCohortItem { + id: string + initialTrackId: string + type: TimelineItem['type'] +} + +export interface LinkedDragCohortTrackTargetResult { + tracks: TimelineTrack[] + trackAssignments: Map +} + function getKindTracks(tracks: TimelineTrack[], kind: TrackKind): TimelineTrack[] { return [...tracks] .filter((track) => getTrackKind(track) === kind) @@ -116,6 +127,275 @@ function getDraggedItemTrackKind(type: TimelineItem['type']): TrackKind { return type === 'audio' ? 'audio' : 'video' } +/** + * Return lanes in section order, starting at the A/V divider and moving + * outward. Video order is therefore the reverse of its visual top-to-bottom + * order, while audio order already starts at the divider. + */ +function getSectionTracks(tracks: TimelineTrack[], kind: TrackKind): TimelineTrack[] { + const kindTracks = getKindTracks(tracks, kind) + return kind === 'video' ? kindTracks.reverse() : kindTracks +} + +function getTrackSectionIndex(tracks: TimelineTrack[], kind: TrackKind, trackId: string): number { + return getSectionTracks(tracks, kind).findIndex((track) => track.id === trackId) +} + +function ensureTrackSectionIndex(params: EnsureTrackIndexParams): { + tracks: TimelineTrack[] + trackId: string +} { + const { kind, index, preferredTrackHeight } = params + let workingTracks = [...params.tracks] + + while (getSectionTracks(workingTracks, kind).length <= index) { + workingTracks = addCreateNewTrack({ + tracks: workingTracks, + kind, + preferredTrackHeight, + }) + } + + return { + tracks: workingTracks, + trackId: getSectionTracks(workingTracks, kind)[index]!.id, + } +} + +interface CohortTrackPlan { + item: LinkedDragCohortItem + kind: TrackKind + sourceSection: number +} + +interface CohortTrackPlanState { + tracks: TimelineTrack[] + plans: CohortTrackPlan[] +} + +function upgradeCohortSourceTracks( + tracks: TimelineTrack[], + draggedItems: LinkedDragCohortItem[], +): TimelineTrack[] | null { + let workingTracks = [...tracks] + for (const draggedItem of draggedItems) { + const kind = getDraggedItemTrackKind(draggedItem.type) + const sourceTrack = workingTracks.find((track) => track.id === draggedItem.initialTrackId) + if (!sourceTrack || sourceTrack.isGroup || sourceTrack.locked) return null + + const sourceKind = getTrackKind(sourceTrack) + if (sourceKind !== null && sourceKind !== kind) return null + if (sourceKind === null) { + const upgradedTrack = renameTrackForKind(sourceTrack, workingTracks, kind) + workingTracks = workingTracks.map((track) => + track.id === sourceTrack.id ? upgradedTrack : track, + ) + } + } + + return workingTracks +} + +function createCohortTrackPlans( + tracks: TimelineTrack[], + draggedItems: LinkedDragCohortItem[], +): CohortTrackPlan[] | null { + const plans: CohortTrackPlan[] = [] + for (const draggedItem of draggedItems) { + const kind = getDraggedItemTrackKind(draggedItem.type) + const sourceSection = getTrackSectionIndex(tracks, kind, draggedItem.initialTrackId) + if (sourceSection < 0) return null + plans.push({ item: draggedItem, kind, sourceSection }) + } + + return plans +} + +function buildCohortTrackPlans( + tracks: TimelineTrack[], + draggedItems: LinkedDragCohortItem[], +): CohortTrackPlanState | null { + if (draggedItems.length === 0) return null + + const workingTracks = upgradeCohortSourceTracks(tracks, draggedItems) + if (!workingTracks) return null + + const plans = createCohortTrackPlans(workingTracks, draggedItems) + if (!plans) return null + + return { tracks: workingTracks, plans } +} + +function getSourceAnchorSection(params: { + plans: CohortTrackPlan[] + zoneKind: TrackKind + anchorItemId: string + anchorRelatedItemIds: readonly string[] +}): number | null { + const relatedIds = new Set([params.anchorItemId, ...params.anchorRelatedItemIds]) + const anchorPlan = params.plans.find( + (plan) => plan.item.id === params.anchorItemId && plan.kind === params.zoneKind, + ) + const relatedZonePlan = params.plans.find( + (plan) => relatedIds.has(plan.item.id) && plan.kind === params.zoneKind, + ) + const fallbackAnchorPlan = params.plans.find((plan) => plan.item.id === params.anchorItemId) + + return ( + anchorPlan?.sourceSection ?? + relatedZonePlan?.sourceSection ?? + fallbackAnchorPlan?.sourceSection ?? + null + ) +} + +function resolveExistingCohortDrop(params: { + tracks: TimelineTrack[] + plans: CohortTrackPlan[] + zoneKind: TrackKind + anchorItemId: string + anchorRelatedItemIds: readonly string[] + hoveredTrackId: string +}): { tracks: TimelineTrack[]; sectionDelta: number } | null { + let workingTracks = params.tracks + let hoveredTrack = workingTracks.find((track) => track.id === params.hoveredTrackId) + if (!hoveredTrack || hoveredTrack.isGroup) return null + + let hoveredKind = getTrackKind(hoveredTrack) + if (hoveredKind === null) { + if (hoveredTrack.locked) return null + const upgradedTrack = renameTrackForKind(hoveredTrack, workingTracks, params.zoneKind) + workingTracks = workingTracks.map((track) => + track.id === hoveredTrack!.id ? upgradedTrack : track, + ) + hoveredTrack = upgradedTrack + hoveredKind = params.zoneKind + } + + const targetSection = getTrackSectionIndex(workingTracks, hoveredKind, hoveredTrack.id) + const sourceAnchorSection = getSourceAnchorSection(params) + if (targetSection < 0 || sourceAnchorSection === null) return null + + return { + tracks: workingTracks, + sectionDelta: targetSection - sourceAnchorSection, + } +} + +function getCreateNewCohortSectionDelta( + tracks: TimelineTrack[], + plans: CohortTrackPlan[], + zoneKind: TrackKind, +): number | null { + const zonePlans = plans.filter((plan) => plan.kind === zoneKind) + if (zonePlans.length === 0) return null + + const outermostSourceSection = Math.max(...zonePlans.map((plan) => plan.sourceSection)) + return getSectionTracks(tracks, zoneKind).length - outermostSourceSection +} + +function assignCohortTrackTargets(params: { + tracks: TimelineTrack[] + plans: CohortTrackPlan[] + sectionDelta: number + preferredTrackHeight: number +}): LinkedDragCohortTrackTargetResult | null { + let workingTracks = params.tracks + const targetTrackIdBySource = new Map() + const sourcePlans = Array.from( + new Map( + params.plans.map((plan) => [ + `${plan.kind}:${plan.item.initialTrackId}`, + { + key: `${plan.kind}:${plan.item.initialTrackId}`, + kind: plan.kind, + targetSection: plan.sourceSection + params.sectionDelta, + }, + ]), + ).values(), + ).sort((left, right) => left.targetSection - right.targetSection) + + for (const sourcePlan of sourcePlans) { + const ensuredTrack = ensureTrackSectionIndex({ + tracks: workingTracks, + kind: sourcePlan.kind, + index: sourcePlan.targetSection, + preferredTrackHeight: params.preferredTrackHeight, + }) + workingTracks = ensuredTrack.tracks + + const targetTrack = workingTracks.find((track) => track.id === ensuredTrack.trackId) + if (!targetTrack || targetTrack.locked) return null + targetTrackIdBySource.set(sourcePlan.key, targetTrack.id) + } + + const trackAssignments = new Map() + for (const plan of params.plans) { + const targetTrackId = targetTrackIdBySource.get(`${plan.kind}:${plan.item.initialTrackId}`) + if (!targetTrackId) return null + trackAssignments.set(plan.item.id, targetTrackId) + } + + return { tracks: workingTracks, trackAssignments } +} + +/** + * Resolve every member of a linked drag cohort by media section instead of by + * raw global track index. The same section delta is applied to video, audio, + * and attached visual items, preserving relative lane relationships while + * keeping each item in a compatible media section. + */ +export function resolveLinkedCohortDragTrackTargets(params: { + tracks: TimelineTrack[] + draggedItems: LinkedDragCohortItem[] + anchorItemId: string + anchorRelatedItemIds?: readonly string[] + hoveredTrackId: string + zone: LinkedDragDropZone + createNew?: boolean + preferredTrackHeight: number +}): LinkedDragCohortTrackTargetResult | null { + const { + tracks, + draggedItems, + anchorItemId, + anchorRelatedItemIds = [], + hoveredTrackId, + zone, + createNew = false, + preferredTrackHeight, + } = params + const planState = buildCohortTrackPlans(tracks, draggedItems) + if (!planState) return null + + const { plans } = planState + const zoneKind: TrackKind = zone + const dropState = createNew + ? { + tracks: planState.tracks, + sectionDelta: getCreateNewCohortSectionDelta(planState.tracks, plans, zoneKind), + } + : resolveExistingCohortDrop({ + tracks: planState.tracks, + plans, + zoneKind, + anchorItemId, + anchorRelatedItemIds, + hoveredTrackId, + }) + if (!dropState || dropState.sectionDelta === null) return null + + const innermostSourceSection = Math.min(...plans.map((plan) => plan.sourceSection)) + const sectionDelta = Math.max(dropState.sectionDelta, -innermostSourceSection) + + return assignCohortTrackTargets({ + tracks: dropState.tracks, + plans, + sectionDelta, + preferredTrackHeight, + }) +} + function buildContiguousTrackAssignment(params: { sourceTrackIds: string[] targetTracks: TimelineTrack[] From bec045c3f2d122826576f602c4165fa9eee60f7b Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 19:43:22 -0700 Subject: [PATCH 31/64] fix(timeline): validate linked cohort drag placement (cherry picked from commit da53e8aa624dc7a11f971b7d933f6e47752a16d0) --- .../timeline/hooks/use-timeline-drag.test.tsx | 198 +++++++++++++ .../timeline/hooks/use-timeline-drag.ts | 278 +++++++++--------- .../timeline/utils/collision-utils.ts | 62 ++++ .../utils/linked-drag-targeting.test.ts | 83 ++++++ .../timeline/utils/linked-drag-targeting.ts | 54 +++- 5 files changed, 535 insertions(+), 140 deletions(-) diff --git a/src/features/timeline/hooks/use-timeline-drag.test.tsx b/src/features/timeline/hooks/use-timeline-drag.test.tsx index 01d67cf7f..7977593cb 100644 --- a/src/features/timeline/hooks/use-timeline-drag.test.tsx +++ b/src/features/timeline/hooks/use-timeline-drag.test.tsx @@ -214,6 +214,73 @@ describe('useTimelineDrag linked cohorts', () => { expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) }) + it('finds one shared correction that stays clear across conflicting destination lanes', () => { + const tracks = makeThreeSectionTracks() + const video1 = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const audio1 = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const video2 = makeTimelineVideoItem({ + id: 'video-2', + trackId: 'v2', + from: 30, + durationInFrames: 10, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + const audio2 = makeTimelineAudioItem({ + id: 'audio-2', + trackId: 'a2', + from: 30, + durationInFrames: 10, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + const innerBlocker = makeTimelineVideoItem({ + id: 'inner-blocker', + trackId: 'v2', + from: 18, + durationInFrames: 8, + mediaId: 'inner-blocker-media', + }) + const outerBlocker = makeTimelineVideoItem({ + id: 'outer-blocker', + trackId: 'v3', + from: 60, + durationInFrames: 10, + mediaId: 'outer-blocker-media', + }) + setupStores(tracks, [video1, audio1, video2, audio2, innerBlocker, outerBlocker]) + useSelectionStore.getState().selectItems(['video-1', 'video-2']) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video1, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + // The inner blocker alone suggests +6, which would overlap the outer + // blocker. The nearest cohort-wide valid correction is instead -12. + expect(getItem('video-1')).toMatchObject({ trackId: 'v2', from: 8 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a2', from: 8 }) + expect(getItem('video-2')).toMatchObject({ trackId: 'v3', from: 38 }) + expect(getItem('audio-2')).toMatchObject({ trackId: 'a3', from: 38 }) + expect(getItem('video-2').from - getItem('video-1').from).toBe(30) + expect(getItem('video-1').from + getItem('video-1').durationInFrames).toBe(18) + expect(getItem('video-2').from + getItem('video-2').durationInFrames).toBeLessThan(60) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) + it('moves an attached caption on its visual section without losing its frame offset', () => { const tracks = makeThreeSectionTracks() const video = makeTimelineVideoItem({ @@ -348,6 +415,137 @@ describe('useTimelineDrag linked cohorts', () => { expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) }) + it('rejects an explicit source member on a child lane of a locked group', () => { + const tracks = [ + makeTimelineTrack({ + id: 'locked-group', + name: 'Locked Group', + order: 0, + isGroup: true, + locked: true, + }), + makeTimelineTrack({ + id: 'v1', + name: 'V1', + kind: 'video', + order: 1, + parentTrackId: 'locked-group', + }), + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 2 }), + ] + const video = makeTimelineVideoItem({ id: 'video-1', trackId: 'v1' }) + setupStores(tracks, [video]) + const beforeItems = structuredClone(useItemsStore.getState().items) + const beforeTracks = structuredClone(useItemsStore.getState().tracks) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(result.current.isDragging).toBe(false) + expect(useItemsStore.getState().items).toEqual(beforeItems) + expect(useItemsStore.getState().tracks).toEqual(beforeTracks) + expect(useSelectionStore.getState().selectedItemIds).toEqual([]) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + }) + + it('rejects an implicit linked companion inherited-locked by its parent group atomically', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ + id: 'locked-group', + name: 'Locked Group', + order: 2, + isGroup: true, + locked: true, + }), + makeTimelineTrack({ + id: 'a1', + name: 'A1', + kind: 'audio', + order: 3, + parentTrackId: 'locked-group', + }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 4 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + setupStores(tracks, [video, audio]) + const beforeItems = structuredClone(useItemsStore.getState().items) + const beforeTracks = structuredClone(useItemsStore.getState().tracks) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(result.current.isDragging).toBe(false) + expect(useItemsStore.getState().items).toEqual(beforeItems) + expect(useItemsStore.getState().tracks).toEqual(beforeTracks) + expect(useSelectionStore.getState().selectedItemIds).toEqual([]) + expect(useSelectionStore.getState().dragState).toBeNull() + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + }) + + it('rejects the whole cohort when an implicit destination lane inherits a group lock', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ + id: 'locked-group', + name: 'Locked Group', + order: 3, + isGroup: true, + locked: true, + }), + makeTimelineTrack({ + id: 'a2', + name: 'A2', + kind: 'audio', + order: 4, + parentTrackId: 'locked-group', + }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + setupStores(tracks, [video, audio]) + useSelectionStore.getState().selectItems(['video-1', 'audio-1']) + const beforeItems = structuredClone(useItemsStore.getState().items) + const beforeTracks = structuredClone(useItemsStore.getState().tracks) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(useItemsStore.getState().items).toEqual(beforeItems) + expect(useItemsStore.getState().tracks).toEqual(beforeTracks) + expect(useSelectionStore.getState().selectedItemIds).toEqual(['video-1', 'audio-1']) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + }) + it('keeps unlinked multi-select lock filtering behavior unchanged', () => { const tracks = [ makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0, locked: true }), diff --git a/src/features/timeline/hooks/use-timeline-drag.ts b/src/features/timeline/hooks/use-timeline-drag.ts index d59a8c069..1c507077e 100644 --- a/src/features/timeline/hooks/use-timeline-drag.ts +++ b/src/features/timeline/hooks/use-timeline-drag.ts @@ -10,7 +10,10 @@ import { frameToPixelsNow, } from '@/features/timeline/utils/zoom-conversions' import { useSnapCalculator } from './use-snap-calculator' -import { findNearestAvailableSpace } from '../utils/collision-utils' +import { + findNearestAvailableSharedOffset, + findNearestAvailableSpace, +} from '../utils/collision-utils' import { getTrackKind } from '../utils/classic-tracks' import { expandItemIdsWithAttachedCaptions, @@ -29,6 +32,7 @@ import { useLinkedEditPreviewStore } from '../stores/linked-edit-preview-store' import { DRAG_THRESHOLD_PIXELS } from '../constants' import { createLogger } from '@/shared/logging/logger' import { createRafCoalescedCallback } from '../utils/raf-coalesced-callback' +import { resolveEffectiveTrackStates } from '../utils/group-utils' const logger = createLogger('TimelineDrag') @@ -455,6 +459,33 @@ interface DraggedItemState { initialTrackId: string } +function getEffectiveTrackStateById(tracks: TimelineTrack[]): ReadonlyMap { + return new Map(resolveEffectiveTrackStates(tracks).map((track) => [track.id, track])) +} + +function areItemSourceTracksUnlocked( + allItems: TimelineItem[], + tracks: TimelineTrack[], + itemIds: readonly string[], +): boolean { + const itemById = new Map(allItems.map((currentItem) => [currentItem.id, currentItem])) + const effectiveTrackById = getEffectiveTrackStateById(tracks) + + return itemIds.every((itemId) => { + const sourceItem = itemById.get(itemId) + const sourceTrack = sourceItem ? effectiveTrackById.get(sourceItem.trackId) : undefined + return sourceTrack?.locked === false + }) +} + +function areDestinationTracksUnlocked( + tracks: TimelineTrack[], + trackIds: readonly string[], +): boolean { + const effectiveTrackById = getEffectiveTrackStateById(tracks) + return trackIds.every((trackId) => effectiveTrackById.get(trackId)?.locked === false) +} + /** * Resolve the full set of items a drag should move and their initial positions: * expand the base selection (linked items when enabled, else the raw selection @@ -482,7 +513,11 @@ function resolveDraggedItemStates( : currentSelectedIds : linkedIds const itemsToDrag = expandItemIdsWithAttachedCaptions(allItems, baseItemsToDrag) - const unlockedItemIds = filterUnlockedItemIds(allItems, currentTracks, itemsToDrag) + const unlockedItemIds = filterUnlockedItemIds( + allItems, + resolveEffectiveTrackStates(currentTracks), + itemsToDrag, + ) const isLinkedCohort = isLinkedDragCohort(allItems, itemsToDrag) const isBlockedByLockedLinkedItem = isLinkedCohort && unlockedItemIds.length !== itemsToDrag.length @@ -753,7 +788,7 @@ export function useTimelineDrag( (mouseY: number, startTrackId: string, itemType: TimelineItem['type']): string | null => { const hoveredTrackId = getTrackIdFromMouseY(mouseY, startTrackId) const compatibleTrack = findCompatibleTrackForItemType({ - tracks: tracksRef.current, + tracks: resolveEffectiveTrackStates(tracksRef.current), items: getItems(), itemType, preferredTrackId: hoveredTrackId, @@ -827,10 +862,13 @@ export function useTimelineDrag( */ const handleDragStart = useCallback( (e: React.MouseEvent) => { - // Don't allow dragging on locked tracks - if (trackLocked) { - return - } + const allItems = getItems() + const currentTracks = useTimelineStore.getState().tracks + const anchorTrack = getEffectiveTrackStateById(currentTracks).get(item.trackId) + + // The caller supplies the rendered lock state, but re-read canonical + // effective state so a child lane cannot bypass a locked Layer Group. + if (trackLocked || !anchorTrack || anchorTrack.locked) return // Prevent if clicking on resize handles const target = e.target as HTMLElement @@ -844,19 +882,9 @@ export function useTimelineDrag( const currentSelectedIds = useSelectionStore.getState().selectedItemIds const isInSelection = currentSelectedIds.includes(item.id) - const allItems = getItems() - const currentTracks = tracksRef.current const linkedSelectionEnabled = useEditorStore.getState().linkedSelectionEnabled - // If not in selection, select it (multi-select handled by TimelineItem's onClick). - // Skip when a multi-select modifier is held: replacing the selection here - // would wipe the existing multi-selection, and the click handler's additive - // toggle would then read this clip as "already selected" and remove it again. - const isMultiSelectClick = e.ctrlKey || e.metaKey const linkedIds = linkedSelectionEnabled ? getLinkedItemIds(allItems, item.id) : [item.id] - if (!isInSelection && !isMultiSelectClick) { - selectItems(linkedIds) - } // Determine which items to drag and snapshot their initial positions const { baseItemsToDrag, draggedItems, isLinkedCohort, isBlockedByLockedLinkedItem } = @@ -868,6 +896,18 @@ export function useTimelineDrag( linkedIds, linkedSelectionEnabled, ) + if (isBlockedByLockedLinkedItem || draggedItems.length === 0) { + isLinkedCohortDragRef.current = false + return + } + + // Only mutate selection after the complete cohort passes source-lock + // validation. A rejected linked gesture is otherwise not atomic. + const isMultiSelectClick = e.ctrlKey || e.metaKey + if (!isInSelection && !isMultiSelectClick) { + selectItems(linkedIds) + } + // Compare cohort *contents*, not just lengths: a same-size but // differently-composed drag cohort (e.g. linked items swapped in) must // still re-sync the selection. @@ -879,11 +919,6 @@ export function useTimelineDrag( selectItems(baseItemsToDrag) } - if (isBlockedByLockedLinkedItem || draggedItems.length === 0) { - isLinkedCohortDragRef.current = false - return - } - isLinkedCohortDragRef.current = isLinkedCohort // Initialize drag state @@ -1168,31 +1203,24 @@ export function useTimelineDrag( durationInFrames: number }> - // Wall-clamp the group: find tightest constraint across all items, - // then shift the entire group by the same delta so they stay together. + // Resolve one offset against every destination lane. Per-item wall + // clamps can move a previously clear member into another blocker. if (!isAltDragRef.current) { const groupExcludeIds = new Set(previewMovedItems.map((m) => m.id)) - let wallClampDelta = 0 - for (const previewItem of previewMovedItems) { - const clamped = clampToTrackWalls( - previewItem.newFrom, - previewItem.durationInFrames, - previewItem.newTrackId, - groupExcludeIds, - currentItems, - currentItemsByTrackId, - ) - const itemDelta = clamped - previewItem.newFrom - // Pick the tightest (smallest magnitude) clamp in each direction - if (itemDelta < 0 && (wallClampDelta >= 0 || itemDelta > wallClampDelta)) { - wallClampDelta = itemDelta - } else if (itemDelta > 0 && (wallClampDelta <= 0 || itemDelta < wallClampDelta)) { - wallClampDelta = itemDelta - } - } - if (wallClampDelta !== 0) { + const previewBlockers = currentItems.filter( + (currentItem) => !groupExcludeIds.has(currentItem.id), + ) + const sharedPreviewOffset = findNearestAvailableSharedOffset( + previewMovedItems.map((previewItem) => ({ + trackId: previewItem.newTrackId, + from: previewItem.newFrom, + durationInFrames: previewItem.durationInFrames, + })), + previewBlockers, + ) + if (sharedPreviewOffset !== null && sharedPreviewOffset !== 0) { for (const previewItem of previewMovedItems) { - previewItem.newFrom += wallClampDelta + previewItem.newFrom += sharedPreviewOffset } } } @@ -1312,19 +1340,27 @@ export function useTimelineDrag( const deltaFrames = pixelsToFramePreciseRef.current(deltaX) const currentItems = getItems() + const currentTracks = useTimelineStore.getState().tracks + const hasLockedSource = !areItemSourceTracksUnlocked( + currentItems, + currentTracks, + dragState.draggedItems.map((draggedItem) => draggedItem.id), + ) const dropTarget = getTrackDropTarget(dragState.currentMouseY, dragState.startTrackId) - const resolvedTrackResolution = resolveDraggedTrackTargets({ - items: currentItems, - draggedItems: dragState.draggedItems, - anchorItemId: dragState.itemId, - isLinkedCohort: isLinkedCohortDragRef.current, - tracks: tracksRef.current, - dropTarget, - preferredTrackHeight: - tracksRef.current.find((track) => track.id === dropTarget.trackId)?.height ?? - tracksRef.current.find((track) => track.id === dragState.startTrackId)?.height ?? - 64, - }) + const resolvedTrackResolution = hasLockedSource + ? { trackTargets: null, isLinkedCohort: isLinkedCohortDragRef.current } + : resolveDraggedTrackTargets({ + items: currentItems, + draggedItems: dragState.draggedItems, + anchorItemId: dragState.itemId, + isLinkedCohort: isLinkedCohortDragRef.current, + tracks: currentTracks, + dropTarget, + preferredTrackHeight: + currentTracks.find((track) => track.id === dropTarget.trackId)?.height ?? + currentTracks.find((track) => track.id === dragState.startTrackId)?.height ?? + 64, + }) const resolvedTrackTargets = resolvedTrackResolution.trackTargets const hasIncompleteLinkedTrackTargets = resolvedTrackResolution.isLinkedCohort && @@ -1335,18 +1371,23 @@ export function useTimelineDrag( )) // Calculate new track for anchor item - const newTrackId = hasIncompleteLinkedTrackTargets - ? null - : (resolvedTrackTargets?.trackAssignments.get(dragState.itemId) ?? - getCompatibleTrackIdFromMouseY( - dragState.currentMouseY, - dragState.startTrackId, - item.type, - )) + const newTrackId = + hasLockedSource || hasIncompleteLinkedTrackTargets + ? null + : (resolvedTrackTargets?.trackAssignments.get(dragState.itemId) ?? + getCompatibleTrackIdFromMouseY( + dragState.currentMouseY, + dragState.startTrackId, + item.type, + )) // Multi-item drag or single? if (newTrackId === null) { - logger.warn('Cannot move items to an incompatible track') + logger.warn( + hasLockedSource + ? 'Cannot move items from a locked track' + : 'Cannot move items to an incompatible track', + ) } else if (dragState.draggedItems.length > 1) { // Multi-item drag: calculate group bounding box for snapping // Snap should only happen at the edges of the entire selection, not individual items @@ -1388,7 +1429,7 @@ export function useTimelineDrag( // Calculate group clamp offset - if any item would go below 0, shift the whole group const groupClampOffset = minProposedFrame < 0 ? -minProposedFrame : 0 const resolvedTrackIndexById = new Map( - tracksRef.current.map((track, index) => [track.id, index]), + currentTracks.map((track, index) => [track.id, index]), ) // Multi-item drag: calculate new positions for all items @@ -1407,7 +1448,7 @@ export function useTimelineDrag( isLinkedCohort: resolvedTrackResolution.isLinkedCohort, dropZone: dropTarget.zone, trackIndexById: resolvedTrackIndexById, - tracks: tracksRef.current, + tracks: currentTracks, anchorTrackId: dragState.startTrackId, targetAnchorTrackId: newTrackId, }) @@ -1427,70 +1468,37 @@ export function useTimelineDrag( durationInFrames: number }> - // For multi-item drag: check if ANY item would collide, and if so, snap the whole group forward - // Find the earliest collision among all moved items const draggedItemIds = movedItems.map((m) => m.id) // For alt-drag (duplicate), include all items in collision check since originals stay in place const itemsExcludingDragged = isAltDrag ? currentItems : currentItems.filter((i) => !draggedItemIds.includes(i.id)) - let maxSnapForward = 0 // largest positive shift needed - let maxSnapBackward = 0 // largest negative shift needed (stored as negative) - - for (const movedItem of movedItems) { - const finalPosition = findNearestAvailableSpace( - movedItem.newFrom, - movedItem.durationInFrames, - movedItem.newTrackId, - itemsExcludingDragged, - ) - - if (finalPosition === null) { - logger.warn( - isAltDrag - ? 'Cannot duplicate items: no available space' - : 'Cannot move items: no available space', + const destinationTracks = resolvedTrackTargets?.tracks ?? currentTracks + const destinationsUnlocked = areDestinationTracksUnlocked( + destinationTracks, + movedItems.map((movedItem) => movedItem.newTrackId), + ) + const groupSnapDelta = destinationsUnlocked + ? findNearestAvailableSharedOffset( + movedItems.map((movedItem) => ({ + trackId: movedItem.newTrackId, + from: movedItem.newFrom, + durationInFrames: movedItem.durationInFrames, + })), + itemsExcludingDragged, ) - // Clean up and cancel - defer drag state to avoid render cascade - if (elementRef?.current) { - elementRef.current.style.transform = '' - } - dragOffsetRef.current = { x: 0, y: 0 } - dragVisualTopByTrackIdRef.current.clear() - dragPreviewOffsetByItemRef.current = {} - clearLargeAltDragCanvas() - clearLinkedMovePreview() - prevSnapTargetRef.current = null - magneticSnapTargetsRef.current = [] - dragStateRef.current = null - isAltDragRef.current = false - clearGlobalDragCursor() - document.body.style.userSelect = '' - setIsDragging(false) - setDragOffset({ x: 0, y: 0 }) - queueMicrotask(() => { - setActiveSnapTarget(null) - setActiveLinkedDropTarget(null) - setDragState(null) - }) - return - } - - const snapAmount = finalPosition - movedItem.newFrom - if (snapAmount > maxSnapForward) { - maxSnapForward = snapAmount - } - if (snapAmount < maxSnapBackward) { - maxSnapBackward = snapAmount - } - } - - // Pick whichever direction has the larger correction needed - const groupSnapDelta = - Math.abs(maxSnapForward) >= Math.abs(maxSnapBackward) ? maxSnapForward : maxSnapBackward + : null - if (isAltDrag) { + if (groupSnapDelta === null) { + logger.warn( + destinationsUnlocked + ? isAltDrag + ? 'Cannot duplicate items: no available space' + : 'Cannot move items: no available space' + : 'Cannot move items to a locked track', + ) + } else if (isAltDrag) { // ALT-DRAG: Duplicate items at new positions const itemIds = movedItems.map((m) => m.id) const positions = movedItems.map((m) => ({ @@ -1538,12 +1546,16 @@ export function useTimelineDrag( const itemsExcludingDragged = isAltDrag ? currentItems : currentItems.filter((i) => i.id !== item.id) - const finalFrame = findNearestAvailableSpace( - proposedFrame, - item.durationInFrames, - newTrackId, - itemsExcludingDragged, - ) + const destinationTracks = resolvedTrackTargets?.tracks ?? currentTracks + const destinationUnlocked = areDestinationTracksUnlocked(destinationTracks, [newTrackId]) + const finalFrame = destinationUnlocked + ? findNearestAvailableSpace( + proposedFrame, + item.durationInFrames, + newTrackId, + itemsExcludingDragged, + ) + : null if (finalFrame !== null) { const roundedFinalFrame = Math.round(finalFrame) @@ -1575,9 +1587,11 @@ export function useTimelineDrag( } else { // No space available - cancel drag (keep at original position) logger.warn( - isAltDrag - ? 'Cannot duplicate item: no available space' - : 'Cannot move item: no available space', + destinationUnlocked + ? isAltDrag + ? 'Cannot duplicate item: no available space' + : 'Cannot move item: no available space' + : 'Cannot move item to a locked track', ) } } diff --git a/src/features/timeline/utils/collision-utils.ts b/src/features/timeline/utils/collision-utils.ts index f79d1846f..01fdb96f7 100644 --- a/src/features/timeline/utils/collision-utils.ts +++ b/src/features/timeline/utils/collision-utils.ts @@ -223,6 +223,68 @@ export function findNearestAvailableSpace( return findNearestAvailableSpaceInTrackItems(proposedFrom, durationInFrames, trackItems) } +/** + * Find one timeline offset that places every cohort member without colliding. + * + * Each blocker creates a finite open interval of invalid offsets for one + * placement. The nearest valid offset must therefore be zero, the frame-zero + * lower bound, or one of those interval boundaries. Checking that finite set + * makes the result deterministic and guarantees that the chosen correction is + * valid for the entire cohort, including placements on different tracks. + */ +export function findNearestAvailableSharedOffset( + placements: ReadonlyArray, + allItems: ReadonlyArray, +): number | null { + if (placements.length === 0) return 0 + if ( + placements.some( + (placement) => + !Number.isFinite(placement.from) || + !Number.isFinite(placement.durationInFrames) || + placement.durationInFrames < 0, + ) + ) { + return null + } + + const minimumOffset = Math.max(...placements.map((placement) => -placement.from)) + const blockersByTrackId = buildCollisionTrackItemsMap(allItems) + const candidates = new Set([minimumOffset]) + if (minimumOffset <= 0) { + candidates.add(0) + } + + for (const placement of placements) { + const placementEnd = placement.from + placement.durationInFrames + for (const blocker of blockersByTrackId.get(placement.trackId) ?? EMPTY_TRACK_ITEMS) { + const blockerEnd = blocker.from + blocker.durationInFrames + candidates.add(blocker.from - placementEnd) + candidates.add(blockerEnd - placement.from) + } + } + + const isValidOffset = (offset: number): boolean => { + if (!Number.isFinite(offset) || offset < minimumOffset) return false + + return placements.every((placement) => { + const start = placement.from + offset + const end = start + placement.durationInFrames + return (blockersByTrackId.get(placement.trackId) ?? EMPTY_TRACK_ITEMS).every((blocker) => { + const blockerEnd = blocker.from + blocker.durationInFrames + return !rangesOverlap(start, end, blocker.from, blockerEnd) + }) + }) + } + + return ( + [...candidates] + .filter((candidate) => candidate >= minimumOffset) + .sort((left, right) => Math.abs(left) - Math.abs(right) || left - right) + .find(isValidOffset) ?? null + ) +} + export interface OverlapInfo { itemA: string itemB: string diff --git a/src/features/timeline/utils/linked-drag-targeting.test.ts b/src/features/timeline/utils/linked-drag-targeting.test.ts index 8abedb650..abdec7d3d 100644 --- a/src/features/timeline/utils/linked-drag-targeting.test.ts +++ b/src/features/timeline/utils/linked-drag-targeting.test.ts @@ -109,6 +109,33 @@ describe('resolveLinkedDragTrackTargets', () => { name: 'A2', }) }) + + it('rejects a hovered child lane that inherits a lock from its parent group', () => { + const result = resolveLinkedDragTrackTargets({ + tracks: [ + makeTrack({ + id: 'locked-group', + name: 'Locked Group', + order: 0, + isGroup: true, + locked: true, + }), + makeTrack({ + id: 'v1', + name: 'V1', + kind: 'video', + order: 1, + parentTrackId: 'locked-group', + }), + makeTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + ], + hoveredTrackId: 'v1', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(result).toBeNull() + }) }) describe('resolveCreateNewDragTrackTargets', () => { @@ -329,4 +356,60 @@ describe('resolveLinkedCohortDragTrackTargets', () => { expect(result).toBeNull() }) + + it('rejects a source lane that inherits a lock from its parent group', () => { + const result = resolveLinkedCohortDragTrackTargets({ + tracks: [ + ...sectionTracks.map((track) => + track.id === 'a1' ? { ...track, parentTrackId: 'locked-group' } : track, + ), + makeTrack({ + id: 'locked-group', + name: 'Locked Group', + order: 6, + isGroup: true, + locked: true, + }), + ], + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(result).toBeNull() + }) + + it('rejects an implicit destination lane that inherits a parent group lock', () => { + const result = resolveLinkedCohortDragTrackTargets({ + tracks: [ + ...sectionTracks.map((track) => + track.id === 'a2' ? { ...track, parentTrackId: 'locked-group' } : track, + ), + makeTrack({ + id: 'locked-group', + name: 'Locked Group', + order: 6, + isGroup: true, + locked: true, + }), + ], + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(result).toBeNull() + }) }) diff --git a/src/features/timeline/utils/linked-drag-targeting.ts b/src/features/timeline/utils/linked-drag-targeting.ts index 23976753e..3c4da0e02 100644 --- a/src/features/timeline/utils/linked-drag-targeting.ts +++ b/src/features/timeline/utils/linked-drag-targeting.ts @@ -5,6 +5,7 @@ import { renameTrackForKind, type TrackKind, } from './classic-tracks' +import { resolveEffectiveTrackStates } from './group-utils' export type LinkedDragDropZone = 'video' | 'audio' @@ -43,6 +44,17 @@ export interface LinkedDragCohortTrackTargetResult { trackAssignments: Map } +function getEffectiveTrackById( + tracks: TimelineTrack[], + trackId: string, +): TimelineTrack | undefined { + return resolveEffectiveTrackStates(tracks).find((track) => track.id === trackId) +} + +function isTrackEffectivelyLocked(tracks: TimelineTrack[], trackId: string): boolean { + return getEffectiveTrackById(tracks, trackId)?.locked !== false +} + function getKindTracks(tracks: TimelineTrack[], kind: TrackKind): TimelineTrack[] { return [...tracks] .filter((track) => getTrackKind(track) === kind) @@ -181,7 +193,13 @@ function upgradeCohortSourceTracks( for (const draggedItem of draggedItems) { const kind = getDraggedItemTrackKind(draggedItem.type) const sourceTrack = workingTracks.find((track) => track.id === draggedItem.initialTrackId) - if (!sourceTrack || sourceTrack.isGroup || sourceTrack.locked) return null + if ( + !sourceTrack || + sourceTrack.isGroup || + isTrackEffectivelyLocked(workingTracks, sourceTrack.id) + ) { + return null + } const sourceKind = getTrackKind(sourceTrack) if (sourceKind !== null && sourceKind !== kind) return null @@ -259,11 +277,16 @@ function resolveExistingCohortDrop(params: { }): { tracks: TimelineTrack[]; sectionDelta: number } | null { let workingTracks = params.tracks let hoveredTrack = workingTracks.find((track) => track.id === params.hoveredTrackId) - if (!hoveredTrack || hoveredTrack.isGroup) return null + if ( + !hoveredTrack || + hoveredTrack.isGroup || + isTrackEffectivelyLocked(workingTracks, hoveredTrack.id) + ) { + return null + } let hoveredKind = getTrackKind(hoveredTrack) if (hoveredKind === null) { - if (hoveredTrack.locked) return null const upgradedTrack = renameTrackForKind(hoveredTrack, workingTracks, params.zoneKind) workingTracks = workingTracks.map((track) => track.id === hoveredTrack!.id ? upgradedTrack : track, @@ -324,9 +347,8 @@ function assignCohortTrackTargets(params: { }) workingTracks = ensuredTrack.tracks - const targetTrack = workingTracks.find((track) => track.id === ensuredTrack.trackId) - if (!targetTrack || targetTrack.locked) return null - targetTrackIdBySource.set(sourcePlan.key, targetTrack.id) + if (isTrackEffectivelyLocked(workingTracks, ensuredTrack.trackId)) return null + targetTrackIdBySource.set(sourcePlan.key, ensuredTrack.trackId) } const trackAssignments = new Map() @@ -433,6 +455,11 @@ export function resolveCreateNewDragTrackTargets(params: { if (draggedItems.length === 0) { return null } + if ( + draggedItems.some((draggedItem) => isTrackEffectivelyLocked(tracks, draggedItem.initialTrackId)) + ) { + return null + } const selectionKinds = Array.from( new Set(draggedItems.map((item) => getDraggedItemTrackKind(item.type))), @@ -592,7 +619,11 @@ export function resolveLinkedDragTrackTargets(params: { }): LinkedDragTrackTargetResult | null { const { tracks, hoveredTrackId, zone, createNew = false, preferredTrackHeight } = params const hoveredTrack = tracks.find((track) => track.id === hoveredTrackId) - if (!hoveredTrack) { + if ( + !hoveredTrack || + hoveredTrack.isGroup || + (!createNew && isTrackEffectivelyLocked(tracks, hoveredTrackId)) + ) { return null } @@ -626,7 +657,7 @@ export function resolveLinkedDragTrackTargets(params: { let sectionIndex: number const hoveredTrackNumber = hoveredKind ? getClassicTrackNumber(hoveredTrack, hoveredKind) : null - if (!hoveredTrack.locked && (hoveredKind === zoneKind || hoveredKind === null)) { + if (hoveredKind === zoneKind || hoveredKind === null) { const upgradedTrack = renameTrackForKind(hoveredTrack, workingTracks, zoneKind) if (upgradedTrack !== hoveredTrack) { workingTracks = workingTracks.map((track) => @@ -681,6 +712,13 @@ export function resolveLinkedDragTrackTargets(params: { }) workingTracks = ensuredCompanionTrack.tracks + if ( + isTrackEffectivelyLocked(workingTracks, zoneTrackId) || + isTrackEffectivelyLocked(workingTracks, ensuredCompanionTrack.trackId) + ) { + return null + } + if (zone === 'video') { return { tracks: workingTracks, From f85fd5ab730e76403e52590b3ed0e7293d91ed80 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 20:19:45 -0700 Subject: [PATCH 32/64] fix(timeline): harden linked drag rejection (cherry picked from commit a4ba3dab3b838ebb8de7ab985f7c20166c85d26b) --- .../timeline/hooks/use-timeline-drag.test.tsx | 367 +++++++++++++++++- .../timeline/hooks/use-timeline-drag.ts | 152 +++++--- .../timeline/utils/collision-utils.test.ts | 64 +++ src/features/timeline/utils/group-utils.ts | 83 ++++ .../utils/linked-drag-targeting.test.ts | 30 +- 5 files changed, 620 insertions(+), 76 deletions(-) create mode 100644 src/features/timeline/utils/collision-utils.test.ts diff --git a/src/features/timeline/hooks/use-timeline-drag.test.tsx b/src/features/timeline/hooks/use-timeline-drag.test.tsx index 7977593cb..cfb24c519 100644 --- a/src/features/timeline/hooks/use-timeline-drag.test.tsx +++ b/src/features/timeline/hooks/use-timeline-drag.test.tsx @@ -89,6 +89,17 @@ function beginDrag( result: { current: ReturnType }, startX: number, startY: number, +) { + startDragAttempt(result, startX, startY) + act(() => { + window.dispatchEvent(new MouseEvent('mousemove', { clientX: startX + 4, clientY: startY })) + }) +} + +function startDragAttempt( + result: { current: ReturnType }, + startX: number, + startY: number, ) { const target = document.createElement('div') const event = { @@ -103,9 +114,6 @@ function beginDrag( act(() => { result.current.handleDragStart(event) }) - act(() => { - window.dispatchEvent(new MouseEvent('mousemove', { clientX: startX + 4, clientY: startY })) - }) } function moveDrag(clientX: number, clientY: number) { @@ -127,6 +135,39 @@ function getItem(id: string): TimelineItem { return item as TimelineItem } +function captureSelectionMetadata() { + const state = useSelectionStore.getState() + return { + selectedItemIds: [...state.selectedItemIds], + selectedItemIdSet: new Set(state.selectedItemIdSet), + selectedMarkerId: state.selectedMarkerId, + selectedTransitionId: state.selectedTransitionId, + selectedTrackId: state.selectedTrackId, + selectedTrackIds: [...state.selectedTrackIds], + activeTrackId: state.activeTrackId, + selectionType: state.selectionType, + activeTool: state.activeTool, + activeSnapTarget: state.activeSnapTarget, + activeLinkedDropTarget: state.activeLinkedDropTarget, + dragState: state.dragState, + editKeyframePanelOpen: state.editKeyframePanelOpen, + expandedKeyframeLanes: new Set(state.expandedKeyframeLanes), + } +} + +function captureTimelineMutationState() { + const commandState = useTimelineCommandStore.getState() + return { + items: structuredClone(useItemsStore.getState().items), + tracks: structuredClone(useItemsStore.getState().tracks), + isDirty: useTimelineSettingsStore.getState().isDirty, + undoStack: structuredClone(commandState.undoStack), + redoStack: structuredClone(commandState.redoStack), + canUndo: commandState.canUndo, + canRedo: commandState.canRedo, + } +} + function makeThreeSectionTracks(): TimelineTrack[] { return [ makeTimelineTrack({ id: 'v3', name: 'V3', kind: 'video', order: 0 }), @@ -415,23 +456,30 @@ describe('useTimelineDrag linked cohorts', () => { expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) }) - it('rejects an explicit source member on a child lane of a locked group', () => { + it('rejects an explicit source below an unlocked inner group and locked outer group', () => { const tracks = [ makeTimelineTrack({ - id: 'locked-group', - name: 'Locked Group', + id: 'outer-locked-group', + name: 'Outer Locked Group', order: 0, isGroup: true, locked: true, }), + makeTimelineTrack({ + id: 'inner-unlocked-group', + name: 'Inner Unlocked Group', + order: 1, + isGroup: true, + parentTrackId: 'outer-locked-group', + }), makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', - order: 1, - parentTrackId: 'locked-group', + order: 2, + parentTrackId: 'inner-unlocked-group', }), - makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 2 }), + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 3 }), ] const video = makeTimelineVideoItem({ id: 'video-1', trackId: 'v1' }) setupStores(tracks, [video]) @@ -451,25 +499,32 @@ describe('useTimelineDrag linked cohorts', () => { expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) }) - it('rejects an implicit linked companion inherited-locked by its parent group atomically', () => { + it('rejects an implicit companion below an unlocked inner group and locked outer group', () => { const tracks = [ makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), makeTimelineTrack({ - id: 'locked-group', - name: 'Locked Group', + id: 'outer-locked-group', + name: 'Outer Locked Group', order: 2, isGroup: true, locked: true, }), + makeTimelineTrack({ + id: 'inner-unlocked-group', + name: 'Inner Unlocked Group', + order: 3, + isGroup: true, + parentTrackId: 'outer-locked-group', + }), makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', - order: 3, - parentTrackId: 'locked-group', + order: 4, + parentTrackId: 'inner-unlocked-group', }), - makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 4 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 5 }), ] const video = makeTimelineVideoItem({ id: 'video-1', @@ -499,24 +554,31 @@ describe('useTimelineDrag linked cohorts', () => { expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) }) - it('rejects the whole cohort when an implicit destination lane inherits a group lock', () => { + it('rejects a destination below an unlocked inner group and locked outer group', () => { const tracks = [ makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), makeTimelineTrack({ - id: 'locked-group', - name: 'Locked Group', + id: 'outer-locked-group', + name: 'Outer Locked Group', order: 3, isGroup: true, locked: true, }), + makeTimelineTrack({ + id: 'inner-unlocked-group', + name: 'Inner Unlocked Group', + order: 4, + isGroup: true, + parentTrackId: 'outer-locked-group', + }), makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', - order: 4, - parentTrackId: 'locked-group', + order: 5, + parentTrackId: 'inner-unlocked-group', }), ] const video = makeTimelineVideoItem({ @@ -546,6 +608,271 @@ describe('useTimelineDrag linked cohorts', () => { expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) }) + it('restores exact prior selection metadata when an unselected cohort targets a locked lane', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0, locked: true }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + const priorSelection = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, priorSelection]) + useSelectionStore.getState().selectItems(['prior-selection']) + useSelectionStore.getState().setEditKeyframePanelOpen(true) + const beforeSelection = captureSelectionMetadata() + const beforeTimeline = captureTimelineMutationState() + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set(['video-1', 'audio-1']), + ) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureTimelineMutationState()).toEqual(beforeTimeline) + }) + + it('rolls back selection and leaves timeline state untouched when released before threshold', () => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + const priorSelection = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, priorSelection]) + useSelectionStore.getState().selectItems(['prior-selection']) + useSelectionStore.getState().setEditKeyframePanelOpen(true) + const beforeSelection = captureSelectionMetadata() + const beforeTimeline = captureTimelineMutationState() + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + startDragAttempt(result, 0, yByTrackId.get('v1')!) + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set(['video-1', 'audio-1']), + ) + releaseDrag() + + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureTimelineMutationState()).toEqual(beforeTimeline) + }) + + it('rejects atomically when a source becomes effectively locked before drop', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ + id: 'outer-group', + name: 'Outer Group', + order: 1, + isGroup: true, + }), + makeTimelineTrack({ + id: 'inner-group', + name: 'Inner Group', + order: 2, + isGroup: true, + parentTrackId: 'outer-group', + }), + makeTimelineTrack({ + id: 'v1', + name: 'V1', + kind: 'video', + order: 3, + parentTrackId: 'inner-group', + }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 4 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 5 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + setupStores(tracks, [video, audio]) + const beforeSelection = captureSelectionMetadata() + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + act(() => { + useItemsStore + .getState() + .setTracks( + tracks.map((track) => (track.id === 'outer-group' ? { ...track, locked: true } : track)), + ) + }) + const beforeDrop = captureTimelineMutationState() + releaseDrag() + + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureTimelineMutationState()).toEqual(beforeDrop) + }) + + it('rejects atomically when a destination becomes effectively locked before drop', () => { + const tracks = [ + makeTimelineTrack({ + id: 'outer-group', + name: 'Outer Group', + order: 0, + isGroup: true, + }), + makeTimelineTrack({ + id: 'inner-group', + name: 'Inner Group', + order: 1, + isGroup: true, + parentTrackId: 'outer-group', + }), + makeTimelineTrack({ + id: 'v2', + name: 'V2', + kind: 'video', + order: 2, + parentTrackId: 'inner-group', + }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 3 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 4 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 5 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + setupStores(tracks, [video, audio]) + const beforeSelection = captureSelectionMetadata() + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + act(() => { + useItemsStore + .getState() + .setTracks( + tracks.map((track) => (track.id === 'outer-group' ? { ...track, locked: true } : track)), + ) + }) + const beforeDrop = captureTimelineMutationState() + releaseDrag() + + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureTimelineMutationState()).toEqual(beforeDrop) + }) + + it.each(['appears', 'moves'] as const)( + 'uses live blocker state when a blocker %s between drag start and drop', + (blockerChange) => { + const tracks = makeThreeSectionTracks() + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const blocker = makeTimelineVideoItem({ + id: 'live-blocker', + trackId: 'v1', + from: blockerChange === 'moves' ? 100 : 8, + durationInFrames: 10, + mediaId: 'blocker-media', + }) + setupStores(tracks, blockerChange === 'moves' ? [video, audio, blocker] : [video, audio]) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(10, yByTrackId.get('v1')!) + act(() => { + if (blockerChange === 'appears') { + useItemsStore.getState().setItems([...useItemsStore.getState().items, blocker]) + } else { + useItemsStore + .getState() + .setItems( + useItemsStore + .getState() + .items.map((currentItem) => + currentItem.id === blocker.id ? { ...currentItem, from: 8 } : currentItem, + ), + ) + } + }) + releaseDrag() + + expect(getItem('video-1')).toMatchObject({ trackId: 'v1', from: 18 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a1', from: 18 }) + expect(getItem('live-blocker')).toMatchObject({ trackId: 'v1', from: 8 }) + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set(['video-1', 'audio-1']), + ) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + + act(() => { + useTimelineCommandStore.getState().undo() + }) + + expect(getItem('video-1')).toMatchObject({ trackId: 'v1', from: 0 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a1', from: 0 }) + expect(getItem('live-blocker')).toMatchObject({ trackId: 'v1', from: 8 }) + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set(['video-1', 'audio-1']), + ) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + }, + ) + it('keeps unlinked multi-select lock filtering behavior unchanged', () => { const tracks = [ makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0, locked: true }), diff --git a/src/features/timeline/hooks/use-timeline-drag.ts b/src/features/timeline/hooks/use-timeline-drag.ts index 1c507077e..325e59e67 100644 --- a/src/features/timeline/hooks/use-timeline-drag.ts +++ b/src/features/timeline/hooks/use-timeline-drag.ts @@ -4,7 +4,7 @@ import type { TimelineItem, TimelineTrack } from '@/types/timeline' import type { DragState, UseTimelineDragReturn, SnapTarget } from '../types/drag' import { useTimelineStore } from '../stores/timeline-store' import { useEditorStore } from '@/shared/state/editor' -import { useSelectionStore } from '@/shared/state/selection' +import { useSelectionStore, type SelectionState } from '@/shared/state/selection' import { pixelsToFramePreciseNow, frameToPixelsNow, @@ -459,6 +459,27 @@ interface DraggedItemState { initialTrackId: string } +type DragSelectionSnapshot = Pick< + SelectionState, + | 'selectedItemIds' + | 'selectedItemIdSet' + | 'selectedMarkerId' + | 'selectedTransitionId' + | 'selectionType' + | 'expandedKeyframeLanes' +> + +function captureDragSelectionSnapshot(state: SelectionState): DragSelectionSnapshot { + return { + selectedItemIds: [...state.selectedItemIds], + selectedItemIdSet: new Set(state.selectedItemIdSet), + selectedMarkerId: state.selectedMarkerId, + selectedTransitionId: state.selectedTransitionId, + selectionType: state.selectionType, + expandedKeyframeLanes: new Set(state.expandedKeyframeLanes), + } +} + function getEffectiveTrackStateById(tracks: TimelineTrack[]): ReadonlyMap { return new Map(resolveEffectiveTrackStates(tracks).map((track) => [track.id, track])) } @@ -568,12 +589,15 @@ export function useTimelineDrag( const isLinkedCohortDragRef = useRef(false) const dragVisualTopByTrackIdRef = useRef>(new Map()) const linkedMovePreviewSignatureRef = useRef('') + const selectionRollbackRef = useRef(null) + const removeDragThresholdListenersRef = useRef<(() => void) | null>(null) // Track Alt key state for duplication mode (dynamic toggle during drag) const isAltDragRef = useRef(false) // Track previous snap target to avoid unnecessary store updates const prevSnapTargetRef = useRef<{ frame: number; type: string } | null>(null) + const magneticSnapTargetsRef = useRef([]) // Get store actions with granular selectors const moveItem = useTimelineStore((s) => s.moveItem) @@ -625,6 +649,51 @@ export function useTimelineDrag( [], ) + const finishDragInteraction = useCallback( + ({ + rollbackSelection, + updateReactState = true, + }: { + rollbackSelection: boolean + updateReactState?: boolean + }) => { + const removeDragThresholdListeners = removeDragThresholdListenersRef.current + removeDragThresholdListenersRef.current = null + removeDragThresholdListeners?.() + + if (elementRef?.current) { + elementRef.current.style.transform = '' + } + dragOffsetRef.current = { x: 0, y: 0 } + dragVisualTopByTrackIdRef.current.clear() + dragPreviewOffsetByItemRef.current = {} + clearLargeAltDragCanvas() + clearLinkedMovePreview() + prevSnapTargetRef.current = null + magneticSnapTargetsRef.current = [] + dragStateRef.current = null + isLinkedCohortDragRef.current = false + isAltDragRef.current = false + clearGlobalDragCursor() + document.body.style.userSelect = '' + + const selectionSnapshot = selectionRollbackRef.current + selectionRollbackRef.current = null + useSelectionStore.setState({ + ...(rollbackSelection && selectionSnapshot ? selectionSnapshot : {}), + dragState: null, + activeSnapTarget: null, + activeLinkedDropTarget: null, + }) + + if (updateReactState) { + setIsDragging(false) + setDragOffset({ x: 0, y: 0 }) + } + }, + [clearLinkedMovePreview, elementRef], + ) + // Get zoom utilities // Zoom conversions are read imperatively (via store.getState()) at call-time // to avoid subscribing every TimelineItem to the live zoom store. @@ -652,7 +721,6 @@ export function useTimelineDrag( // Helper to get items on-demand (avoids subscription that would cause all items to re-render) const getItems = useCallback(() => useTimelineStore.getState().items, []) // Update refs synchronously (not in useEffect) so they're always current - const magneticSnapTargetsRef = useRef([]) const getSnapThresholdFramesRef = useRef(getSnapThresholdFrames) getSnapThresholdFramesRef.current = getSnapThresholdFrames @@ -862,6 +930,8 @@ export function useTimelineDrag( */ const handleDragStart = useCallback( (e: React.MouseEvent) => { + if (dragStateRef.current) return + const allItems = getItems() const currentTracks = useTimelineStore.getState().tracks const anchorTrack = getEffectiveTrackStateById(currentTracks).get(item.trackId) @@ -879,7 +949,8 @@ export function useTimelineDrag( e.stopPropagation() // Check if this item is in current selection - const currentSelectedIds = useSelectionStore.getState().selectedItemIds + const currentSelectionState = useSelectionStore.getState() + const currentSelectedIds = currentSelectionState.selectedItemIds const isInSelection = currentSelectedIds.includes(item.id) const linkedSelectionEnabled = useEditorStore.getState().linkedSelectionEnabled @@ -905,6 +976,7 @@ export function useTimelineDrag( // validation. A rejected linked gesture is otherwise not atomic. const isMultiSelectClick = e.ctrlKey || e.metaKey if (!isInSelection && !isMultiSelectClick) { + selectionRollbackRef.current = captureDragSelectionSnapshot(currentSelectionState) selectItems(linkedIds) } @@ -916,6 +988,7 @@ export function useTimelineDrag( baseItemsToDrag.length === selectedIdSet.size && baseItemsToDrag.every((id) => selectedIdSet.has(id)) if (isInSelection && !cohortMatchesSelection) { + selectionRollbackRef.current = captureDragSelectionSnapshot(currentSelectionState) selectItems(baseItemsToDrag) } @@ -970,29 +1043,31 @@ export function useTimelineDrag( setActiveLinkedDropTarget(null) clearLinkedMovePreview() - // Remove this listener - the main useEffect will handle it now - window.removeEventListener('mousemove', checkDragThreshold) - window.removeEventListener('mouseup', cancelDrag) + // Remove these listeners - the main useEffect will handle it now. + removeDragThresholdListeners() } } const cancelDrag = () => { - // Clean up if mouse released before threshold - dragStateRef.current = null - magneticSnapTargetsRef.current = [] - dragVisualTopByTrackIdRef.current.clear() - dragPreviewOffsetByItemRef.current = {} - clearLargeAltDragCanvas() - clearLinkedMovePreview() + // A click released before the threshold is a cancelled drag attempt. + finishDragInteraction({ rollbackSelection: true }) + } + + function removeDragThresholdListeners() { window.removeEventListener('mousemove', checkDragThreshold) window.removeEventListener('mouseup', cancelDrag) + if (removeDragThresholdListenersRef.current === removeDragThresholdListeners) { + removeDragThresholdListenersRef.current = null + } } + removeDragThresholdListenersRef.current = removeDragThresholdListeners window.addEventListener('mousemove', checkDragThreshold) window.addEventListener('mouseup', cancelDrag) }, [ clearLinkedMovePreview, + finishDragInteraction, item.id, item.from, item.trackId, @@ -1335,6 +1410,7 @@ export function useTimelineDrag( const dragState = dragStateRef.current const deltaX = dragState.currentMouseX - dragState.startMouseX const isAltDrag = isAltDragRef.current + let dropAccepted = false // Calculate frame delta const deltaFrames = pixelsToFramePreciseRef.current(deltaX) @@ -1515,6 +1591,7 @@ export function useTimelineDrag( } else { duplicateItemsRef.current(itemIds, positions) } + dropAccepted = true } else { // Normal drag: Apply the snap to ALL items in the group const allUpdates = movedItems.map((m) => ({ @@ -1531,6 +1608,7 @@ export function useTimelineDrag( } else { moveItemsRef.current(allUpdates) } + dropAccepted = true } } else { // Single item drag @@ -1573,6 +1651,7 @@ export function useTimelineDrag( [{ from: roundedFinalFrame, trackId: newTrackId }], ) } + dropAccepted = true } else { // Normal drag: Move item const trackChanged = newTrackId !== dragState.startTrackId @@ -1583,6 +1662,7 @@ export function useTimelineDrag( } else { moveItemRef.current(item.id, roundedFinalFrame, trackChanged ? newTrackId : undefined) } + dropAccepted = true } } else { // No space available - cancel drag (keep at original position) @@ -1596,35 +1676,7 @@ export function useTimelineDrag( } } - // Clean up - defer drag state clearing to avoid multiple render cycles - // The move operation already triggered a re-render; clearing drag state - // should happen after that render completes - if (elementRef?.current) { - elementRef.current.style.transform = '' - } - dragOffsetRef.current = { x: 0, y: 0 } // Reset shared ref immediately - dragVisualTopByTrackIdRef.current.clear() - dragPreviewOffsetByItemRef.current = {} - clearLargeAltDragCanvas() - clearLinkedMovePreview() - prevSnapTargetRef.current = null // Reset snap target tracking - magneticSnapTargetsRef.current = [] - dragStateRef.current = null - isAltDragRef.current = false // Reset alt drag state - clearGlobalDragCursor() - document.body.style.userSelect = '' - - // Batch React state updates (React 18 batches these automatically) - setIsDragging(false) - setDragOffset({ x: 0, y: 0 }) - - // Defer selection store cleanup to next microtask to avoid - // synchronous re-render cascade after move operation - queueMicrotask(() => { - setActiveSnapTarget(null) - setActiveLinkedDropTarget(null) - setDragState(null) - }) + finishDragInteraction({ rollbackSelection: !dropAccepted }) } if (dragStateRef.current) { @@ -1641,12 +1693,6 @@ export function useTimelineDrag( window.removeEventListener('mousemove', coalescedMouseMove.queue) window.removeEventListener('mouseup', handleCoalescedMouseUp) coalescedMouseMove.cancel() - magneticSnapTargetsRef.current = [] - dragVisualTopByTrackIdRef.current.clear() - clearLargeAltDragCanvas() - clearLinkedMovePreview() - clearGlobalDragCursor() - document.body.style.userSelect = '' } } }, [ @@ -1659,6 +1705,7 @@ export function useTimelineDrag( calculateMagneticSnap, getMagneticSnapTargets, clearLinkedMovePreview, + finishDragInteraction, elementRef, getItems, setActiveLinkedDropTarget, @@ -1667,6 +1714,15 @@ export function useTimelineDrag( setLinkedMovePreview, ]) + useEffect( + () => () => { + if (dragStateRef.current || selectionRollbackRef.current) { + finishDragInteraction({ rollbackSelection: true, updateReactState: false }) + } + }, + [finishDragInteraction], + ) + return { isDragging, dragOffset, diff --git a/src/features/timeline/utils/collision-utils.test.ts b/src/features/timeline/utils/collision-utils.test.ts new file mode 100644 index 000000000..059ea5bdc --- /dev/null +++ b/src/features/timeline/utils/collision-utils.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vite-plus/test' +import { findNearestAvailableSharedOffset } from './collision-utils' + +describe('findNearestAvailableSharedOffset', () => { + it('returns one nearest correction that is valid across conflicting lanes', () => { + const offset = findNearestAvailableSharedOffset( + [ + { trackId: 'v2', from: 20, durationInFrames: 10 }, + { trackId: 'v3', from: 50, durationInFrames: 10 }, + ], + [ + { trackId: 'v2', from: 18, durationInFrames: 8 }, + { trackId: 'v3', from: 60, durationInFrames: 10 }, + ], + ) + + expect(offset).toBe(-12) + }) + + it('breaks equidistant ties deterministically toward the earlier offset', () => { + expect( + findNearestAvailableSharedOffset( + [{ trackId: 'v1', from: 20, durationInFrames: 10 }], + [{ trackId: 'v1', from: 15, durationInFrames: 20 }], + ), + ).toBe(-15) + }) + + it('honors the frame-zero lower bound when the backward edge is unreachable', () => { + expect( + findNearestAvailableSharedOffset( + [{ trackId: 'v1', from: 2, durationInFrames: 10 }], + [{ trackId: 'v1', from: 0, durationInFrames: 8 }], + ), + ).toBe(6) + }) + + it('accepts touching edges and an empty cohort without adding an offset', () => { + expect( + findNearestAvailableSharedOffset( + [{ trackId: 'v1', from: 10, durationInFrames: 10 }], + [ + { trackId: 'v1', from: 0, durationInFrames: 10 }, + { trackId: 'v1', from: 20, durationInFrames: 10 }, + ], + ), + ).toBe(0) + expect(findNearestAvailableSharedOffset([], [])).toBe(0) + }) + + it('rejects non-finite positions and negative durations', () => { + expect( + findNearestAvailableSharedOffset( + [{ trackId: 'v1', from: Number.NaN, durationInFrames: 10 }], + [], + ), + ).toBeNull() + expect( + findNearestAvailableSharedOffset([{ trackId: 'v1', from: 0, durationInFrames: -1 }], []), + ).toBeNull() + }) +}) diff --git a/src/features/timeline/utils/group-utils.ts b/src/features/timeline/utils/group-utils.ts index 378d874ff..ffcdcaeb1 100644 --- a/src/features/timeline/utils/group-utils.ts +++ b/src/features/timeline/utils/group-utils.ts @@ -1,5 +1,88 @@ import type { TimelineItem, TimelineTrack } from '@/types/timeline' +type EffectiveTrackState = Pick + +const ROOT_EFFECTIVE_TRACK_STATE: EffectiveTrackState = { + locked: false, + muted: false, + visible: true, + solo: false, +} +const INVALID_PARENT_EFFECTIVE_TRACK_STATE: EffectiveTrackState = { + ...ROOT_EFFECTIVE_TRACK_STATE, + // A malformed ancestry chain must not make an otherwise inherited lock + // disappear. Other properties retain their canonical neutral defaults. + locked: true, +} + +function inheritTrackState( + track: TimelineTrack, + parentState: EffectiveTrackState, +): EffectiveTrackState { + return { + locked: track.locked || parentState.locked, + muted: track.muted || parentState.muted, + visible: track.visible !== false && parentState.visible, + solo: track.solo || parentState.solo, + } +} + +interface GroupAncestryTrace { + path: TimelineTrack[] + parentState: EffectiveTrackState + cycleStartIndex: number | null +} + +function traceGroupAncestry( + groupId: string, + groupsById: ReadonlyMap, + effectiveGroupStateById: ReadonlyMap, +): GroupAncestryTrace { + const path: TimelineTrack[] = [] + const pathIndexById = new Map() + let currentId = groupId + + while (true) { + const knownState = effectiveGroupStateById.get(currentId) + if (knownState) return { path, parentState: knownState, cycleStartIndex: null } + + const cycleStartIndex = pathIndexById.get(currentId) + if (cycleStartIndex !== undefined) { + return { + path, + parentState: INVALID_PARENT_EFFECTIVE_TRACK_STATE, + cycleStartIndex, + } + } + + const currentGroup = groupsById.get(currentId) + if (!currentGroup) { + return { + path, + parentState: INVALID_PARENT_EFFECTIVE_TRACK_STATE, + cycleStartIndex: null, + } + } + + pathIndexById.set(currentId, path.length) + path.push(currentGroup) + if (!currentGroup.parentTrackId) { + return { path, parentState: ROOT_EFFECTIVE_TRACK_STATE, cycleStartIndex: null } + } + currentId = currentGroup.parentTrackId + } +} + +function foldTrackStates( + tracks: readonly TimelineTrack[], + parentState: EffectiveTrackState, +): EffectiveTrackState { + return tracks.reduceRight( + (effectiveState, track) => inheritTrackState(track, effectiveState), + parentState, + ) +} + /** * Build a set of track IDs whose items should contribute snap targets. */ diff --git a/src/features/timeline/utils/linked-drag-targeting.test.ts b/src/features/timeline/utils/linked-drag-targeting.test.ts index abdec7d3d..4489fe32b 100644 --- a/src/features/timeline/utils/linked-drag-targeting.test.ts +++ b/src/features/timeline/utils/linked-drag-targeting.test.ts @@ -357,19 +357,26 @@ describe('resolveLinkedCohortDragTrackTargets', () => { expect(result).toBeNull() }) - it('rejects a source lane that inherits a lock from its parent group', () => { + it('rejects a source lane that inherits a lock through nested groups', () => { const result = resolveLinkedCohortDragTrackTargets({ tracks: [ ...sectionTracks.map((track) => - track.id === 'a1' ? { ...track, parentTrackId: 'locked-group' } : track, + track.id === 'a1' ? { ...track, parentTrackId: 'inner-group' } : track, ), makeTrack({ - id: 'locked-group', - name: 'Locked Group', + id: 'outer-group', + name: 'Outer Group', order: 6, isGroup: true, locked: true, }), + makeTrack({ + id: 'inner-group', + name: 'Inner Group', + order: 7, + isGroup: true, + parentTrackId: 'outer-group', + }), ], draggedItems: [ { id: 'video-1', initialTrackId: 'v1', type: 'video' }, @@ -385,19 +392,26 @@ describe('resolveLinkedCohortDragTrackTargets', () => { expect(result).toBeNull() }) - it('rejects an implicit destination lane that inherits a parent group lock', () => { + it('rejects an implicit destination lane that inherits a lock through nested groups', () => { const result = resolveLinkedCohortDragTrackTargets({ tracks: [ ...sectionTracks.map((track) => - track.id === 'a2' ? { ...track, parentTrackId: 'locked-group' } : track, + track.id === 'a2' ? { ...track, parentTrackId: 'inner-group' } : track, ), makeTrack({ - id: 'locked-group', - name: 'Locked Group', + id: 'outer-group', + name: 'Outer Group', order: 6, isGroup: true, locked: true, }), + makeTrack({ + id: 'inner-group', + name: 'Inner Group', + order: 7, + isGroup: true, + parentTrackId: 'outer-group', + }), ], draggedItems: [ { id: 'video-1', initialTrackId: 'v1', type: 'video' }, From 767afd047aabc5b69a8edda2efe5ed64afb40764 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:37:22 -0700 Subject: [PATCH 33/64] fix(timeline): own post-drag release clicks (cherry picked from commit 6eed1fef4d768b22643e8df3d1f3078456975c92) --- .../timeline/components/timeline-content.tsx | 2 +- .../components/timeline-item/index.tsx | 2 - .../post-drag-click-guard.test.ts | 58 +- .../timeline-item/post-drag-click-guard.ts | 47 +- ...se-timeline-item-pointer-handlers.test.tsx | 1 - .../use-timeline-item-pointer-handlers.ts | 8 - .../hooks/use-timeline-drag.dom.test.tsx | 711 ++++++++++++++++++ .../timeline/hooks/use-timeline-drag.ts | 125 ++- 8 files changed, 906 insertions(+), 48 deletions(-) create mode 100644 src/features/timeline/hooks/use-timeline-drag.dom.test.tsx diff --git a/src/features/timeline/components/timeline-content.tsx b/src/features/timeline/components/timeline-content.tsx index aa8ecdfba..3fba95516 100644 --- a/src/features/timeline/components/timeline-content.tsx +++ b/src/features/timeline/components/timeline-content.tsx @@ -1384,7 +1384,7 @@ export const TimelineContent = memo(function TimelineContent({ const handleContainerClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement const interactionJustFinished = - marqueeWasActiveRef.current || dragWasActiveRef.current || scrubWasActiveRef.current + marqueeWasActiveRef.current || scrubWasActiveRef.current // Radix menus render outside the timeline DOM, but their synthetic events // still bubble through this component tree. if (shouldIgnoreTimelineContainerClick(target, interactionJustFinished)) { diff --git a/src/features/timeline/components/timeline-item/index.tsx b/src/features/timeline/components/timeline-item/index.tsx index 9d883da6b..b8ad43ce2 100644 --- a/src/features/timeline/components/timeline-item/index.tsx +++ b/src/features/timeline/components/timeline-item/index.tsx @@ -342,7 +342,6 @@ export const TimelineItem = memo(function TimelineItem({ const { dragAffectsJoin, isAnyDragActiveRef, - dragWasActiveRef, isAltDrag, isPartOfDrag, isBeingDragged, @@ -602,7 +601,6 @@ export const TimelineItem = memo(function TimelineItem({ activeToolRef, smartTrimIntentRef, smartBodyIntent, - dragWasActiveRef, isTrimming, isStretching, isSlipSlideActive, diff --git a/src/features/timeline/components/timeline-item/post-drag-click-guard.test.ts b/src/features/timeline/components/timeline-item/post-drag-click-guard.test.ts index d4f805a3c..e2f089c5b 100644 --- a/src/features/timeline/components/timeline-item/post-drag-click-guard.test.ts +++ b/src/features/timeline/components/timeline-item/post-drag-click-guard.test.ts @@ -1,23 +1,51 @@ -// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +import { + resetPostTimelineGestureClickForTest, + suppressPostTimelineGestureClick, +} from './post-drag-click-guard' -import { describe, expect, it } from 'vite-plus/test' -import { shouldSuppressTimelineItemClickAfterDrag } from './post-drag-click-guard' +function dispatchMouseEvent(target: EventTarget, type: 'mousedown' | 'click', detail = 1) { + target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, detail })) +} -describe('shouldSuppressTimelineItemClickAfterDrag', () => { - it('suppresses post-drag clicks for selection tools', () => { - expect(shouldSuppressTimelineItemClickAfterDrag('select', true)).toBe(true) - expect(shouldSuppressTimelineItemClickAfterDrag('trim-edit', true)).toBe(true) +describe('post timeline gesture click ownership', () => { + afterEach(() => resetPostTimelineGestureClickForTest()) + + it('suppresses exactly one browser-generated click', () => { + const element = document.createElement('button') + const onClick = vi.fn() + element.addEventListener('click', onClick) + document.body.appendChild(element) + + suppressPostTimelineGestureClick() + dispatchMouseEvent(element, 'click') + dispatchMouseEvent(element, 'click') + + expect(onClick).toHaveBeenCalledTimes(1) }) - it('allows post-drag clicks for non-selection tools so razor and edit tools still work', () => { - expect(shouldSuppressTimelineItemClickAfterDrag('razor', true)).toBe(false) - expect(shouldSuppressTimelineItemClickAfterDrag('rate-stretch', true)).toBe(false) - expect(shouldSuppressTimelineItemClickAfterDrag('slip', true)).toBe(false) - expect(shouldSuppressTimelineItemClickAfterDrag('slide', true)).toBe(false) + it('releases ownership when a later independent mouse gesture starts', () => { + const element = document.createElement('button') + const onClick = vi.fn() + element.addEventListener('click', onClick) + document.body.appendChild(element) + + suppressPostTimelineGestureClick() + dispatchMouseEvent(element, 'mousedown') + dispatchMouseEvent(element, 'click') + + expect(onClick).toHaveBeenCalledTimes(1) }) - it('never suppresses when no drag just finished', () => { - expect(shouldSuppressTimelineItemClickAfterDrag('select', false)).toBe(false) - expect(shouldSuppressTimelineItemClickAfterDrag('razor', false)).toBe(false) + it('does not suppress keyboard or programmatic activation', () => { + const element = document.createElement('button') + const onClick = vi.fn() + element.addEventListener('click', onClick) + document.body.appendChild(element) + + suppressPostTimelineGestureClick() + dispatchMouseEvent(element, 'click', 0) + + expect(onClick).toHaveBeenCalledTimes(1) }) }) diff --git a/src/features/timeline/components/timeline-item/post-drag-click-guard.ts b/src/features/timeline/components/timeline-item/post-drag-click-guard.ts index c6e9549c9..afc7d7edf 100644 --- a/src/features/timeline/components/timeline-item/post-drag-click-guard.ts +++ b/src/features/timeline/components/timeline-item/post-drag-click-guard.ts @@ -1,12 +1,41 @@ -import type { SelectionState } from '@/shared/state/selection/types' - -export function shouldSuppressTimelineItemClickAfterDrag( - activeTool: SelectionState['activeTool'], - dragWasActive: boolean, -): boolean { - if (!dragWasActive) { - return false +let removePendingClickOwnership: (() => void) | null = null + +function clearPendingClickOwnership() { + removePendingClickOwnership?.() + removePendingClickOwnership = null +} + +/** + * Own the browser-generated click that immediately follows a completed mouse + * gesture. A later independent click always starts with another mousedown, + * which clears the ownership before that click can be dispatched. + */ +export function suppressPostTimelineGestureClick(): void { + clearPendingClickOwnership() + if (typeof document === 'undefined') return + + const handleIndependentMouseDown = () => { + clearPendingClickOwnership() } + const handleClick = (event: MouseEvent) => { + // Keyboard activation and HTMLElement.click() do not belong to the mouse + // gesture and must remain available. + if (event.detail === 0) return + + clearPendingClickOwnership() + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + } + + removePendingClickOwnership = () => { + document.removeEventListener('mousedown', handleIndependentMouseDown, true) + document.removeEventListener('click', handleClick, true) + } + document.addEventListener('mousedown', handleIndependentMouseDown, true) + document.addEventListener('click', handleClick, true) +} - return activeTool === 'select' || activeTool === 'trim-edit' +export function resetPostTimelineGestureClickForTest(): void { + clearPendingClickOwnership() } diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx index 98ee53983..2f93ce51a 100644 --- a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx +++ b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx @@ -91,7 +91,6 @@ function makeInput( activeToolRef: { current: activeTool }, smartTrimIntentRef: { current: null }, smartBodyIntent: null, - dragWasActiveRef: { current: false }, isTrimming: false, isStretching: false, isSlipSlideActive: false, diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts index 1621f61a8..41d8530a0 100644 --- a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts +++ b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts @@ -32,14 +32,12 @@ import { } from '../../utils/smart-trim-zones' import { isRateStretchableItem } from '../../hooks/use-rate-stretch' import { getTimelineClipLabelRowHeightPx } from './hover-layout' -import { shouldSuppressTimelineItemClickAfterDrag } from './post-drag-click-guard' import { emitUiSound } from '@/shared/ui/ui-sound' import type { useTimelineDrag } from '../../hooks/use-timeline-drag' import type { useTimelineTrim } from '../../hooks/use-timeline-trim' import type { useRateStretch } from '../../hooks/use-rate-stretch' import type { useTimelineSlipSlide } from '../../hooks/use-timeline-slip-slide' import type { useSmartTrimHover } from './use-smart-trim-hover' -import type { useDragVisualState } from './use-drag-visual-state' export interface TimelineItemPointerHint { x: number @@ -55,7 +53,6 @@ export interface TimelineItemPointerHandlersInput { activeToolRef: RefObject smartTrimIntentRef: ReturnType['smartTrimIntentRef'] smartBodyIntent: SmartBodyIntent - dragWasActiveRef: ReturnType['dragWasActiveRef'] isTrimming: boolean isStretching: boolean isSlipSlideActive: boolean @@ -91,7 +88,6 @@ export function useTimelineItemPointerHandlers({ activeToolRef, smartTrimIntentRef, smartBodyIntent, - dragWasActiveRef, isTrimming, isStretching, isSlipSlideActive, @@ -110,9 +106,6 @@ export function useTimelineItemPointerHandlers({ emitUiSound('error') return } - if (shouldSuppressTimelineItemClickAfterDrag(activeToolRef.current, dragWasActiveRef.current)) - return - // Razor tool: split item at click position if (activeToolRef.current === 'razor') { const tracksContainer = e.currentTarget.closest('.timeline-tracks') as HTMLElement | null @@ -216,7 +209,6 @@ export function useTimelineItemPointerHandlers({ }, [ activeToolRef, - dragWasActiveRef, trackLocked, item.durationInFrames, item.from, diff --git a/src/features/timeline/hooks/use-timeline-drag.dom.test.tsx b/src/features/timeline/hooks/use-timeline-drag.dom.test.tsx new file mode 100644 index 000000000..b01682bd4 --- /dev/null +++ b/src/features/timeline/hooks/use-timeline-drag.dom.test.tsx @@ -0,0 +1,711 @@ +import { useState } from 'react' +import { act, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import type { TimelineItem, TimelineTrack } from '@/types/timeline' +import { useEditorStore } from '@/shared/state/editor' +import { useSelectionStore } from '@/shared/state/selection' +import { + makeTimelineAudioItem, + makeTimelineTrack, + makeTimelineVideoItem, + resetTimelineCompositionTestState, +} from '../test-helpers' +import { useItemsStore } from '../stores/items-store' +import { useLinkedEditPreviewStore } from '../stores/linked-edit-preview-store' +import { useTimelineCommandStore } from '../stores/timeline-command-store' +import { useTimelineSettingsStore } from '../stores/timeline-settings-store' +import { useTimelineStore } from '../stores/timeline-store' +import { useTransitionsStore } from '../stores/transitions-store' +import { useZoomStore } from '../stores/zoom-store' +import { getLinkedItemIds } from '../utils/linked-items' +import { resetPostTimelineGestureClickForTest } from '../components/timeline-item/post-drag-click-guard' +import { useTimelineDrag } from './use-timeline-drag' + +const TIMELINE_DURATION = 600 +const TRACK_HEIGHT = 80 +let rafCallbacks = new Map() +let nextRafId = 1 + +function makeRect(top: number, bottom: number): DOMRect { + return { + x: 0, + y: top, + top, + left: 0, + right: 1000, + bottom, + width: 1000, + height: bottom - top, + toJSON: () => ({}), + } +} + +function setupStores(tracks: TimelineTrack[], items: TimelineItem[]) { + resetTimelineCompositionTestState() + useTimelineSettingsStore.setState({ fps: 30, isDirty: false, snapEnabled: false }) + useZoomStore.setState({ level: 0.3, pixelsPerSecond: 30 }) + useItemsStore.getState().setTracks(tracks) + useItemsStore.getState().setItems(items) + useTransitionsStore.getState().setTransitions([]) + useEditorStore.setState({ linkedSelectionEnabled: true }) + useSelectionStore.getState().clearSelection() + useSelectionStore.getState().setDragState(null) + useSelectionStore.getState().setActiveSnapTarget(null) + useSelectionStore.getState().setActiveLinkedDropTarget(null) + useLinkedEditPreviewStore.getState().clear() +} + +function captureSelectionMetadata() { + const state = useSelectionStore.getState() + return { + selectedItemIds: [...state.selectedItemIds], + selectedItemIdSet: new Set(state.selectedItemIdSet), + selectedMarkerId: state.selectedMarkerId, + selectedTransitionId: state.selectedTransitionId, + selectedTrackId: state.selectedTrackId, + selectedTrackIds: [...state.selectedTrackIds], + activeTrackId: state.activeTrackId, + selectionType: state.selectionType, + activeTool: state.activeTool, + activeSnapTarget: state.activeSnapTarget, + activeLinkedDropTarget: state.activeLinkedDropTarget, + dragState: state.dragState, + editKeyframePanelOpen: state.editKeyframePanelOpen, + expandedKeyframeLanes: new Set(state.expandedKeyframeLanes), + } +} + +function captureMutationState() { + const history = useTimelineCommandStore.getState() + return { + items: structuredClone(useItemsStore.getState().items), + tracks: structuredClone(useItemsStore.getState().tracks), + isDirty: useTimelineSettingsStore.getState().isDirty, + undoStack: structuredClone(history.undoStack), + redoStack: structuredClone(history.redoStack), + canUndo: history.canUndo, + canRedo: history.canRedo, + } +} + +function RenderedDragSurface({ + item, + tracks, + onClipClick, + onBackgroundClick, +}: { + item: TimelineItem + tracks: TimelineTrack[] + onClipClick?: () => void + onBackgroundClick?: () => void +}) { + const { handleDragStart } = useTimelineDrag(item, TIMELINE_DURATION) + const [, rerender] = useState(0) + + return ( +
{ + onBackgroundClick?.() + const selection = useSelectionStore.getState() + selection.clearItemSelection() + selection.selectMarker(null) + rerender((value) => value + 1) + }} + > +
+ {[...tracks] + .sort((left, right) => left.order - right.order) + .map((track) => ( +
+ {track.id === item.trackId && ( + + )} +
+ ))} +
+
+ ) +} + +function renderDragSurface(item: TimelineItem, tracks: TimelineTrack[]) { + const onClipClick = vi.fn() + const onBackgroundClick = vi.fn() + const view = render( + , + ) + const rows = Array.from(view.container.querySelectorAll('[data-track-id]')) + const centerYByTrackId = new Map() + rows.forEach((row, index) => { + const top = index * TRACK_HEIGHT + row.getBoundingClientRect = () => makeRect(top, top + TRACK_HEIGHT) + centerYByTrackId.set(row.dataset.trackId!, top + TRACK_HEIGHT / 2) + }) + const trackContainer = view.container.querySelector('.timeline-tracks')! + const timelineContainer = view.container.querySelector('.timeline-container')! + trackContainer.getBoundingClientRect = () => + makeRect(-TRACK_HEIGHT, rows.length * TRACK_HEIGHT + TRACK_HEIGHT) + timelineContainer.getBoundingClientRect = trackContainer.getBoundingClientRect + + return { + ...view, + anchor: view.getByTestId('drag-anchor'), + background: view.getByTestId('timeline-background'), + centerYByTrackId, + onClipClick, + onBackgroundClick, + } +} + +function flushAnimationFrames() { + const callbacks = Array.from(rafCallbacks.values()) + rafCallbacks.clear() + for (const callback of callbacks) callback(performance.now()) +} + +function dispatchClick(target: Element) { + target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, detail: 1 })) +} + +function dragRendered(params: { + anchor: Element + startX?: number + startY: number + endX: number + endY: number + clickTarget: Element +}) { + const startX = params.startX ?? 0 + fireEvent.mouseDown(params.anchor, { button: 0, clientX: startX, clientY: params.startY }) + fireEvent.mouseMove(window, { clientX: startX + 4, clientY: params.startY }) + act(flushAnimationFrames) + fireEvent.mouseMove(window, { clientX: params.endX, clientY: params.endY }) + act(flushAnimationFrames) + fireEvent.mouseUp(window, { button: 0, clientX: params.endX, clientY: params.endY }) + act(() => dispatchClick(params.clickTarget)) +} + +function makeBasicLinkedCohort() { + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + return { video, audio } +} + +function makePreflightRejectionCase( + kind: 'nested' | 'deep' | 'implicit-nested' | 'missing' | 'cycle' | 'implicit-cycle', +) { + const { video, audio } = makeBasicLinkedCohort() + const baseTracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + + if (kind === 'nested' || kind === 'deep') { + const depth = kind === 'deep' ? 4 : 2 + const groups = Array.from({ length: depth }, (_, index) => + makeTimelineTrack({ + id: `source-group-${index}`, + name: `Source Group ${index}`, + order: 4 + index, + isGroup: true, + locked: index === 0, + parentTrackId: index === 0 ? undefined : `source-group-${index - 1}`, + }), + ) + const source = makeTimelineTrack({ + id: 'source-lane', + name: 'Source Lane', + kind: 'video', + order: 4 + depth, + parentTrackId: `source-group-${depth - 1}`, + }) + return { + tracks: [...baseTracks, ...groups, source], + items: [{ ...video, trackId: source.id }, audio], + anchor: { ...video, trackId: source.id }, + } + } + + if (kind === 'implicit-nested') { + const outer = makeTimelineTrack({ + id: 'audio-outer', + name: 'Audio Outer', + order: 4, + isGroup: true, + locked: true, + }) + const inner = makeTimelineTrack({ + id: 'audio-inner', + name: 'Audio Inner', + order: 5, + isGroup: true, + parentTrackId: outer.id, + }) + const companion = makeTimelineTrack({ + id: 'companion-lane', + name: 'Companion Lane', + kind: 'audio', + order: 6, + parentTrackId: inner.id, + }) + return { + tracks: [...baseTracks, outer, inner, companion], + items: [video, { ...audio, trackId: companion.id }], + anchor: video, + } + } + + if (kind === 'missing') { + const source = makeTimelineTrack({ + id: 'missing-source', + name: 'Missing Source', + kind: 'video', + order: 4, + parentTrackId: 'absent-parent', + }) + return { + tracks: [...baseTracks, source], + items: [{ ...video, trackId: source.id }, audio], + anchor: { ...video, trackId: source.id }, + } + } + + const cycleA = makeTimelineTrack({ + id: 'cycle-a', + name: 'Cycle A', + order: 4, + isGroup: true, + parentTrackId: 'cycle-b', + }) + const cycleB = makeTimelineTrack({ + id: 'cycle-b', + name: 'Cycle B', + order: 5, + isGroup: true, + parentTrackId: 'cycle-a', + }) + const cyclicLane = makeTimelineTrack({ + id: 'cyclic-lane', + name: 'Cyclic Lane', + kind: kind === 'implicit-cycle' ? 'audio' : 'video', + order: 6, + parentTrackId: cycleA.id, + }) + return kind === 'implicit-cycle' + ? { + tracks: [...baseTracks, cycleA, cycleB, cyclicLane], + items: [video, { ...audio, trackId: cyclicLane.id }], + anchor: video, + } + : { + tracks: [...baseTracks, cycleA, cycleB, cyclicLane], + items: [{ ...video, trackId: cyclicLane.id }, audio], + anchor: { ...video, trackId: cyclicLane.id }, + } +} + +describe('useTimelineDrag rendered click ownership', () => { + beforeEach(() => { + rafCallbacks = new Map() + nextRafId = 1 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = nextRafId++ + rafCallbacks.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => rafCallbacks.delete(id)) + }) + + afterEach(() => { + resetPostTimelineGestureClickForTest() + vi.unstubAllGlobals() + }) + + it.each([ + ['row 24 nested locked source', 'nested'], + ['row 25 four-level locked source', 'deep'], + ['row 26 nested locked implicit companion', 'implicit-nested'], + ['row 28 missing source parent', 'missing'], + ['row 30 cyclic source', 'cycle'], + ['row 32 cyclic implicit companion', 'implicit-cycle'], + ] as const)('%s rejects and owns its rendered post-mouseup click', (_name, kind) => { + const { tracks, items, anchor } = makePreflightRejectionCase(kind) + setupStores(tracks, items) + useSelectionStore.getState().selectTrack(anchor.trackId) + const beforeSelection = captureSelectionMetadata() + const beforeMutation = captureMutationState() + const view = renderDragSurface(anchor, tracks) + const startY = view.centerYByTrackId.get(anchor.trackId)! + + dragRendered({ + anchor: view.anchor, + startY, + endX: 30, + endY: startY, + clickTarget: view.background, + }) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeMutation) + }) + + it.each([ + ['row 34 selected cohort', true, false], + ['row 41 unselected cohort with history', false, true], + ] as const)( + '%s restores the complete selection and mutation state after locked-target rejection', + (_name, initiallySelected, seedHistory) => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0, locked: true }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const { video, audio } = makeBasicLinkedCohort() + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, prior]) + useSelectionStore + .getState() + .selectItems(initiallySelected ? [video.id, audio.id] : [prior.id]) + useSelectionStore.getState().setEditKeyframePanelOpen(true) + if (seedHistory) { + useTimelineStore.getState().moveItem(prior.id, prior.from + 1) + } else { + useTimelineSettingsStore.setState({ isDirty: true }) + } + const beforeSelection = captureSelectionMetadata() + const beforeMutation = captureMutationState() + const view = renderDragSurface(video, tracks) + + dragRendered({ + anchor: view.anchor, + startY: view.centerYByTrackId.get('v1')!, + endX: 30, + endY: view.centerYByTrackId.get('v2')!, + clickTarget: view.background, + }) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeMutation) + }, + ) + + it('row 35 restores prior item, track, and keyframe metadata below threshold', () => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const { video, audio } = makeBasicLinkedCohort() + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, prior]) + useSelectionStore.setState({ + selectedItemIds: [prior.id], + selectedItemIdSet: new Set([prior.id]), + selectedMarkerId: null, + selectedTransitionId: null, + selectedTrackId: 'v1', + selectedTrackIds: ['v1'], + activeTrackId: 'v1', + selectionType: 'item', + editKeyframePanelOpen: true, + expandedKeyframeLanes: new Set([prior.id]), + }) + const beforeSelection = captureSelectionMetadata() + const beforeMutation = captureMutationState() + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 10, clientY: startY }) + fireEvent.mouseMove(window, { clientX: 12, clientY: startY }) + fireEvent.mouseUp(window, { button: 0, clientX: 12, clientY: startY }) + act(() => dispatchClick(view.anchor)) + + expect(view.onClipClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeMutation) + }) + + it.each(['marker', 'transition'] as const)( + 'restores a prior %s selection after a rendered below-threshold cancellation', + (selectionType) => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const { video, audio } = makeBasicLinkedCohort() + setupStores(tracks, [video, audio]) + useSelectionStore.getState().selectTrack('v1') + if (selectionType === 'marker') { + useSelectionStore.getState().selectMarker('marker-1') + } else { + useSelectionStore.getState().selectTransition('transition-1') + } + const beforeSelection = captureSelectionMetadata() + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 10, clientY: startY }) + fireEvent.mouseMove(window, { clientX: 12, clientY: startY }) + fireEvent.mouseUp(window, { button: 0, clientX: 12, clientY: startY }) + act(() => dispatchClick(view.anchor)) + + expect(view.onClipClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + }, + ) + + it('row 42 preserves malformed-source full state and the prior keyframe target', () => { + const malformed = makePreflightRejectionCase('missing') + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(malformed.tracks, [...malformed.items, prior]) + useSelectionStore.getState().selectItems([prior.id]) + useSelectionStore.getState().setEditKeyframePanelOpen(true) + useTimelineSettingsStore.setState({ isDirty: true }) + const beforeSelection = captureSelectionMetadata() + const beforeMutation = captureMutationState() + const view = renderDragSurface(malformed.anchor, malformed.tracks) + const startY = view.centerYByTrackId.get(malformed.anchor.trackId)! + + dragRendered({ + anchor: view.anchor, + startY, + endX: 30, + endY: startY, + clickTarget: view.background, + }) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeMutation) + }) + + it.each(['Escape', 'pointercancel'] as const)( + 'restores exact state when an active gesture ends via %s', + (cancellation) => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const { video, audio } = makeBasicLinkedCohort() + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, prior]) + useSelectionStore.getState().selectItems([prior.id]) + const beforeSelection = captureSelectionMetadata() + const beforeMutation = captureMutationState() + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 0, clientY: startY }) + fireEvent.mouseMove(window, { clientX: 4, clientY: startY }) + act(flushAnimationFrames) + if (cancellation === 'Escape') { + fireEvent.keyDown(window, { key: 'Escape' }) + } else { + window.dispatchEvent(new Event('pointercancel', { bubbles: true })) + } + act(() => dispatchClick(view.background)) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeMutation) + }, + ) + + it.each(['source', 'destination'] as const)( + 'revalidates live %s lock drift and owns the rendered release click', + (lockDrift) => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const { video, audio } = makeBasicLinkedCohort() + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, prior]) + useSelectionStore.getState().selectItems([prior.id]) + const beforeSelection = captureSelectionMetadata() + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + const endY = view.centerYByTrackId.get('v2')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 0, clientY: startY }) + fireEvent.mouseMove(window, { clientX: 4, clientY: startY }) + act(flushAnimationFrames) + fireEvent.mouseMove(window, { clientX: 30, clientY: endY }) + act(flushAnimationFrames) + act(() => { + useItemsStore + .getState() + .setTracks( + tracks.map((track) => + track.id === (lockDrift === 'source' ? 'v1' : 'v2') + ? { ...track, locked: true } + : track, + ), + ) + }) + const beforeDropMutation = captureMutationState() + fireEvent.mouseUp(window, { button: 0, clientX: 30, clientY: endY }) + act(() => dispatchClick(view.background)) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeDropMutation) + }, + ) + + it('restores on unmount without swallowing the next independent click', () => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const { video, audio } = makeBasicLinkedCohort() + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, prior]) + useSelectionStore.getState().selectItems([prior.id]) + const beforeSelection = captureSelectionMetadata() + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 0, clientY: startY }) + fireEvent.mouseMove(window, { clientX: 2, clientY: startY }) + view.unmount() + + expect(captureSelectionMetadata()).toEqual(beforeSelection) + + const independent = document.createElement('button') + const onIndependentClick = vi.fn() + independent.addEventListener('click', onIndependentClick) + document.body.appendChild(independent) + fireEvent.mouseDown(independent) + fireEvent.mouseUp(independent) + dispatchClick(independent) + expect(onIndependentClick).toHaveBeenCalledTimes(1) + }) + + it('allows a no-move ordinary click and a Razor click', () => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const { video, audio } = makeBasicLinkedCohort() + setupStores(tracks, [video, audio]) + useSelectionStore.getState().selectTrack('v1') + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 10, clientY: startY }) + fireEvent.mouseUp(window, { button: 0, clientX: 10, clientY: startY }) + act(() => dispatchClick(view.anchor)) + + expect(view.onClipClick).toHaveBeenCalledTimes(1) + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set([video.id, audio.id]), + ) + + const razor = document.createElement('button') + const onRazorClick = vi.fn() + razor.addEventListener('click', onRazorClick) + document.body.appendChild(razor) + fireEvent.mouseDown(razor) + fireEvent.mouseUp(razor) + dispatchClick(razor) + expect(onRazorClick).toHaveBeenCalledTimes(1) + }) + + it('keeps successful linked-drop selection and allows the next independent click', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const { video, audio } = makeBasicLinkedCohort() + setupStores(tracks, [video, audio]) + const view = renderDragSurface(video, tracks) + + dragRendered({ + anchor: view.anchor, + startY: view.centerYByTrackId.get('v1')!, + endX: 30, + endY: view.centerYByTrackId.get('v2')!, + clickTarget: view.background, + }) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set([video.id, audio.id]), + ) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + + fireEvent.mouseDown(view.background) + fireEvent.mouseUp(view.background) + act(() => dispatchClick(view.background)) + expect(view.onBackgroundClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/features/timeline/hooks/use-timeline-drag.ts b/src/features/timeline/hooks/use-timeline-drag.ts index 325e59e67..d450d8595 100644 --- a/src/features/timeline/hooks/use-timeline-drag.ts +++ b/src/features/timeline/hooks/use-timeline-drag.ts @@ -33,6 +33,7 @@ import { DRAG_THRESHOLD_PIXELS } from '../constants' import { createLogger } from '@/shared/logging/logger' import { createRafCoalescedCallback } from '../utils/raf-coalesced-callback' import { resolveEffectiveTrackStates } from '../utils/group-utils' +import { suppressPostTimelineGestureClick } from '../components/timeline-item/post-drag-click-guard' const logger = createLogger('TimelineDrag') @@ -465,7 +466,11 @@ type DragSelectionSnapshot = Pick< | 'selectedItemIdSet' | 'selectedMarkerId' | 'selectedTransitionId' + | 'selectedTrackId' + | 'selectedTrackIds' + | 'activeTrackId' | 'selectionType' + | 'editKeyframePanelOpen' | 'expandedKeyframeLanes' > @@ -475,7 +480,11 @@ function captureDragSelectionSnapshot(state: SelectionState): DragSelectionSnaps selectedItemIdSet: new Set(state.selectedItemIdSet), selectedMarkerId: state.selectedMarkerId, selectedTransitionId: state.selectedTransitionId, + selectedTrackId: state.selectedTrackId, + selectedTrackIds: [...state.selectedTrackIds], + activeTrackId: state.activeTrackId, selectionType: state.selectionType, + editKeyframePanelOpen: state.editKeyframePanelOpen, expandedKeyframeLanes: new Set(state.expandedKeyframeLanes), } } @@ -590,6 +599,7 @@ export function useTimelineDrag( const dragVisualTopByTrackIdRef = useRef>(new Map()) const linkedMovePreviewSignatureRef = useRef('') const selectionRollbackRef = useRef(null) + const gestureMovementRef = useRef(0) const removeDragThresholdListenersRef = useRef<(() => void) | null>(null) // Track Alt key state for duplication mode (dynamic toggle during drag) @@ -652,9 +662,11 @@ export function useTimelineDrag( const finishDragInteraction = useCallback( ({ rollbackSelection, + suppressPostGestureClick = false, updateReactState = true, }: { rollbackSelection: boolean + suppressPostGestureClick?: boolean updateReactState?: boolean }) => { const removeDragThresholdListeners = removeDragThresholdListenersRef.current @@ -674,6 +686,7 @@ export function useTimelineDrag( dragStateRef.current = null isLinkedCohortDragRef.current = false isAltDragRef.current = false + gestureMovementRef.current = 0 clearGlobalDragCursor() document.body.style.userSelect = '' @@ -686,6 +699,10 @@ export function useTimelineDrag( activeLinkedDropTarget: null, }) + if (suppressPostGestureClick) { + suppressPostTimelineGestureClick() + } + if (updateReactState) { setIsDragging(false) setDragOffset({ x: 0, y: 0 }) @@ -694,6 +711,46 @@ export function useTimelineDrag( [clearLinkedMovePreview, elementRef], ) + const trackRejectedDragAttempt = useCallback( + (startMouseX: number, startMouseY: number) => { + const handleMouseMove = (event: MouseEvent) => { + gestureMovementRef.current = Math.max( + gestureMovementRef.current, + Math.abs(event.clientX - startMouseX), + Math.abs(event.clientY - startMouseY), + ) + } + const handleMouseUp = () => { + finishDragInteraction({ + rollbackSelection: true, + suppressPostGestureClick: gestureMovementRef.current > 0, + }) + } + const handleCancellation = () => { + finishDragInteraction({ rollbackSelection: true, suppressPostGestureClick: true }) + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') handleCancellation() + } + const removeListeners = () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + window.removeEventListener('pointercancel', handleCancellation) + window.removeEventListener('keydown', handleKeyDown) + if (removeDragThresholdListenersRef.current === removeListeners) { + removeDragThresholdListenersRef.current = null + } + } + + removeDragThresholdListenersRef.current = removeListeners + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + window.addEventListener('pointercancel', handleCancellation) + window.addEventListener('keydown', handleKeyDown) + }, + [finishDragInteraction], + ) + // Get zoom utilities // Zoom conversions are read imperatively (via store.getState()) at call-time // to avoid subscribing every TimelineItem to the live zoom store. @@ -930,7 +987,17 @@ export function useTimelineDrag( */ const handleDragStart = useCallback( (e: React.MouseEvent) => { - if (dragStateRef.current) return + if (dragStateRef.current || selectionRollbackRef.current) return + + // Prevent if clicking on resize handles + const target = e.target as HTMLElement + if (target.classList.contains('cursor-ew-resize')) { + return + } + + const currentSelectionState = useSelectionStore.getState() + selectionRollbackRef.current = captureDragSelectionSnapshot(currentSelectionState) + gestureMovementRef.current = 0 const allItems = getItems() const currentTracks = useTimelineStore.getState().tracks @@ -938,18 +1005,14 @@ export function useTimelineDrag( // The caller supplies the rendered lock state, but re-read canonical // effective state so a child lane cannot bypass a locked Layer Group. - if (trackLocked || !anchorTrack || anchorTrack.locked) return - - // Prevent if clicking on resize handles - const target = e.target as HTMLElement - if (target.classList.contains('cursor-ew-resize')) { + if (trackLocked || !anchorTrack || anchorTrack.locked) { + trackRejectedDragAttempt(e.clientX, e.clientY) return } e.stopPropagation() // Check if this item is in current selection - const currentSelectionState = useSelectionStore.getState() const currentSelectedIds = currentSelectionState.selectedItemIds const isInSelection = currentSelectedIds.includes(item.id) @@ -969,6 +1032,7 @@ export function useTimelineDrag( ) if (isBlockedByLockedLinkedItem || draggedItems.length === 0) { isLinkedCohortDragRef.current = false + trackRejectedDragAttempt(e.clientX, e.clientY) return } @@ -976,7 +1040,6 @@ export function useTimelineDrag( // validation. A rejected linked gesture is otherwise not atomic. const isMultiSelectClick = e.ctrlKey || e.metaKey if (!isInSelection && !isMultiSelectClick) { - selectionRollbackRef.current = captureDragSelectionSnapshot(currentSelectionState) selectItems(linkedIds) } @@ -988,7 +1051,6 @@ export function useTimelineDrag( baseItemsToDrag.length === selectedIdSet.size && baseItemsToDrag.every((id) => selectedIdSet.has(id)) if (isInSelection && !cohortMatchesSelection) { - selectionRollbackRef.current = captureDragSelectionSnapshot(currentSelectionState) selectItems(baseItemsToDrag) } @@ -1018,6 +1080,11 @@ export function useTimelineDrag( const deltaX = e.clientX - dragStateRef.current.startMouseX const deltaY = e.clientY - dragStateRef.current.startMouseY + gestureMovementRef.current = Math.max( + gestureMovementRef.current, + Math.abs(deltaX), + Math.abs(deltaY), + ) // Check if we've moved enough to start dragging if (Math.abs(deltaX) > DRAG_THRESHOLD_PIXELS || Math.abs(deltaY) > DRAG_THRESHOLD_PIXELS) { @@ -1050,12 +1117,25 @@ export function useTimelineDrag( const cancelDrag = () => { // A click released before the threshold is a cancelled drag attempt. - finishDragInteraction({ rollbackSelection: true }) + finishDragInteraction({ + rollbackSelection: true, + suppressPostGestureClick: gestureMovementRef.current > 0, + }) + } + + const cancelDragExplicitly = () => { + finishDragInteraction({ rollbackSelection: true, suppressPostGestureClick: true }) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') cancelDragExplicitly() } function removeDragThresholdListeners() { window.removeEventListener('mousemove', checkDragThreshold) window.removeEventListener('mouseup', cancelDrag) + window.removeEventListener('pointercancel', cancelDragExplicitly) + window.removeEventListener('keydown', handleKeyDown) if (removeDragThresholdListenersRef.current === removeDragThresholdListeners) { removeDragThresholdListenersRef.current = null } @@ -1064,6 +1144,8 @@ export function useTimelineDrag( removeDragThresholdListenersRef.current = removeDragThresholdListeners window.addEventListener('mousemove', checkDragThreshold) window.addEventListener('mouseup', cancelDrag) + window.addEventListener('pointercancel', cancelDragExplicitly) + window.addEventListener('keydown', handleKeyDown) }, [ clearLinkedMovePreview, @@ -1078,6 +1160,7 @@ export function useTimelineDrag( setDragState, getItems, getMagneticSnapTargets, + trackRejectedDragAttempt, ], ) @@ -1676,7 +1759,17 @@ export function useTimelineDrag( } } - finishDragInteraction({ rollbackSelection: !dropAccepted }) + finishDragInteraction({ + rollbackSelection: !dropAccepted, + suppressPostGestureClick: true, + }) + } + + const handleCancellation = () => { + finishDragInteraction({ rollbackSelection: true, suppressPostGestureClick: true }) + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') handleCancellation() } if (dragStateRef.current) { @@ -1688,10 +1781,14 @@ export function useTimelineDrag( window.addEventListener('mousemove', coalescedMouseMove.queue) window.addEventListener('mouseup', handleCoalescedMouseUp) + window.addEventListener('pointercancel', handleCancellation) + window.addEventListener('keydown', handleKeyDown) return () => { window.removeEventListener('mousemove', coalescedMouseMove.queue) window.removeEventListener('mouseup', handleCoalescedMouseUp) + window.removeEventListener('pointercancel', handleCancellation) + window.removeEventListener('keydown', handleKeyDown) coalescedMouseMove.cancel() } } @@ -1717,7 +1814,11 @@ export function useTimelineDrag( useEffect( () => () => { if (dragStateRef.current || selectionRollbackRef.current) { - finishDragInteraction({ rollbackSelection: true, updateReactState: false }) + finishDragInteraction({ + rollbackSelection: true, + suppressPostGestureClick: true, + updateReactState: false, + }) } }, [finishDragInteraction], From caa8616888dd912a3074e4c331b49cc82ed00913 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 08:37:50 -0700 Subject: [PATCH 34/64] fix(integration): reconcile hierarchy and test storage --- .../timeline/components/timeline-content.tsx | 3 +- .../use-timeline-item-pointer-handlers.ts | 9 +- src/features/timeline/utils/group-utils.ts | 83 ------------------- src/test/setup.ts | 26 ++++++ 4 files changed, 28 insertions(+), 93 deletions(-) diff --git a/src/features/timeline/components/timeline-content.tsx b/src/features/timeline/components/timeline-content.tsx index 3fba95516..feb02c7e8 100644 --- a/src/features/timeline/components/timeline-content.tsx +++ b/src/features/timeline/components/timeline-content.tsx @@ -1383,8 +1383,7 @@ export const TimelineContent = memo(function TimelineContent({ // own scrub path, while drag/marquee/razor gestures must not move playback. const handleContainerClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement - const interactionJustFinished = - marqueeWasActiveRef.current || scrubWasActiveRef.current + const interactionJustFinished = marqueeWasActiveRef.current || scrubWasActiveRef.current // Radix menus render outside the timeline DOM, but their synthetic events // still bubble through this component tree. if (shouldIgnoreTimelineContainerClick(target, interactionJustFinished)) { diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts index 41d8530a0..09055a2d0 100644 --- a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts +++ b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts @@ -207,14 +207,7 @@ export function useTimelineItemPointerHandlers({ selectItems(targetIds) } }, - [ - activeToolRef, - trackLocked, - item.durationInFrames, - item.from, - item.id, - smartTrimIntentRef, - ], + [activeToolRef, trackLocked, item.durationInFrames, item.from, item.id, smartTrimIntentRef], ) // Double-click: open media in source monitor with clip's source range as I/O diff --git a/src/features/timeline/utils/group-utils.ts b/src/features/timeline/utils/group-utils.ts index ffcdcaeb1..378d874ff 100644 --- a/src/features/timeline/utils/group-utils.ts +++ b/src/features/timeline/utils/group-utils.ts @@ -1,88 +1,5 @@ import type { TimelineItem, TimelineTrack } from '@/types/timeline' -type EffectiveTrackState = Pick - -const ROOT_EFFECTIVE_TRACK_STATE: EffectiveTrackState = { - locked: false, - muted: false, - visible: true, - solo: false, -} -const INVALID_PARENT_EFFECTIVE_TRACK_STATE: EffectiveTrackState = { - ...ROOT_EFFECTIVE_TRACK_STATE, - // A malformed ancestry chain must not make an otherwise inherited lock - // disappear. Other properties retain their canonical neutral defaults. - locked: true, -} - -function inheritTrackState( - track: TimelineTrack, - parentState: EffectiveTrackState, -): EffectiveTrackState { - return { - locked: track.locked || parentState.locked, - muted: track.muted || parentState.muted, - visible: track.visible !== false && parentState.visible, - solo: track.solo || parentState.solo, - } -} - -interface GroupAncestryTrace { - path: TimelineTrack[] - parentState: EffectiveTrackState - cycleStartIndex: number | null -} - -function traceGroupAncestry( - groupId: string, - groupsById: ReadonlyMap, - effectiveGroupStateById: ReadonlyMap, -): GroupAncestryTrace { - const path: TimelineTrack[] = [] - const pathIndexById = new Map() - let currentId = groupId - - while (true) { - const knownState = effectiveGroupStateById.get(currentId) - if (knownState) return { path, parentState: knownState, cycleStartIndex: null } - - const cycleStartIndex = pathIndexById.get(currentId) - if (cycleStartIndex !== undefined) { - return { - path, - parentState: INVALID_PARENT_EFFECTIVE_TRACK_STATE, - cycleStartIndex, - } - } - - const currentGroup = groupsById.get(currentId) - if (!currentGroup) { - return { - path, - parentState: INVALID_PARENT_EFFECTIVE_TRACK_STATE, - cycleStartIndex: null, - } - } - - pathIndexById.set(currentId, path.length) - path.push(currentGroup) - if (!currentGroup.parentTrackId) { - return { path, parentState: ROOT_EFFECTIVE_TRACK_STATE, cycleStartIndex: null } - } - currentId = currentGroup.parentTrackId - } -} - -function foldTrackStates( - tracks: readonly TimelineTrack[], - parentState: EffectiveTrackState, -): EffectiveTrackState { - return tracks.reduceRight( - (effectiveState, track) => inheritTrackState(track, effectiveState), - parentState, - ) -} - /** * Build a set of track IDs whose items should contribute snap targets. */ diff --git a/src/test/setup.ts b/src/test/setup.ts index 3fc0556cf..10850533e 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -3,6 +3,32 @@ import { afterEach } from 'vite-plus/test' import '@/i18n' import { resetAutoKeyframeStore } from '@/features/keyframes/stores/auto-keyframe-store' +function ensureTestLocalStorage(): void { + try { + if (typeof globalThis.localStorage !== 'undefined') return + } catch { + // Opaque jsdom origins can expose a throwing localStorage accessor. + } + + const values = new Map() + const storage: Storage = { + get length() { + return values.size + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + } + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: storage, + }) +} + +ensureTestLocalStorage() + // Mock ImageData for Canvas operations type TestGlobalWithImageData = typeof globalThis & { ImageData?: typeof ImageData } const testGlobal = globalThis as TestGlobalWithImageData From 4d981f143dd6049267fac02ec84a6858e9df05b2 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 19:13:05 -0700 Subject: [PATCH 35/64] feat(shortcuts): make transport bindings canonical (cherry picked from commit dcdc29721e771f448e93a3f634448cba1ed06fee) --- packages/freecut-editor/README.md | 6 + .../freecut-editor/consumer-smoke.test.tsx | 18 + packages/freecut-editor/src/index.d.ts | 97 ++ packages/freecut-editor/src/index.ts | 7 + src/config/hotkeys.test.ts | 431 ++++---- src/config/hotkeys.ts | 966 +++++++++--------- src/features/docs/pages/06-timeline.ts | 4 +- src/features/docs/pages/07-editing-tools.ts | 6 +- src/features/docs/pages/08-preview.ts | 6 + src/features/docs/pages/09-source-monitor.ts | 1 + .../docs/pages/20-keyboard-shortcuts.ts | 8 +- src/features/editor/host/contract.ts | 29 + src/features/editor/host/editor-surface.tsx | 37 +- src/features/editor/host/index.ts | 5 + .../editor/host/shortcut-settings.test.ts | 127 +++ src/features/editor/host/shortcut-settings.ts | 86 ++ .../dopesheet-editor/shortcuts.test.tsx | 148 +-- .../components/hotkey-editor-sections.ts | 339 +++--- .../components/timeline-header.test.tsx | 31 + .../timeline/components/timeline-header.tsx | 48 +- .../timeline-item/item-context-menu.test.tsx | 12 +- .../timeline-item/item-context-menu.tsx | 3 +- .../shortcuts/use-playback-shortcuts.test.tsx | 247 ++--- .../hooks/shortcuts/use-playback-shortcuts.ts | 27 +- .../hooks/shortcuts/use-tool-shortcuts.ts | 4 +- .../use-host-timeline-shortcuts.test.tsx | 19 + src/i18n/locales/partials/de/projects.json | 3 + src/i18n/locales/partials/de/timeline.json | 3 + src/i18n/locales/partials/en/projects.json | 3 + src/i18n/locales/partials/en/timeline.json | 3 + src/i18n/locales/partials/es/projects.json | 3 + src/i18n/locales/partials/es/timeline.json | 3 + src/i18n/locales/partials/fr/projects.json | 3 + src/i18n/locales/partials/fr/timeline.json | 3 + src/i18n/locales/partials/ja/projects.json | 3 + src/i18n/locales/partials/ja/timeline.json | 3 + src/i18n/locales/partials/ko/projects.json | 3 + src/i18n/locales/partials/ko/timeline.json | 3 + src/i18n/locales/partials/pt-BR/projects.json | 3 + src/i18n/locales/partials/pt-BR/timeline.json | 3 + src/i18n/locales/partials/tr/projects.json | 3 + src/i18n/locales/partials/tr/timeline.json | 3 + src/i18n/locales/partials/zh/projects.json | 3 + src/i18n/locales/partials/zh/timeline.json | 3 + 44 files changed, 1691 insertions(+), 1075 deletions(-) create mode 100644 src/features/editor/host/shortcut-settings.test.ts create mode 100644 src/features/editor/host/shortcut-settings.ts diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md index 3a5ea9f1d..0c0a2d153 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -63,6 +63,12 @@ provider details, URLs, paths, and media bytes remain host-owned. The same 0.3.0 surface retains the host-backed caption tracks, bounded cues, caption styles, and display toggles from 0.2.0. +Hosts can also provide the optional `EditorHost.shortcuts` port. Its versioned +`HostShortcutSettings` payload carries the same override map used by FreeCut's +shortcut editor, including J/K/L transport. UI changes call `setSettings`, and +host or agent changes can flow back through `subscribe`, so embedded shortcut +configuration never becomes a UI-only setting. + This package is built from a specific FreeCut commit. To create the local consumer artifact from a clean checkout, run: diff --git a/packages/freecut-editor/consumer-smoke.test.tsx b/packages/freecut-editor/consumer-smoke.test.tsx index 442ada09b..b8ca6abab 100644 --- a/packages/freecut-editor/consumer-smoke.test.tsx +++ b/packages/freecut-editor/consumer-smoke.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +/// import '@testing-library/jest-dom' import '@quantfive/freecut-editor-surface/style.css' @@ -6,7 +7,9 @@ import { render, screen, waitFor } from '@testing-library/react' import { beforeAll, describe, expect, it, vi } from 'vite-plus/test' import { FreeCutEditorSurface, + HOTKEYS, capabilityForCommand, + createHostShortcutSettings, isHostCapabilityEnabled, type EditorHost, type EmbeddedEditorSnapshot, @@ -44,6 +47,15 @@ function fakeHost(): EditorHost { submitEdit: vi.fn(() => { throw new Error('consumer smoke does not submit an edit') }), + shortcuts: { + getSettings: () => + createHostShortcutSettings({ + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', + }), + setSettings: vi.fn(), + }, } } @@ -108,6 +120,12 @@ describe('published FreeCut browser entry', () => { expect(screen.getByTestId('properties-clip-panel-host')).toBeInTheDocument() expect(await screen.findByTestId('caption-editor')).toBeInTheDocument() + expect(HOTKEYS).toMatchObject({ + SHUTTLE_REVERSE: 'j', + SHUTTLE_PAUSE: 'k', + SHUTTLE_FORWARD: 'l', + EDIT_KEYFRAME_ADD: 'shift+k', + }) expect(host.load).toHaveBeenCalledTimes(1) expect(capabilityForCommand('move_item')).toBe('timeline.move') expect(capabilityForCommand('set_caption_style')).toBe('timeline.caption') diff --git a/packages/freecut-editor/src/index.d.ts b/packages/freecut-editor/src/index.d.ts index 9a417f9da..477a93a56 100644 --- a/packages/freecut-editor/src/index.d.ts +++ b/packages/freecut-editor/src/index.d.ts @@ -1,5 +1,82 @@ import type { ComponentType, ReactNode } from 'react' +export type HotkeyKey = + | 'PLAY_PAUSE' + | 'SHUTTLE_REVERSE' + | 'SHUTTLE_PAUSE' + | 'SHUTTLE_FORWARD' + | 'PREVIOUS_FRAME' + | 'NEXT_FRAME' + | 'GO_TO_START' + | 'GO_TO_END' + | 'NEXT_SNAP_POINT' + | 'PREVIOUS_SNAP_POINT' + | 'SPLIT_AT_PLAYHEAD' + | 'SPLIT_AT_PLAYHEAD_ALT' + | 'JOIN_ITEMS' + | 'DELETE_SELECTED' + | 'DELETE_SELECTED_ALT' + | 'RIPPLE_DELETE' + | 'RIPPLE_DELETE_ALT' + | 'FREEZE_FRAME' + | 'LINK_AUDIO_VIDEO' + | 'UNLINK_AUDIO_VIDEO' + | 'TOGGLE_LINKED_SELECTION' + | 'NUDGE_LEFT' + | 'NUDGE_RIGHT' + | 'NUDGE_UP' + | 'NUDGE_DOWN' + | 'NUDGE_LEFT_LARGE' + | 'NUDGE_RIGHT_LARGE' + | 'NUDGE_UP_LARGE' + | 'NUDGE_DOWN_LARGE' + | 'UNDO' + | 'REDO' + | 'ZOOM_IN' + | 'ZOOM_OUT' + | 'ZOOM_TO_FIT' + | 'ZOOM_TO_100' + | 'ZOOM_TO_100_ALT' + | 'COPY' + | 'CUT' + | 'PASTE' + | 'SELECTION_TOOL' + | 'TRIM_EDIT_TOOL' + | 'RAZOR_TOOL' + | 'RATE_STRETCH_TOOL' + | 'SLIP_TOOL' + | 'SLIDE_TOOL' + | 'SAVE' + | 'EXPORT' + | 'TOGGLE_SNAP' + | 'TOGGLE_CANVAS_SNAP' + | 'OPEN_SCENE_BROWSER' + | 'WORKSPACE_EDIT' + | 'WORKSPACE_COLOR' + | 'WORKSPACE_ANIMATE' + | 'ADD_MARKER' + | 'REMOVE_MARKER' + | 'PREVIOUS_MARKER' + | 'NEXT_MARKER' + | 'CLEAR_KEYFRAMES' + | 'KEYFRAME_EDITOR_GRAPH' + | 'KEYFRAME_EDITOR_DOPESHEET' + | 'KEYFRAME_EDITOR_SPLIT' + | 'EDIT_KEYFRAME_ADD' + | 'KEYFRAME_PREVIOUS' + | 'KEYFRAME_NEXT' + | 'KEYFRAME_TOGGLE_AUTO' + | 'KEYFRAME_FIT' + | 'MARK_IN' + | 'MARK_OUT' + | 'CLEAR_IN_OUT' + | 'INSERT_EDIT' + | 'OVERWRITE_EDIT' + +export type HotkeyOverrideMap = Partial> + +export declare const HOTKEYS: Readonly> + export type EditorCapability = | 'project.navigate' | 'project.save' @@ -354,6 +431,25 @@ export interface EditorHostNavigation { back(): void } +export declare const HOST_SHORTCUTS_SCHEMA: 'freecut-host-shortcuts' +export declare const HOST_SHORTCUTS_VERSION: 1 + +export interface HostShortcutSettings { + schema: typeof HOST_SHORTCUTS_SCHEMA + version: typeof HOST_SHORTCUTS_VERSION + overrides: HotkeyOverrideMap +} + +export interface EditorShortcutPort { + getSettings(): Promise | HostShortcutSettings + setSettings(settings: HostShortcutSettings): Promise | void + subscribe?(listener: (settings: HostShortcutSettings) => void): () => void +} + +export declare function createHostShortcutSettings( + overrides?: HotkeyOverrideMap, +): HostShortcutSettings + export interface EditorHost { readonly capabilities: EditorCapabilityMap load(): Promise | EmbeddedEditorSnapshot @@ -362,6 +458,7 @@ export interface EditorHost { ): Promise | ResolvedMediaLocator | null submitEdit(batch: EditCommandBatch): Promise | HostEditResult subscribe?(listener: (snapshot: EmbeddedEditorSnapshot) => void): () => void + shortcuts?: EditorShortcutPort transcript?: EditorTranscriptPort navigation?: EditorHostNavigation notify?(notice: HostNotice): void diff --git a/packages/freecut-editor/src/index.ts b/packages/freecut-editor/src/index.ts index 8bad3a08d..31d32a48d 100644 --- a/packages/freecut-editor/src/index.ts +++ b/packages/freecut-editor/src/index.ts @@ -1,7 +1,11 @@ export { FreeCutEditorSurface } from '@/features/editor/host/editor-surface' export { EditorHostProvider } from '@/features/editor/host/context-provider' +export { HOTKEYS } from '@/config/hotkeys' +export type { HotkeyKey, HotkeyOverrideMap } from '@/config/hotkeys' export { DEFAULT_HOST_CAPABILITIES, + HOST_SHORTCUTS_SCHEMA, + HOST_SHORTCUTS_VERSION, MAX_TRANSCRIPT_COMMAND_TEXT_BYTES, MAX_TRANSCRIPT_CURSOR_LENGTH, MAX_TRANSCRIPT_DURATION_US, @@ -11,6 +15,7 @@ export { MAX_TRANSCRIPT_SELECTIONS, SUPPORTED_HOST_COMMANDS, capabilityForCommand, + createHostShortcutSettings, createLocalEditorHost, isHostCapabilityEnabled, } from '@/features/editor/host/contract' @@ -21,6 +26,7 @@ export type { EditorCapabilityMap, EditorHost, EditorHostNavigation, + EditorShortcutPort, EmbeddedEditorAsset, EmbeddedEditorProject, EmbeddedEditorSnapshot, @@ -32,6 +38,7 @@ export type { HostEditResult, HostMediaKind, HostNotice, + HostShortcutSettings, HostTranscriptCommandAction, HostTranscriptCommandPreview, HostTranscriptCommandPreviewRequest, diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index 53dec865a..28e53c697 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from 'vite-plus/test' import { HOTKEYS, HOTKEY_EXPORT_SCHEMA, @@ -15,10 +15,10 @@ import { parseHotkeyImportDocument, resolveHotkeys, sanitizeHotkeyOverrides, -} from "./hotkeys"; +} from './hotkeys' -describe("keyframe productivity hotkeys", () => { - it("provides distinct defaults for the focused editor workflow", () => { +describe('keyframe productivity hotkeys', () => { + it('provides distinct defaults for the focused editor workflow', () => { expect({ split: HOTKEYS.KEYFRAME_EDITOR_SPLIT, addInEdit: HOTKEYS.EDIT_KEYFRAME_ADD, @@ -27,297 +27,380 @@ describe("keyframe productivity hotkeys", () => { auto: HOTKEYS.KEYFRAME_TOGGLE_AUTO, fit: HOTKEYS.KEYFRAME_FIT, }).toEqual({ - split: "3", - addInEdit: "k", - previous: "alt+bracketleft", - next: "alt+bracketright", - auto: "a", - fit: "f", - }); - }); -}); + split: '3', + addInEdit: 'shift+k', + previous: 'alt+bracketleft', + next: 'alt+bracketright', + auto: 'a', + fit: 'f', + }) + }) +}) -describe("normalizeHotkeyBinding", () => { - it("orders modifiers consistently and normalizes aliases", () => { - expect(normalizeHotkeyBinding("Shift+Ctrl+ArrowLeft")).toBe( - "mod+shift+left", - ); - }); -}); +describe('transport and editing defaults', () => { + it('uses canonical J/K/L transport without conflicting with keyframe add', () => { + expect({ + reverse: HOTKEYS.SHUTTLE_REVERSE, + pause: HOTKEYS.SHUTTLE_PAUSE, + forward: HOTKEYS.SHUTTLE_FORWARD, + addKeyframe: HOTKEYS.EDIT_KEYFRAME_ADD, + splitAtPlayhead: HOTKEYS.SPLIT_AT_PLAYHEAD, + }).toEqual({ + reverse: 'j', + pause: 'k', + forward: 'l', + addKeyframe: 'shift+k', + splitAtPlayhead: 'shift+c', + }) + }) +}) -describe("formatHotkeyBinding", () => { - it("formats modifier labels for mac", () => { - expect(formatHotkeyBinding("mod+alt+k", "MacIntel")).toBe( - "Cmd + Option + K", - ); - }); +describe('normalizeHotkeyBinding', () => { + it('orders modifiers consistently and normalizes aliases', () => { + expect(normalizeHotkeyBinding('Shift+Ctrl+ArrowLeft')).toBe('mod+shift+left') + }) +}) - it("formats punctuation bindings for windows", () => { - expect(formatHotkeyBinding("mod+shift+comma", "Win32")).toBe( - "Ctrl + Shift + ,", - ); - }); -}); +describe('formatHotkeyBinding', () => { + it('formats modifier labels for mac', () => { + expect(formatHotkeyBinding('mod+alt+k', 'MacIntel')).toBe('Cmd + Option + K') + }) -describe("getBrowserHostileHotkey", () => { - it("detects browser-reserved shortcuts after normalization", () => { - expect(getBrowserHostileHotkey("Ctrl+E")).toEqual({ - binding: "mod+e", - browserAction: "Focus search or address bar in some browsers", - }); - }); + it('formats punctuation bindings for windows', () => { + expect(formatHotkeyBinding('mod+shift+comma', 'Win32')).toBe('Ctrl + Shift + ,') + }) +}) - it("returns null for browser-safe shortcuts", () => { - expect(getBrowserHostileHotkey("shift+j")).toBeNull(); - }); +describe('getBrowserHostileHotkey', () => { + it('detects browser-reserved shortcuts after normalization', () => { + expect(getBrowserHostileHotkey('Ctrl+E')).toEqual({ + binding: 'mod+e', + browserAction: 'Focus search or address bar in some browsers', + }) + }) - it("flags browser zoom shortcuts as hostile", () => { - expect(getBrowserHostileHotkey("Ctrl+=")).toEqual({ - binding: "mod+equal", - browserAction: "Browser zoom in", - }); - expect(getBrowserHostileHotkey("Ctrl+-")).toEqual({ - binding: "mod+minus", - browserAction: "Browser zoom out", - }); - expect(getBrowserHostileHotkey("Ctrl+0")).toEqual({ - binding: "mod+0", - browserAction: "Reset browser zoom", - }); - }); + it('returns null for browser-safe shortcuts', () => { + expect(getBrowserHostileHotkey('shift+j')).toBeNull() + }) - it("flags Ctrl+Shift+L as hostile and leaves Shift+L available", () => { - expect(getBrowserHostileHotkey("Ctrl+Shift+L")).toEqual({ - binding: "mod+shift+l", - browserAction: "Focus address bar or search in some browsers", - }); - expect(getBrowserHostileHotkey("Shift+L")).toBeNull(); - }); -}); + it('flags browser zoom shortcuts as hostile', () => { + expect(getBrowserHostileHotkey('Ctrl+=')).toEqual({ + binding: 'mod+equal', + browserAction: 'Browser zoom in', + }) + expect(getBrowserHostileHotkey('Ctrl+-')).toEqual({ + binding: 'mod+minus', + browserAction: 'Browser zoom out', + }) + expect(getBrowserHostileHotkey('Ctrl+0')).toEqual({ + binding: 'mod+0', + browserAction: 'Reset browser zoom', + }) + }) -describe("getHotkeyBindingFromEventData", () => { - it("captures letter bindings with modifiers", () => { + it('flags Ctrl+Shift+L as hostile and leaves Shift+L available', () => { + expect(getBrowserHostileHotkey('Ctrl+Shift+L')).toEqual({ + binding: 'mod+shift+l', + browserAction: 'Focus address bar or search in some browsers', + }) + expect(getBrowserHostileHotkey('Shift+L')).toBeNull() + }) +}) + +describe('getHotkeyBindingFromEventData', () => { + it('captures letter bindings with modifiers', () => { expect( getHotkeyBindingFromEventData({ - code: "KeyA", - key: "a", + code: 'KeyA', + key: 'a', ctrlKey: true, shiftKey: true, }), - ).toBe("mod+shift+a"); - }); + ).toBe('mod+shift+a') + }) - it("captures modifier-only previews before a final key lands", () => { + it('captures modifier-only previews before a final key lands', () => { expect( getHotkeyBindingFromEventData({ - code: "ShiftLeft", - key: "Shift", + code: 'ShiftLeft', + key: 'Shift', shiftKey: true, }), - ).toBe("shift"); - }); + ).toBe('shift') + }) - it("uses event.code for shifted punctuation keys", () => { + it('uses event.code for shifted punctuation keys', () => { expect( getHotkeyPrimaryTokenFromEventData({ - code: "Comma", - key: "<", + code: 'Comma', + key: '<', shiftKey: true, }), - ).toBe("comma"); - }); -}); + ).toBe('comma') + }) +}) -describe("findHotkeyConflicts", () => { - it("returns other bindings using the same normalized shortcut", () => { +describe('findHotkeyConflicts', () => { + it('returns other bindings using the same normalized shortcut', () => { const bindings = resolveHotkeys({ - SELECTION_TOOL: "c", - }); + SELECTION_TOOL: 'c', + }) - expect(findHotkeyConflicts(bindings, "c", "SELECTION_TOOL")).toEqual([ - "RAZOR_TOOL", - ]); - }); -}); + expect(findHotkeyConflicts(bindings, 'c', 'SELECTION_TOOL')).toEqual(['RAZOR_TOOL']) + }) +}) -describe("sanitizeHotkeyOverrides", () => { - it("keeps only supported commands with normalized non-default bindings", () => { +describe('sanitizeHotkeyOverrides', () => { + it('keeps only supported commands with normalized non-default bindings', () => { expect( sanitizeHotkeyOverrides({ - PLAY_PAUSE: " Shift+Space ", - EXPORT: "Ctrl+E", - UNKNOWN_COMMAND: "q", - DELETE_SELECTED: "", + PLAY_PAUSE: ' Shift+Space ', + EXPORT: 'Ctrl+E', + UNKNOWN_COMMAND: 'q', + DELETE_SELECTED: '', }), ).toEqual({ - PLAY_PAUSE: "shift+space", - EXPORT: "mod+e", - DELETE_SELECTED: "", - }); - }); -}); + PLAY_PAUSE: 'shift+space', + EXPORT: 'mod+e', + DELETE_SELECTED: '', + }) + }) -describe("createHotkeyExportDocument", () => { - it("creates a versioned export with command metadata and sanitized overrides", () => { + it('migrates the legacy split-at-cursor command id', () => { + expect( + sanitizeHotkeyOverrides({ + SPLIT_AT_CURSOR: 'mod+shift+c', + }), + ).toEqual({ + SPLIT_AT_PLAYHEAD: 'mod+shift+c', + }) + }) +}) + +describe('createHotkeyExportDocument', () => { + it('creates a versioned export with command metadata and sanitized overrides', () => { const exportDocument = createHotkeyExportDocument({ - PLAY_PAUSE: "Shift+Space", - EXPORT: "Ctrl+E", - }); + PLAY_PAUSE: 'Shift+Space', + EXPORT: 'Ctrl+E', + }) - expect(exportDocument.schema).toBe(HOTKEY_EXPORT_SCHEMA); - expect(exportDocument.version).toBe(HOTKEY_EXPORT_VERSION); + expect(exportDocument.schema).toBe(HOTKEY_EXPORT_SCHEMA) + expect(exportDocument.version).toBe(HOTKEY_EXPORT_VERSION) expect(exportDocument.overrides).toEqual({ - PLAY_PAUSE: "shift+space", - EXPORT: "mod+e", - }); + PLAY_PAUSE: 'shift+space', + EXPORT: 'mod+e', + }) expect(exportDocument.commands).toContainEqual( expect.objectContaining({ - id: "PLAY_PAUSE", - label: "Play/Pause", - binding: "shift+space", - defaultBinding: "space", + id: 'PLAY_PAUSE', + label: 'Play/Pause', + binding: 'shift+space', + defaultBinding: 'space', isCustom: true, }), - ); + ) + expect(exportDocument.commands).toContainEqual( + expect.objectContaining({ + id: 'SHUTTLE_PAUSE', + binding: 'k', + defaultBinding: 'k', + }), + ) expect(exportDocument.commands).toContainEqual( expect.objectContaining({ - id: "EXPORT", - binding: "mod+e", - defaultBinding: "mod+shift+e", + id: 'EXPORT', + binding: 'mod+e', + defaultBinding: 'mod+shift+e', isCustom: true, }), - ); - }); + ) + }) - it("exports explicitly unassigned commands as custom blank bindings", () => { + it('exports explicitly unassigned commands as custom blank bindings', () => { const exportDocument = createHotkeyExportDocument({ - DELETE_SELECTED: "", - }); + DELETE_SELECTED: '', + }) expect(exportDocument.overrides).toEqual({ - DELETE_SELECTED: "", - }); + DELETE_SELECTED: '', + }) expect(exportDocument.commands).toContainEqual( expect.objectContaining({ - id: "DELETE_SELECTED", - binding: "", - defaultBinding: "delete", + id: 'DELETE_SELECTED', + binding: '', + defaultBinding: 'delete', isCustom: true, }), - ); - }); -}); + ) + }) +}) -describe("parseHotkeyImportDocument", () => { - it("imports versioned override payloads and ignores unknown commands", () => { +describe('parseHotkeyImportDocument', () => { + it('imports versioned override payloads and ignores unknown commands', () => { expect( parseHotkeyImportDocument({ schema: HOTKEY_EXPORT_SCHEMA, version: 1, overrides: { - PLAY_PAUSE: "Shift+Space", - UNKNOWN_COMMAND: "q", + PLAY_PAUSE: 'Shift+Space', + UNKNOWN_COMMAND: 'q', }, }), ).toEqual({ overrides: { - PLAY_PAUSE: "shift+space", + PLAY_PAUSE: 'shift+space', }, importedCommandCount: 1, ignoredCommandCount: 1, remappedCommandCount: 0, sourceVersion: 1, - }); - }); + }) + }) - it("falls back to command entries when overrides are missing", () => { + it('falls back to command entries when overrides are missing', () => { expect( parseHotkeyImportDocument({ schema: HOTKEY_EXPORT_SCHEMA, version: 1, commands: [ - { id: "PLAY_PAUSE", binding: "Shift+Space" }, - { id: "EXPORT", binding: "Ctrl+E" }, - { id: "UNKNOWN_COMMAND", binding: "q" }, + { id: 'PLAY_PAUSE', binding: 'Shift+Space' }, + { id: 'EXPORT', binding: 'Ctrl+E' }, + { id: 'UNKNOWN_COMMAND', binding: 'q' }, ], }), ).toEqual({ overrides: { - PLAY_PAUSE: "shift+space", - EXPORT: "mod+e", + PLAY_PAUSE: 'shift+space', + EXPORT: 'mod+e', }, importedCommandCount: 2, ignoredCommandCount: 1, remappedCommandCount: 0, sourceVersion: 1, - }); - }); + }) + }) - it("remaps renamed commands from exported metadata when ids no longer match", () => { + it('remaps renamed commands from exported metadata when ids no longer match', () => { expect( parseHotkeyImportDocument({ schema: HOTKEY_EXPORT_SCHEMA, version: 1, commands: [ { - id: "PLAYBACK_TOGGLE_OLD", - label: "Play/Pause", - defaultBinding: "space", - binding: "Shift+Space", + id: 'PLAYBACK_TOGGLE_OLD', + label: 'Play/Pause', + defaultBinding: 'space', + binding: 'Shift+Space', }, ], }), ).toEqual({ overrides: { - PLAY_PAUSE: "shift+space", + PLAY_PAUSE: 'shift+space', }, importedCommandCount: 1, ignoredCommandCount: 0, remappedCommandCount: 1, sourceVersion: 1, - }); - }); + }) + }) - it("imports explicitly unassigned shortcuts", () => { + it('imports explicitly unassigned shortcuts', () => { expect( parseHotkeyImportDocument({ schema: HOTKEY_EXPORT_SCHEMA, version: 1, commands: [ - { id: "PLAY_PAUSE", binding: "" }, - { id: "EXPORT", binding: "Ctrl+E" }, + { id: 'PLAY_PAUSE', binding: '' }, + { id: 'EXPORT', binding: 'Ctrl+E' }, ], }), ).toEqual({ overrides: { - PLAY_PAUSE: "", - EXPORT: "mod+e", + PLAY_PAUSE: '', + EXPORT: 'mod+e', }, importedCommandCount: 2, ignoredCommandCount: 0, remappedCommandCount: 0, sourceVersion: 1, - }); - }); + }) + }) - it("supports plain legacy key-binding maps", () => { + it('supports plain legacy key-binding maps', () => { expect( parseHotkeyImportDocument({ - PLAY_PAUSE: "Shift+Space", - EXPORT: "Ctrl+E", - DELETE_SELECTED: "", - UNKNOWN_COMMAND: "q", + PLAY_PAUSE: 'Shift+Space', + EXPORT: 'Ctrl+E', + DELETE_SELECTED: '', + UNKNOWN_COMMAND: 'q', }), ).toEqual({ overrides: { - PLAY_PAUSE: "shift+space", - EXPORT: "mod+e", - DELETE_SELECTED: "", + PLAY_PAUSE: 'shift+space', + EXPORT: 'mod+e', + DELETE_SELECTED: '', }, importedCommandCount: 3, ignoredCommandCount: 1, remappedCommandCount: 0, sourceVersion: null, - }); - }); -}); + }) + }) + + it('imports the renamed split command from a v1 preset', () => { + expect( + parseHotkeyImportDocument({ + schema: HOTKEY_EXPORT_SCHEMA, + version: 1, + overrides: { + SPLIT_AT_CURSOR: 'mod+shift+c', + }, + }), + ).toEqual({ + overrides: { + SPLIT_AT_PLAYHEAD: 'mod+shift+c', + }, + importedCommandCount: 1, + ignoredCommandCount: 0, + remappedCommandCount: 1, + sourceVersion: 1, + }) + }) + + it('migrates the v1 plain-K keyframe default without recreating the transport conflict', () => { + expect( + parseHotkeyImportDocument({ + schema: HOTKEY_EXPORT_SCHEMA, + version: 1, + commands: [{ id: 'EDIT_KEYFRAME_ADD', binding: 'k', defaultBinding: 'k' }], + }), + ).toEqual({ + overrides: {}, + importedCommandCount: 1, + ignoredCommandCount: 0, + remappedCommandCount: 0, + sourceVersion: 1, + }) + }) + + it('preserves an intentional plain-K keyframe override in a v2 preset', () => { + expect( + parseHotkeyImportDocument({ + schema: HOTKEY_EXPORT_SCHEMA, + version: 2, + overrides: { + EDIT_KEYFRAME_ADD: 'k', + }, + }), + ).toEqual({ + overrides: { + EDIT_KEYFRAME_ADD: 'k', + }, + importedCommandCount: 1, + ignoredCommandCount: 0, + remappedCommandCount: 0, + sourceVersion: 2, + }) + }) +}) diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index 050724cd4..9cc74b8bd 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -7,273 +7,278 @@ export const HOTKEYS = { // Playback controls - PLAY_PAUSE: "space", - PREVIOUS_FRAME: "left", - NEXT_FRAME: "right", - GO_TO_START: "home", - GO_TO_END: "end", - NEXT_SNAP_POINT: "down", - PREVIOUS_SNAP_POINT: "up", + PLAY_PAUSE: 'space', + SHUTTLE_REVERSE: 'j', + SHUTTLE_PAUSE: 'k', + SHUTTLE_FORWARD: 'l', + PREVIOUS_FRAME: 'left', + NEXT_FRAME: 'right', + GO_TO_START: 'home', + GO_TO_END: 'end', + NEXT_SNAP_POINT: 'down', + PREVIOUS_SNAP_POINT: 'up', // Timeline editing - SPLIT_AT_PLAYHEAD_ALT: "alt+c", - JOIN_ITEMS: "shift+j", - DELETE_SELECTED: "delete", - DELETE_SELECTED_ALT: "backspace", - RIPPLE_DELETE: "mod+delete", - RIPPLE_DELETE_ALT: "mod+backspace", - FREEZE_FRAME: "shift+f", - LINK_AUDIO_VIDEO: "mod+alt+l", - UNLINK_AUDIO_VIDEO: "alt+shift+l", - TOGGLE_LINKED_SELECTION: "shift+l", - NUDGE_LEFT: "shift+left", - NUDGE_RIGHT: "shift+right", - NUDGE_UP: "shift+up", - NUDGE_DOWN: "shift+down", - NUDGE_LEFT_LARGE: "mod+shift+left", - NUDGE_RIGHT_LARGE: "mod+shift+right", - NUDGE_UP_LARGE: "mod+shift+up", - NUDGE_DOWN_LARGE: "mod+shift+down", + SPLIT_AT_PLAYHEAD_ALT: 'alt+c', + JOIN_ITEMS: 'shift+j', + DELETE_SELECTED: 'delete', + DELETE_SELECTED_ALT: 'backspace', + RIPPLE_DELETE: 'mod+delete', + RIPPLE_DELETE_ALT: 'mod+backspace', + FREEZE_FRAME: 'shift+f', + LINK_AUDIO_VIDEO: 'mod+alt+l', + UNLINK_AUDIO_VIDEO: 'alt+shift+l', + TOGGLE_LINKED_SELECTION: 'shift+l', + NUDGE_LEFT: 'shift+left', + NUDGE_RIGHT: 'shift+right', + NUDGE_UP: 'shift+up', + NUDGE_DOWN: 'shift+down', + NUDGE_LEFT_LARGE: 'mod+shift+left', + NUDGE_RIGHT_LARGE: 'mod+shift+right', + NUDGE_UP_LARGE: 'mod+shift+up', + NUDGE_DOWN_LARGE: 'mod+shift+down', // History - UNDO: "mod+z", - REDO: "mod+shift+z", + UNDO: 'mod+z', + REDO: 'mod+shift+z', // Zoom - ZOOM_IN: "mod+equal", - ZOOM_OUT: "mod+minus", - ZOOM_TO_FIT: "backslash", - ZOOM_TO_100: "shift+backslash", - ZOOM_TO_100_ALT: "mod+0", + ZOOM_IN: 'mod+equal', + ZOOM_OUT: 'mod+minus', + ZOOM_TO_FIT: 'backslash', + ZOOM_TO_100: 'shift+backslash', + ZOOM_TO_100_ALT: 'mod+0', // Clipboard - COPY: "mod+c", - CUT: "mod+x", - PASTE: "mod+v", + COPY: 'mod+c', + CUT: 'mod+x', + PASTE: 'mod+v', // Tools - SELECTION_TOOL: "v", - TRIM_EDIT_TOOL: "t", - RAZOR_TOOL: "c", - SPLIT_AT_CURSOR: "shift+c", - RATE_STRETCH_TOOL: "r", - SLIP_TOOL: "y", - SLIDE_TOOL: "u", + SELECTION_TOOL: 'v', + TRIM_EDIT_TOOL: 't', + RAZOR_TOOL: 'c', + SPLIT_AT_PLAYHEAD: 'shift+c', + RATE_STRETCH_TOOL: 'r', + SLIP_TOOL: 'y', + SLIDE_TOOL: 'u', // Project - SAVE: "mod+s", - EXPORT: "mod+shift+e", + SAVE: 'mod+s', + EXPORT: 'mod+shift+e', // UI - TOGGLE_SNAP: "s", - TOGGLE_CANVAS_SNAP: "shift+s", - OPEN_SCENE_BROWSER: "mod+shift+f", - WORKSPACE_EDIT: "alt+1", - WORKSPACE_COLOR: "alt+2", - WORKSPACE_ANIMATE: "alt+3", + TOGGLE_SNAP: 's', + TOGGLE_CANVAS_SNAP: 'shift+s', + OPEN_SCENE_BROWSER: 'mod+shift+f', + WORKSPACE_EDIT: 'alt+1', + WORKSPACE_COLOR: 'alt+2', + WORKSPACE_ANIMATE: 'alt+3', // Markers - ADD_MARKER: "m", - REMOVE_MARKER: "shift+m", - PREVIOUS_MARKER: "bracketleft", - NEXT_MARKER: "bracketright", + ADD_MARKER: 'm', + REMOVE_MARKER: 'shift+m', + PREVIOUS_MARKER: 'bracketleft', + NEXT_MARKER: 'bracketright', // Keyframes - CLEAR_KEYFRAMES: "shift+a", - KEYFRAME_EDITOR_GRAPH: "1", - KEYFRAME_EDITOR_DOPESHEET: "2", - KEYFRAME_EDITOR_SPLIT: "3", - EDIT_KEYFRAME_ADD: "k", - KEYFRAME_PREVIOUS: "alt+bracketleft", - KEYFRAME_NEXT: "alt+bracketright", - KEYFRAME_TOGGLE_AUTO: "a", - KEYFRAME_FIT: "f", + CLEAR_KEYFRAMES: 'shift+a', + KEYFRAME_EDITOR_GRAPH: '1', + KEYFRAME_EDITOR_DOPESHEET: '2', + KEYFRAME_EDITOR_SPLIT: '3', + EDIT_KEYFRAME_ADD: 'shift+k', + KEYFRAME_PREVIOUS: 'alt+bracketleft', + KEYFRAME_NEXT: 'alt+bracketright', + KEYFRAME_TOGGLE_AUTO: 'a', + KEYFRAME_FIT: 'f', // Source Monitor - MARK_IN: "i", - MARK_OUT: "o", - CLEAR_IN_OUT: "alt+x", - INSERT_EDIT: "comma", - OVERWRITE_EDIT: "period", -} as const; + MARK_IN: 'i', + MARK_OUT: 'o', + CLEAR_IN_OUT: 'alt+x', + INSERT_EDIT: 'comma', + OVERWRITE_EDIT: 'period', +} as const -export type HotkeyKey = keyof typeof HOTKEYS; -export type HotkeyBindingMap = Record; -export type HotkeyOverrideMap = Partial>; -type HotkeyPlatform = "mac" | "windows"; +export type HotkeyKey = keyof typeof HOTKEYS +export type HotkeyBindingMap = Record +export type HotkeyOverrideMap = Partial> +type HotkeyPlatform = 'mac' | 'windows' -export const HOTKEY_EXPORT_SCHEMA = "freecut-hotkeys"; -export const HOTKEY_EXPORT_VERSION = 1; +export const HOTKEY_EXPORT_SCHEMA = 'freecut-hotkeys' +export const HOTKEY_EXPORT_VERSION = 2 export interface HotkeyExportCommand { - id: HotkeyKey; - label: string; - binding: string; - defaultBinding: string; - isCustom: boolean; + id: HotkeyKey + label: string + binding: string + defaultBinding: string + isCustom: boolean } export interface HotkeyExportDocument { - schema: typeof HOTKEY_EXPORT_SCHEMA; - version: typeof HOTKEY_EXPORT_VERSION; - exportedAt: string; - commands: HotkeyExportCommand[]; - overrides: HotkeyOverrideMap; + schema: typeof HOTKEY_EXPORT_SCHEMA + version: typeof HOTKEY_EXPORT_VERSION + exportedAt: string + commands: HotkeyExportCommand[] + overrides: HotkeyOverrideMap } interface HotkeyImportCommand { - id?: string; - key?: string; - label?: string; - binding?: string; - shortcut?: string; - defaultBinding?: string; + id?: string + key?: string + label?: string + binding?: string + shortcut?: string + defaultBinding?: string } export interface HotkeyImportResult { - overrides: HotkeyOverrideMap; - importedCommandCount: number; - ignoredCommandCount: number; - remappedCommandCount: number; - sourceVersion: number | null; + overrides: HotkeyOverrideMap + importedCommandCount: number + ignoredCommandCount: number + remappedCommandCount: number + sourceVersion: number | null } export interface BrowserHostileHotkey { - binding: string; - browserAction: string; + binding: string + browserAction: string } interface HotkeyCommandLookup { - byLabel: Map; - byDefaultBinding: Map; + byLabel: Map + byDefaultBinding: Map } -const HOTKEY_MODIFIERS = ["mod", "alt", "shift"] as const; -const HOTKEY_MODIFIER_SET = new Set(HOTKEY_MODIFIERS); +const HOTKEY_MODIFIERS = ['mod', 'alt', 'shift'] as const +const HOTKEY_MODIFIER_SET = new Set(HOTKEY_MODIFIERS) const HOTKEY_MODIFIER_ORDER = new Map( HOTKEY_MODIFIERS.map((token, index) => [token, index]), -); +) const HOTKEY_TOKEN_ALIASES: Record = { - cmd: "mod", - command: "mod", - ctrl: "mod", - control: "mod", - option: "alt", - return: "enter", - esc: "escape", - del: "delete", - "=": "equal", - equals: "equal", - "-": "minus", - arrowleft: "left", - arrowright: "right", - arrowup: "up", - arrowdown: "down", -}; + cmd: 'mod', + command: 'mod', + ctrl: 'mod', + control: 'mod', + option: 'alt', + return: 'enter', + esc: 'escape', + del: 'delete', + '=': 'equal', + equals: 'equal', + '-': 'minus', + arrowleft: 'left', + arrowright: 'right', + arrowup: 'up', + arrowdown: 'down', +} const HOTKEY_KEY_LABELS: Record = { - space: "Space", - comma: ",", - period: ".", - bracketleft: "[", - bracketright: "]", - minus: "-", - equal: "=", - slash: "/", - backslash: "\\", - semicolon: ";", + space: 'Space', + comma: ',', + period: '.', + bracketleft: '[', + bracketright: ']', + minus: '-', + equal: '=', + slash: '/', + backslash: '\\', + semicolon: ';', quote: "'", - backquote: "`", - left: "Left", - right: "Right", - up: "Up", - down: "Down", - home: "Home", - end: "End", - delete: "Delete", - backspace: "Backspace", - escape: "Esc", - tab: "Tab", - enter: "Enter", -}; + backquote: '`', + left: 'Left', + right: 'Right', + up: 'Up', + down: 'Down', + home: 'Home', + end: 'End', + delete: 'Delete', + backspace: 'Backspace', + escape: 'Esc', + tab: 'Tab', + enter: 'Enter', +} const HOTKEY_CODE_TOKEN_MAP: Record = { - Space: "space", - Comma: "comma", - Period: "period", - BracketLeft: "bracketleft", - BracketRight: "bracketright", - Minus: "minus", - Equal: "equal", - Slash: "slash", - Backslash: "backslash", - Semicolon: "semicolon", - Quote: "quote", - Backquote: "backquote", - ArrowLeft: "left", - ArrowRight: "right", - ArrowUp: "up", - ArrowDown: "down", - Home: "home", - End: "end", - Delete: "delete", - Backspace: "backspace", - Escape: "escape", - Tab: "tab", - Enter: "enter", -}; - -const HOTKEY_COMMAND_ALIASES: Partial> = {}; + Space: 'space', + Comma: 'comma', + Period: 'period', + BracketLeft: 'bracketleft', + BracketRight: 'bracketright', + Minus: 'minus', + Equal: 'equal', + Slash: 'slash', + Backslash: 'backslash', + Semicolon: 'semicolon', + Quote: 'quote', + Backquote: 'backquote', + ArrowLeft: 'left', + ArrowRight: 'right', + ArrowUp: 'up', + ArrowDown: 'down', + Home: 'home', + End: 'end', + Delete: 'delete', + Backspace: 'backspace', + Escape: 'escape', + Tab: 'tab', + Enter: 'enter', +} + +const HOTKEY_COMMAND_ALIASES: Partial> = { + SPLIT_AT_CURSOR: 'SPLIT_AT_PLAYHEAD', +} const BROWSER_HOSTILE_HOTKEYS: readonly BrowserHostileHotkey[] = [ - { binding: "alt+left", browserAction: "Back navigation" }, - { binding: "alt+right", browserAction: "Forward navigation" }, - { binding: "f5", browserAction: "Reload page" }, - { binding: "mod+r", browserAction: "Reload page" }, - { binding: "mod+shift+r", browserAction: "Hard reload page" }, - { binding: "mod+t", browserAction: "New tab" }, - { binding: "mod+shift+t", browserAction: "Reopen closed tab" }, - { binding: "mod+w", browserAction: "Close tab" }, - { binding: "mod+n", browserAction: "New window" }, - { binding: "mod+shift+n", browserAction: "New private window" }, - { binding: "mod+l", browserAction: "Focus address bar" }, + { binding: 'alt+left', browserAction: 'Back navigation' }, + { binding: 'alt+right', browserAction: 'Forward navigation' }, + { binding: 'f5', browserAction: 'Reload page' }, + { binding: 'mod+r', browserAction: 'Reload page' }, + { binding: 'mod+shift+r', browserAction: 'Hard reload page' }, + { binding: 'mod+t', browserAction: 'New tab' }, + { binding: 'mod+shift+t', browserAction: 'Reopen closed tab' }, + { binding: 'mod+w', browserAction: 'Close tab' }, + { binding: 'mod+n', browserAction: 'New window' }, + { binding: 'mod+shift+n', browserAction: 'New private window' }, + { binding: 'mod+l', browserAction: 'Focus address bar' }, { - binding: "mod+shift+l", - browserAction: "Focus address bar or search in some browsers", + binding: 'mod+shift+l', + browserAction: 'Focus address bar or search in some browsers', }, - { binding: "mod+d", browserAction: "Bookmark page or focus address bar" }, + { binding: 'mod+d', browserAction: 'Bookmark page or focus address bar' }, { - binding: "mod+e", - browserAction: "Focus search or address bar in some browsers", + binding: 'mod+e', + browserAction: 'Focus search or address bar in some browsers', }, - { binding: "mod+p", browserAction: "Print page" }, - { binding: "mod+f", browserAction: "Find in page" }, - { binding: "mod+equal", browserAction: "Browser zoom in" }, - { binding: "mod+minus", browserAction: "Browser zoom out" }, - { binding: "mod+0", browserAction: "Reset browser zoom" }, - { binding: "mod+1", browserAction: "Switch to tab 1" }, - { binding: "mod+2", browserAction: "Switch to tab 2" }, - { binding: "mod+3", browserAction: "Switch to tab 3" }, - { binding: "mod+4", browserAction: "Switch to tab 4" }, - { binding: "mod+5", browserAction: "Switch to tab 5" }, - { binding: "mod+6", browserAction: "Switch to tab 6" }, - { binding: "mod+7", browserAction: "Switch to tab 7" }, - { binding: "mod+8", browserAction: "Switch to tab 8" }, - { binding: "mod+9", browserAction: "Switch to last tab" }, -] as const; + { binding: 'mod+p', browserAction: 'Print page' }, + { binding: 'mod+f', browserAction: 'Find in page' }, + { binding: 'mod+equal', browserAction: 'Browser zoom in' }, + { binding: 'mod+minus', browserAction: 'Browser zoom out' }, + { binding: 'mod+0', browserAction: 'Reset browser zoom' }, + { binding: 'mod+1', browserAction: 'Switch to tab 1' }, + { binding: 'mod+2', browserAction: 'Switch to tab 2' }, + { binding: 'mod+3', browserAction: 'Switch to tab 3' }, + { binding: 'mod+4', browserAction: 'Switch to tab 4' }, + { binding: 'mod+5', browserAction: 'Switch to tab 5' }, + { binding: 'mod+6', browserAction: 'Switch to tab 6' }, + { binding: 'mod+7', browserAction: 'Switch to tab 7' }, + { binding: 'mod+8', browserAction: 'Switch to tab 8' }, + { binding: 'mod+9', browserAction: 'Switch to last tab' }, +] as const const BROWSER_HOSTILE_HOTKEY_MAP = new Map( BROWSER_HOSTILE_HOTKEYS.map((entry) => [entry.binding, entry]), -); +) export interface HotkeyEventData { - key?: string; - code?: string; - ctrlKey?: boolean; - metaKey?: boolean; - altKey?: boolean; - shiftKey?: boolean; + key?: string + code?: string + ctrlKey?: boolean + metaKey?: boolean + altKey?: boolean + shiftKey?: boolean } /** @@ -282,426 +287,401 @@ export interface HotkeyEventData { */ export const HOTKEY_DESCRIPTIONS: Record = { // Playback - PLAY_PAUSE: "Play/Pause", - PREVIOUS_FRAME: "Previous frame", - NEXT_FRAME: "Next frame", - GO_TO_START: "Go to start", - GO_TO_END: "Go to end", - NEXT_SNAP_POINT: "Next snap point", - PREVIOUS_SNAP_POINT: "Previous snap point", + PLAY_PAUSE: 'Play/Pause', + SHUTTLE_REVERSE: 'Shuttle reverse', + SHUTTLE_PAUSE: 'Pause transport', + SHUTTLE_FORWARD: 'Shuttle forward', + PREVIOUS_FRAME: 'Previous frame', + NEXT_FRAME: 'Next frame', + GO_TO_START: 'Go to start', + GO_TO_END: 'Go to end', + NEXT_SNAP_POINT: 'Next snap point', + PREVIOUS_SNAP_POINT: 'Previous snap point', // Timeline editing - SPLIT_AT_PLAYHEAD_ALT: "Split at playhead", - JOIN_ITEMS: "Join selected clips", - DELETE_SELECTED: "Delete selected items", - DELETE_SELECTED_ALT: "Delete selected items (alternative)", - RIPPLE_DELETE: "Ripple delete selected items", - RIPPLE_DELETE_ALT: "Ripple delete selected items (alternative)", - FREEZE_FRAME: "Insert freeze frame at playhead", - LINK_AUDIO_VIDEO: "Link selected clips", - UNLINK_AUDIO_VIDEO: "Unlink selected clips", - TOGGLE_LINKED_SELECTION: "Toggle linked selection", - NUDGE_LEFT: "Nudge selected visual items left (1px)", - NUDGE_RIGHT: "Nudge selected visual items right (1px)", - NUDGE_UP: "Nudge selected visual items up (1px)", - NUDGE_DOWN: "Nudge selected visual items down (1px)", - NUDGE_LEFT_LARGE: "Nudge selected visual items left (10px)", - NUDGE_RIGHT_LARGE: "Nudge selected visual items right (10px)", - NUDGE_UP_LARGE: "Nudge selected visual items up (10px)", - NUDGE_DOWN_LARGE: "Nudge selected visual items down (10px)", + SPLIT_AT_PLAYHEAD_ALT: 'Split at playhead', + JOIN_ITEMS: 'Join selected clips', + DELETE_SELECTED: 'Delete selected items', + DELETE_SELECTED_ALT: 'Delete selected items (alternative)', + RIPPLE_DELETE: 'Ripple delete selected items', + RIPPLE_DELETE_ALT: 'Ripple delete selected items (alternative)', + FREEZE_FRAME: 'Insert freeze frame at playhead', + LINK_AUDIO_VIDEO: 'Link selected clips', + UNLINK_AUDIO_VIDEO: 'Unlink selected clips', + TOGGLE_LINKED_SELECTION: 'Toggle linked selection', + NUDGE_LEFT: 'Nudge selected visual items left (1px)', + NUDGE_RIGHT: 'Nudge selected visual items right (1px)', + NUDGE_UP: 'Nudge selected visual items up (1px)', + NUDGE_DOWN: 'Nudge selected visual items down (1px)', + NUDGE_LEFT_LARGE: 'Nudge selected visual items left (10px)', + NUDGE_RIGHT_LARGE: 'Nudge selected visual items right (10px)', + NUDGE_UP_LARGE: 'Nudge selected visual items up (10px)', + NUDGE_DOWN_LARGE: 'Nudge selected visual items down (10px)', // History - UNDO: "Undo", - REDO: "Redo", + UNDO: 'Undo', + REDO: 'Redo', // Zoom - ZOOM_IN: "Zoom in timeline", - ZOOM_OUT: "Zoom out timeline", - ZOOM_TO_FIT: "Zoom to fit all content", - ZOOM_TO_100: "Zoom to 100% at cursor or playhead", - ZOOM_TO_100_ALT: "Zoom to 100% at cursor or playhead (alternative)", + ZOOM_IN: 'Zoom in timeline', + ZOOM_OUT: 'Zoom out timeline', + ZOOM_TO_FIT: 'Zoom to fit all content', + ZOOM_TO_100: 'Zoom to 100% at cursor or playhead', + ZOOM_TO_100_ALT: 'Zoom to 100% at cursor or playhead (alternative)', // Clipboard - COPY: "Copy selected items or keyframes", - CUT: "Cut selected items or keyframes", - PASTE: "Paste items or keyframes", + COPY: 'Copy selected items or keyframes', + CUT: 'Cut selected items or keyframes', + PASTE: 'Paste items or keyframes', // Tools - SELECTION_TOOL: "Selection tool", - TRIM_EDIT_TOOL: "Trim edit tool", - RAZOR_TOOL: "Razor tool", - SPLIT_AT_CURSOR: "Split at cursor", - RATE_STRETCH_TOOL: "Rate stretch tool", - SLIP_TOOL: "Slip tool", - SLIDE_TOOL: "Slide tool", + SELECTION_TOOL: 'Selection tool', + TRIM_EDIT_TOOL: 'Trim edit tool', + RAZOR_TOOL: 'Razor tool', + SPLIT_AT_PLAYHEAD: 'Split at playhead', + RATE_STRETCH_TOOL: 'Rate stretch tool', + SLIP_TOOL: 'Slip tool', + SLIDE_TOOL: 'Slide tool', // Project - SAVE: "Save project", - EXPORT: "Export video", + SAVE: 'Save project', + EXPORT: 'Export video', // UI - TOGGLE_SNAP: "Toggle snap", - TOGGLE_CANVAS_SNAP: "Toggle canvas (gizmo) snap", - OPEN_SCENE_BROWSER: "Open Scene Browser (search AI captions)", - WORKSPACE_EDIT: "Switch to Edit workspace", - WORKSPACE_COLOR: "Switch to Color workspace", - WORKSPACE_ANIMATE: "Switch to Motion workspace", + TOGGLE_SNAP: 'Toggle snap', + TOGGLE_CANVAS_SNAP: 'Toggle canvas (gizmo) snap', + OPEN_SCENE_BROWSER: 'Open Scene Browser (search AI captions)', + WORKSPACE_EDIT: 'Switch to Edit workspace', + WORKSPACE_COLOR: 'Switch to Color workspace', + WORKSPACE_ANIMATE: 'Switch to Motion workspace', // Markers - ADD_MARKER: "Add marker at playhead", - REMOVE_MARKER: "Remove selected marker", - PREVIOUS_MARKER: "Jump to previous marker", - NEXT_MARKER: "Jump to next marker", + ADD_MARKER: 'Add marker at playhead', + REMOVE_MARKER: 'Remove selected marker', + PREVIOUS_MARKER: 'Jump to previous marker', + NEXT_MARKER: 'Jump to next marker', // Keyframes - CLEAR_KEYFRAMES: "Clear all keyframes from selected items", - KEYFRAME_EDITOR_GRAPH: "Switch keyframe editor to graph view", - KEYFRAME_EDITOR_DOPESHEET: "Switch keyframe editor to dopesheet view", - KEYFRAME_EDITOR_SPLIT: "Switch keyframe editor to split view", - EDIT_KEYFRAME_ADD: "Add keyframe at playhead for selected Edit layer", - KEYFRAME_PREVIOUS: "Jump to previous property keyframe", - KEYFRAME_NEXT: "Jump to next property keyframe", - KEYFRAME_TOGGLE_AUTO: "Toggle auto-key for active property", - KEYFRAME_FIT: "Fit selected keyframes in view", + CLEAR_KEYFRAMES: 'Clear all keyframes from selected items', + KEYFRAME_EDITOR_GRAPH: 'Switch keyframe editor to graph view', + KEYFRAME_EDITOR_DOPESHEET: 'Switch keyframe editor to dopesheet view', + KEYFRAME_EDITOR_SPLIT: 'Switch keyframe editor to split view', + EDIT_KEYFRAME_ADD: 'Add keyframe at playhead for selected Edit layer', + KEYFRAME_PREVIOUS: 'Jump to previous property keyframe', + KEYFRAME_NEXT: 'Jump to next property keyframe', + KEYFRAME_TOGGLE_AUTO: 'Toggle auto-key for active property', + KEYFRAME_FIT: 'Fit selected keyframes in view', // Source Monitor - MARK_IN: "Mark In point", - MARK_OUT: "Mark Out point", - CLEAR_IN_OUT: "Clear In/Out points", - INSERT_EDIT: "Insert edit", - OVERWRITE_EDIT: "Overwrite edit", -}; + MARK_IN: 'Mark In point', + MARK_OUT: 'Mark Out point', + CLEAR_IN_OUT: 'Clear In/Out points', + INSERT_EDIT: 'Insert edit', + OVERWRITE_EDIT: 'Overwrite edit', +} -const HOTKEY_COMMAND_LOOKUP = createHotkeyCommandLookup(); +const HOTKEY_COMMAND_LOOKUP = createHotkeyCommandLookup() function getNavigatorPlatform(): string { - if (typeof navigator === "undefined") return "Windows"; + if (typeof navigator === 'undefined') return 'Windows' const userAgentData = ( navigator as Navigator & { - userAgentData?: { platform?: string }; + userAgentData?: { platform?: string } } - ).userAgentData; + ).userAgentData - if (typeof userAgentData?.platform === "string") { - return userAgentData.platform; + if (typeof userAgentData?.platform === 'string') { + return userAgentData.platform } - return navigator.platform || navigator.userAgent || "Windows"; + return navigator.platform || navigator.userAgent || 'Windows' } function getHotkeyPlatform(platformValue?: string): HotkeyPlatform { - const platform = (platformValue ?? getNavigatorPlatform()).toLowerCase(); - return platform.includes("mac") || - platform.includes("iphone") || - platform.includes("ipad") - ? "mac" - : "windows"; + const platform = (platformValue ?? getNavigatorPlatform()).toLowerCase() + return platform.includes('mac') || platform.includes('iphone') || platform.includes('ipad') + ? 'mac' + : 'windows' } -export function resolveHotkeys( - overrides: HotkeyOverrideMap = {}, -): HotkeyBindingMap { +export function resolveHotkeys(overrides: HotkeyOverrideMap = {}): HotkeyBindingMap { return { ...HOTKEYS, ...sanitizeHotkeyOverrides(overrides), - }; + } } function isExplicitlyUnassignedHotkey(rawBinding: string): boolean { - return rawBinding.trim() === ""; + return rawBinding.trim() === '' } function isHotkeyKey(value: string): value is HotkeyKey { - return value in HOTKEYS; + return value in HOTKEYS } function resolveHotkeyKey(value: string): HotkeyKey | null { if (isHotkeyKey(value)) { - return value; + return value } - return HOTKEY_COMMAND_ALIASES[value] ?? null; + return HOTKEY_COMMAND_ALIASES[value] ?? null } function normalizeHotkeyCommandLabel(label: string): string { - return label.trim().toLowerCase(); + return label.trim().toLowerCase() } function createHotkeyCommandLookup(): HotkeyCommandLookup { - const byLabel = new Map(); - const byDefaultBinding = new Map(); + const byLabel = new Map() + const byDefaultBinding = new Map() for (const key of Object.keys(HOTKEYS) as HotkeyKey[]) { - byLabel.set(normalizeHotkeyCommandLabel(HOTKEY_DESCRIPTIONS[key]), key); - byDefaultBinding.set(normalizeHotkeyBinding(HOTKEYS[key]), key); + byLabel.set(normalizeHotkeyCommandLabel(HOTKEY_DESCRIPTIONS[key]), key) + byDefaultBinding.set(normalizeHotkeyBinding(HOTKEYS[key]), key) } return { byLabel, byDefaultBinding, - }; + } } function resolveHotkeyImportCommand(command: HotkeyImportCommand): { - key: HotkeyKey | null; - wasRemapped: boolean; + key: HotkeyKey | null + wasRemapped: boolean } { const rawKey = - typeof command.id === "string" + typeof command.id === 'string' ? command.id - : typeof command.key === "string" + : typeof command.key === 'string' ? command.key - : null; + : null if (rawKey) { - const directKey = resolveHotkeyKey(rawKey); + const directKey = resolveHotkeyKey(rawKey) if (directKey) { return { key: directKey, wasRemapped: directKey !== rawKey, - }; + } } } - if (typeof command.label === "string") { - const labelMatch = HOTKEY_COMMAND_LOOKUP.byLabel.get( - normalizeHotkeyCommandLabel(command.label), - ); + if (typeof command.label === 'string') { + const labelMatch = HOTKEY_COMMAND_LOOKUP.byLabel.get(normalizeHotkeyCommandLabel(command.label)) if (labelMatch) { return { key: labelMatch, wasRemapped: true, - }; + } } } - if (typeof command.defaultBinding === "string") { - const normalizedDefaultBinding = normalizeHotkeyBinding( - command.defaultBinding, - ); - const bindingMatch = HOTKEY_COMMAND_LOOKUP.byDefaultBinding.get( - normalizedDefaultBinding, - ); + if (typeof command.defaultBinding === 'string') { + const normalizedDefaultBinding = normalizeHotkeyBinding(command.defaultBinding) + const bindingMatch = HOTKEY_COMMAND_LOOKUP.byDefaultBinding.get(normalizedDefaultBinding) if (bindingMatch) { return { key: bindingMatch, wasRemapped: true, - }; + } } } return { key: null, wasRemapped: false, - }; + } } function normalizeHotkeyToken(token: string): string { - const normalized = token.trim().toLowerCase(); - if (!normalized) return ""; - return HOTKEY_TOKEN_ALIASES[normalized] ?? normalized; + const normalized = token.trim().toLowerCase() + if (!normalized) return '' + return HOTKEY_TOKEN_ALIASES[normalized] ?? normalized } export function splitHotkeyBinding(binding: string): string[] { return binding - .split("+") + .split('+') .map((token) => normalizeHotkeyToken(token)) - .filter(Boolean); + .filter(Boolean) } export function normalizeHotkeyBinding(binding: string): string { - const modifiers = new Set(); - const keys: string[] = []; + const modifiers = new Set() + const keys: string[] = [] for (const token of splitHotkeyBinding(binding)) { if (HOTKEY_MODIFIER_SET.has(token)) { - modifiers.add(token); - continue; + modifiers.add(token) + continue } if (!keys.includes(token)) { - keys.push(token); + keys.push(token) } } const orderedModifiers = Array.from(modifiers).sort((left, right) => { - return ( - (HOTKEY_MODIFIER_ORDER.get(left) ?? 99) - - (HOTKEY_MODIFIER_ORDER.get(right) ?? 99) - ); - }); + return (HOTKEY_MODIFIER_ORDER.get(left) ?? 99) - (HOTKEY_MODIFIER_ORDER.get(right) ?? 99) + }) - return [...orderedModifiers, ...keys].join("+"); + return [...orderedModifiers, ...keys].join('+') } export function sanitizeHotkeyOverrides(overrides: unknown): HotkeyOverrideMap { - if (!overrides || typeof overrides !== "object") { - return {}; + if (!overrides || typeof overrides !== 'object') { + return {} } - const normalizedOverrides: HotkeyOverrideMap = {}; + const normalizedOverrides: HotkeyOverrideMap = {} for (const [rawKey, rawBinding] of Object.entries(overrides)) { - if (!isHotkeyKey(rawKey) || typeof rawBinding !== "string") { - continue; + const key = resolveHotkeyKey(rawKey) + if (!key || typeof rawBinding !== 'string') { + continue } if (isExplicitlyUnassignedHotkey(rawBinding)) { - normalizedOverrides[rawKey] = ""; - continue; + normalizedOverrides[key] = '' + continue } - const normalizedBinding = normalizeHotkeyBinding(rawBinding); + const normalizedBinding = normalizeHotkeyBinding(rawBinding) if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - continue; + continue } - if (normalizedBinding === HOTKEYS[rawKey]) { - continue; + if (normalizedBinding === HOTKEYS[key]) { + continue } - normalizedOverrides[rawKey] = normalizedBinding; + normalizedOverrides[key] = normalizedBinding } - return normalizedOverrides; + return normalizedOverrides } export function hasHotkeyPrimaryToken(binding: string): boolean { - return splitHotkeyBinding(binding).some( - (token) => !HOTKEY_MODIFIER_SET.has(token), - ); + return splitHotkeyBinding(binding).some((token) => !HOTKEY_MODIFIER_SET.has(token)) } function formatHotkeyToken(token: string, platform: HotkeyPlatform): string { - if (token === "mod") { - return platform === "mac" ? "Cmd" : "Ctrl"; + if (token === 'mod') { + return platform === 'mac' ? 'Cmd' : 'Ctrl' } - if (token === "alt") { - return platform === "mac" ? "Option" : "Alt"; + if (token === 'alt') { + return platform === 'mac' ? 'Option' : 'Alt' } - if (token === "shift") { - return "Shift"; + if (token === 'shift') { + return 'Shift' } if (HOTKEY_KEY_LABELS[token]) { - return HOTKEY_KEY_LABELS[token]; + return HOTKEY_KEY_LABELS[token] } if (/^[a-z]$/.test(token)) { - return token.toUpperCase(); + return token.toUpperCase() } - return token; + return token } -export function formatHotkeyBinding( - binding: string, - platformValue?: string, -): string { - const normalizedBinding = normalizeHotkeyBinding(binding); - if (!normalizedBinding) return ""; +export function formatHotkeyBinding(binding: string, platformValue?: string): string { + const normalizedBinding = normalizeHotkeyBinding(binding) + if (!normalizedBinding) return '' - const platform = getHotkeyPlatform(platformValue); + const platform = getHotkeyPlatform(platformValue) return normalizedBinding - .split("+") + .split('+') .map((token) => formatHotkeyToken(token, platform)) - .join(" + "); + .join(' + ') } -export function getBrowserHostileHotkey( - binding: string, -): BrowserHostileHotkey | null { - const normalizedBinding = normalizeHotkeyBinding(binding); +export function getBrowserHostileHotkey(binding: string): BrowserHostileHotkey | null { + const normalizedBinding = normalizeHotkeyBinding(binding) if (!normalizedBinding) { - return null; + return null } - return BROWSER_HOSTILE_HOTKEY_MAP.get(normalizedBinding) ?? null; + return BROWSER_HOSTILE_HOTKEY_MAP.get(normalizedBinding) ?? null } -export function getHotkeyPrimaryTokenFromEventData( - eventData: HotkeyEventData, -): string | null { - const code = eventData.code ?? ""; +export function getHotkeyPrimaryTokenFromEventData(eventData: HotkeyEventData): string | null { + const code = eventData.code ?? '' if (HOTKEY_CODE_TOKEN_MAP[code]) { - return HOTKEY_CODE_TOKEN_MAP[code]; + return HOTKEY_CODE_TOKEN_MAP[code] } - if (code.startsWith("Key") && code.length === 4) { - return code.slice(3).toLowerCase(); + if (code.startsWith('Key') && code.length === 4) { + return code.slice(3).toLowerCase() } - if (code.startsWith("Digit") && code.length === 6) { - return code.slice(5); + if (code.startsWith('Digit') && code.length === 6) { + return code.slice(5) } - if (code.startsWith("Numpad") && code.length === 7) { - return code.slice(6); + if (code.startsWith('Numpad') && code.length === 7) { + return code.slice(6) } - const key = normalizeHotkeyToken(eventData.key ?? ""); + const key = normalizeHotkeyToken(eventData.key ?? '') if (!key || HOTKEY_MODIFIER_SET.has(key)) { - return null; + return null } if (key.length === 1 && /^[a-z0-9]$/.test(key)) { - return key; + return key } - return HOTKEY_KEY_LABELS[key] ? key : null; + return HOTKEY_KEY_LABELS[key] ? key : null } -export function getHotkeyBindingFromEventData( - eventData: HotkeyEventData, -): string | null { - const tokens: string[] = []; +export function getHotkeyBindingFromEventData(eventData: HotkeyEventData): string | null { + const tokens: string[] = [] if (eventData.ctrlKey || eventData.metaKey) { - tokens.push("mod"); + tokens.push('mod') } if (eventData.altKey) { - tokens.push("alt"); + tokens.push('alt') } if (eventData.shiftKey) { - tokens.push("shift"); + tokens.push('shift') } - const primaryToken = getHotkeyPrimaryTokenFromEventData(eventData); + const primaryToken = getHotkeyPrimaryTokenFromEventData(eventData) if (primaryToken) { - tokens.push(primaryToken); + tokens.push(primaryToken) } if (tokens.length === 0) { - return null; + return null } - return normalizeHotkeyBinding(tokens.join("+")); + return normalizeHotkeyBinding(tokens.join('+')) } -function getHotkeyConflictMap( - bindings: HotkeyBindingMap, -): Record { - const conflicts: Record = {}; - - for (const [key, binding] of Object.entries(bindings) as [ - HotkeyKey, - string, - ][]) { - const normalizedBinding = normalizeHotkeyBinding(binding); +function getHotkeyConflictMap(bindings: HotkeyBindingMap): Record { + const conflicts: Record = {} + + for (const [key, binding] of Object.entries(bindings) as [HotkeyKey, string][]) { + const normalizedBinding = normalizeHotkeyBinding(binding) if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - continue; + continue } - conflicts[normalizedBinding] ??= []; - conflicts[normalizedBinding].push(key); + conflicts[normalizedBinding] ??= [] + conflicts[normalizedBinding].push(key) } - return conflicts; + return conflicts } export function findHotkeyConflicts( @@ -709,22 +689,22 @@ export function findHotkeyConflicts( binding: string, currentKey?: HotkeyKey, ): HotkeyKey[] { - const normalizedBinding = normalizeHotkeyBinding(binding); + const normalizedBinding = normalizeHotkeyBinding(binding) if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - return []; + return [] } return (getHotkeyConflictMap(bindings)[normalizedBinding] ?? []).filter( (key) => key !== currentKey, - ); + ) } export function createHotkeyExportDocument( overrides: HotkeyOverrideMap = {}, ): HotkeyExportDocument { - const normalizedOverrides = sanitizeHotkeyOverrides(overrides); - const bindings = resolveHotkeys(normalizedOverrides); - const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[]; + const normalizedOverrides = sanitizeHotkeyOverrides(overrides) + const bindings = resolveHotkeys(normalizedOverrides) + const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[] return { schema: HOTKEY_EXPORT_SCHEMA, @@ -738,23 +718,23 @@ export function createHotkeyExportDocument( isCustom: key in normalizedOverrides, })), overrides: normalizedOverrides, - }; + } } function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object"; + return Boolean(value) && typeof value === 'object' } function getImportBinding(command: HotkeyImportCommand): string | null { - if (typeof command.binding === "string") { - return command.binding; + if (typeof command.binding === 'string') { + return command.binding } - if (typeof command.shortcut === "string") { - return command.shortcut; + if (typeof command.shortcut === 'string') { + return command.shortcut } - return null; + return null } function collectImportedOverrides(source: unknown): HotkeyImportResult { @@ -765,43 +745,43 @@ function collectImportedOverrides(source: unknown): HotkeyImportResult { ignoredCommandCount: 0, remappedCommandCount: 0, sourceVersion: null, - }; + } } - const normalizedOverrides: HotkeyOverrideMap = {}; - let importedCommandCount = 0; - let ignoredCommandCount = 0; - let remappedCommandCount = 0; + const normalizedOverrides: HotkeyOverrideMap = {} + let importedCommandCount = 0 + let ignoredCommandCount = 0 + let remappedCommandCount = 0 for (const [rawKey, rawBinding] of Object.entries(source)) { - const resolvedKey = resolveHotkeyKey(rawKey); - if (!resolvedKey || typeof rawBinding !== "string") { - ignoredCommandCount += 1; - continue; + const resolvedKey = resolveHotkeyKey(rawKey) + if (!resolvedKey || typeof rawBinding !== 'string') { + ignoredCommandCount += 1 + continue } - const normalizedBinding = normalizeHotkeyBinding(rawBinding); + const normalizedBinding = normalizeHotkeyBinding(rawBinding) if (isExplicitlyUnassignedHotkey(rawBinding)) { - normalizedOverrides[resolvedKey] = ""; - importedCommandCount += 1; + normalizedOverrides[resolvedKey] = '' + importedCommandCount += 1 if (resolvedKey !== rawKey) { - remappedCommandCount += 1; + remappedCommandCount += 1 } - continue; + continue } if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - ignoredCommandCount += 1; - continue; + ignoredCommandCount += 1 + continue } - importedCommandCount += 1; + importedCommandCount += 1 if (resolvedKey !== rawKey) { - remappedCommandCount += 1; + remappedCommandCount += 1 } if (normalizedBinding !== HOTKEYS[resolvedKey]) { - normalizedOverrides[resolvedKey] = normalizedBinding; + normalizedOverrides[resolvedKey] = normalizedBinding } } @@ -811,84 +791,96 @@ function collectImportedOverrides(source: unknown): HotkeyImportResult { ignoredCommandCount, remappedCommandCount, sourceVersion: null, - }; + } +} + +function migrateLegacyHotkeyImport(result: HotkeyImportResult): HotkeyImportResult { + if ( + (result.sourceVersion === null || result.sourceVersion < 2) && + result.overrides.EDIT_KEYFRAME_ADD === 'k' + ) { + const overrides = { ...result.overrides } + delete overrides.EDIT_KEYFRAME_ADD + return { ...result, overrides } + } + + return result } export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { if (!isRecord(source)) { - throw new Error("Invalid hotkey preset format"); + throw new Error('Invalid hotkey preset format') } if (source.schema !== HOTKEY_EXPORT_SCHEMA) { - return collectImportedOverrides(source); + return migrateLegacyHotkeyImport(collectImportedOverrides(source)) } - const sourceVersion = - typeof source.version === "number" ? source.version : null; + const sourceVersion = typeof source.version === 'number' ? source.version : null - const overridesSource = isRecord(source.overrides) ? source.overrides : null; - const commandsSource = Array.isArray(source.commands) ? source.commands : []; + const overridesSource = isRecord(source.overrides) ? source.overrides : null + const commandsSource = Array.isArray(source.commands) ? source.commands : [] - let importedCommandCount = 0; - let ignoredCommandCount = 0; - let remappedCommandCount = 0; - const importedOverrides: HotkeyOverrideMap = {}; + let importedCommandCount = 0 + let ignoredCommandCount = 0 + let remappedCommandCount = 0 + const importedOverrides: HotkeyOverrideMap = {} if (overridesSource) { - const overrideImport = collectImportedOverrides(overridesSource); - importedCommandCount += overrideImport.importedCommandCount; - ignoredCommandCount += overrideImport.ignoredCommandCount; - remappedCommandCount += overrideImport.remappedCommandCount; - Object.assign(importedOverrides, overrideImport.overrides); + const overrideImport = collectImportedOverrides(overridesSource) + importedCommandCount += overrideImport.importedCommandCount + ignoredCommandCount += overrideImport.ignoredCommandCount + remappedCommandCount += overrideImport.remappedCommandCount + Object.assign(importedOverrides, overrideImport.overrides) } else { for (const command of commandsSource) { if (!isRecord(command)) { - ignoredCommandCount += 1; - continue; + ignoredCommandCount += 1 + continue } - const importCommand = command as HotkeyImportCommand; - const rawBinding = getImportBinding(importCommand); - const resolvedCommand = resolveHotkeyImportCommand(importCommand); + const importCommand = command as HotkeyImportCommand + const rawBinding = getImportBinding(importCommand) + const resolvedCommand = resolveHotkeyImportCommand(importCommand) if (!resolvedCommand.key || rawBinding === null) { - ignoredCommandCount += 1; - continue; + ignoredCommandCount += 1 + continue } - const normalizedBinding = normalizeHotkeyBinding(rawBinding); + const normalizedBinding = normalizeHotkeyBinding(rawBinding) if (isExplicitlyUnassignedHotkey(rawBinding)) { - importedCommandCount += 1; + importedCommandCount += 1 if (resolvedCommand.wasRemapped) { - remappedCommandCount += 1; + remappedCommandCount += 1 } - importedOverrides[resolvedCommand.key] = ""; - continue; + importedOverrides[resolvedCommand.key] = '' + continue } if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - ignoredCommandCount += 1; - continue; + ignoredCommandCount += 1 + continue } - importedCommandCount += 1; + importedCommandCount += 1 if (resolvedCommand.wasRemapped) { - remappedCommandCount += 1; + remappedCommandCount += 1 } if (normalizedBinding !== HOTKEYS[resolvedCommand.key]) { - importedOverrides[resolvedCommand.key] = normalizedBinding; + importedOverrides[resolvedCommand.key] = normalizedBinding } } } - return { + return migrateLegacyHotkeyImport({ overrides: sanitizeHotkeyOverrides(importedOverrides), importedCommandCount, ignoredCommandCount, remappedCommandCount, sourceVersion, - }; + }) } /** @@ -898,4 +890,4 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { export const HOTKEY_OPTIONS = { enableOnFormTags: false, preventDefault: true, -} as const; +} as const diff --git a/src/features/docs/pages/06-timeline.ts b/src/features/docs/pages/06-timeline.ts index 6de463af8..0c9172f48 100644 --- a/src/features/docs/pages/06-timeline.ts +++ b/src/features/docs/pages/06-timeline.ts @@ -48,9 +48,9 @@ const page = { { kind: 'list', items: [ - 'Split at the playhead with `Alt+C`, or use the **Razor** tool (`C`) to cut wherever you click.', + 'Split at the playhead with `Shift+C` (`Alt+C` also works), or use the **Razor** tool (`C`) to cut wherever you click.', 'Join adjacent sections of the same clip with `Shift+J`.', - '**Delete** leaves a gap; **Ripple Delete** (`Ctrl+Delete`) removes the clip and closes the gap.', + '**Delete** leaves a gap; **Ripple Delete** (`Ctrl+Delete` on Windows/Linux, `Cmd+Delete` on macOS) removes the clip and closes the gap.', 'Use **Close All Gaps** to pull clips together and remove empty space on a track.', ], }, diff --git a/src/features/docs/pages/07-editing-tools.ts b/src/features/docs/pages/07-editing-tools.ts index b72b7e745..00cdd70eb 100644 --- a/src/features/docs/pages/07-editing-tools.ts +++ b/src/features/docs/pages/07-editing-tools.ts @@ -32,8 +32,8 @@ const page = { { kind: 'list', items: [ - 'A **ripple** trim changes an edit and shifts all later material, so the total duration changes.', - 'A **rolling** trim moves the cut between two neighboring clips, with no change to overall duration.', + 'Hold `Shift` while dragging a trim edge for a **ripple** trim, which shifts all later material.', + 'Hold `Alt` while dragging a shared edge for a **rolling** trim, which moves the cut between neighboring clips without changing total duration.', 'A **slip** edit changes which source frames appear inside a clip without moving the clip or its neighbors.', 'A **slide** edit moves a clip along the track while automatically adjusting the neighboring cuts.', ], @@ -41,7 +41,7 @@ const page = { { kind: 'note', tone: 'info', - text: 'Ripple and rolling are behaviors of the **Trim edit** tool, not separate tools with their own shortcut.', + text: 'Ripple and rolling are modifier behaviors of the **Trim edit** tool (`T`), not separate tools.', }, ], }, diff --git a/src/features/docs/pages/08-preview.ts b/src/features/docs/pages/08-preview.ts index 2cb57da95..01098379b 100644 --- a/src/features/docs/pages/08-preview.ts +++ b/src/features/docs/pages/08-preview.ts @@ -16,11 +16,17 @@ const page = { kind: 'list', items: [ 'Play and pause with the preview controls or `Space`.', + 'Use `J`, `K`, and `L` for reverse shuttle, pause, and forward shuttle. Repeated `J` or `L` presses increase shuttle speed.', 'Step one frame at a time with `Left` and `Right` for frame-accurate checks.', 'Jump to the start of the timeline with `Home` and the end with `End`.', 'Read the timecode display to confirm the exact playhead position.', ], }, + { + kind: 'note', + tone: 'info', + text: 'When the pointer is over the Source Monitor, `J`, `K`, and `L` control the source. Otherwise they control the program timeline.', + }, ], }, { diff --git a/src/features/docs/pages/09-source-monitor.ts b/src/features/docs/pages/09-source-monitor.ts index 1b246d106..cbc86a65f 100644 --- a/src/features/docs/pages/09-source-monitor.ts +++ b/src/features/docs/pages/09-source-monitor.ts @@ -18,6 +18,7 @@ const page = { 'Double-click a media card, or use **Open In Source Monitor** from Media info, to load a source.', 'The monitor header shows the source file name, with a close control to leave it.', 'Source playback is independent of the timeline preview, so you can scrub a source without moving the timeline playhead.', + 'Hover the Source Monitor and use `J`, `K`, or `L` to shuttle backward, pause, or shuttle forward without affecting program playback.', 'Click the timecode readout to toggle between timecode and frame-number display.', ], }, diff --git a/src/features/docs/pages/20-keyboard-shortcuts.ts b/src/features/docs/pages/20-keyboard-shortcuts.ts index 3977f214c..368a8468c 100644 --- a/src/features/docs/pages/20-keyboard-shortcuts.ts +++ b/src/features/docs/pages/20-keyboard-shortcuts.ts @@ -15,6 +15,7 @@ const page = { headers: ['Action', 'Shortcut'], rows: [ ['Play / Pause', '`Space`'], + ['Shuttle reverse / Pause / Forward', '`J` / `K` / `L`'], ['Previous / Next frame', '`Left` / `Right`'], ['Previous / Next snap point', '`Up` / `Down`'], ['Go to start / end', '`Home` / `End`'], @@ -29,8 +30,7 @@ const page = { kind: 'table', headers: ['Action', 'Shortcut'], rows: [ - ['Split at playhead', '`Alt+C`'], - ['Split at cursor', '`Shift+C`'], + ['Split at playhead', '`Shift+C` / `Alt+C`'], ['Join', '`Shift+J`'], ['Delete / Ripple delete', '`Delete` / `Ctrl+Delete`'], ['Insert freeze frame', '`Shift+F`'], @@ -59,7 +59,7 @@ const page = { { kind: 'note', tone: 'info', - text: 'Ripple and rolling are trim behaviors of the **Trim edit** tool, not separate tools with their own shortcut.', + text: 'With the **Trim edit** tool, hold `Shift` while dragging for a ripple trim or `Alt` for a rolling trim.', }, ], }, @@ -90,7 +90,7 @@ const page = { ['Add / Remove marker', '`M` / `Shift+M`'], ['Previous / Next marker', '`[` / `]`'], ['Clear keyframes', '`Shift+A`'], - ['Add keyframe to selected Edit layer', '`K`'], + ['Add keyframe to selected Edit layer', '`Shift+K`'], ['Keyframe graph / sheet / split view', '`1` / `2` / `3`'], ['Previous / Next property keyframe', '`Alt+[` / `Alt+]`'], ['Toggle auto-key for active property', '`A`'], diff --git a/src/features/editor/host/contract.ts b/src/features/editor/host/contract.ts index 4e16365d6..294dcc9aa 100644 --- a/src/features/editor/host/contract.ts +++ b/src/features/editor/host/contract.ts @@ -7,6 +7,7 @@ import type { TimelineRevision, } from '@/features/editor/codepress/contract' import type { FreeCutFrameDocument } from '@/features/editor/codepress/document' +import { sanitizeHotkeyOverrides, type HotkeyOverrideMap } from '@/config/hotkeys' /** * The browser surface is deliberately a port. It knows how to render and @@ -328,6 +329,32 @@ export interface EditorHostNavigation { back(): void } +export const HOST_SHORTCUTS_SCHEMA = 'freecut-host-shortcuts' +export const HOST_SHORTCUTS_VERSION = 1 + +/** Versioned shortcut payload shared by the host, UI, and agent settings surface. */ +export interface HostShortcutSettings { + schema: typeof HOST_SHORTCUTS_SCHEMA + version: typeof HOST_SHORTCUTS_VERSION + overrides: HotkeyOverrideMap +} + +export interface EditorShortcutPort { + getSettings(): Promise | HostShortcutSettings + setSettings(settings: HostShortcutSettings): Promise | void + subscribe?(listener: (settings: HostShortcutSettings) => void): () => void +} + +export function createHostShortcutSettings( + overrides: HotkeyOverrideMap = {}, +): HostShortcutSettings { + return { + schema: HOST_SHORTCUTS_SCHEMA, + version: HOST_SHORTCUTS_VERSION, + overrides: sanitizeHotkeyOverrides(overrides), + } +} + export interface EditorHost { readonly capabilities: EditorCapabilityMap load(): Promise | EmbeddedEditorSnapshot @@ -343,6 +370,8 @@ export interface EditorHost { * the surface calls when it tears the runtime down. */ subscribe?(listener: (snapshot: EmbeddedEditorSnapshot) => void): () => void + /** Optional host/agent round-trip for user-configurable keyboard shortcuts. */ + shortcuts?: EditorShortcutPort /** Optional application-issued transcript read/preview boundary. */ transcript?: EditorTranscriptPort navigation?: EditorHostNavigation diff --git a/src/features/editor/host/editor-surface.tsx b/src/features/editor/host/editor-surface.tsx index c078f9bfd..456cb64e9 100644 --- a/src/features/editor/host/editor-surface.tsx +++ b/src/features/editor/host/editor-surface.tsx @@ -15,6 +15,7 @@ import { EditorHostProvider } from './context-provider' import { HostCaptionEditorProvider } from './caption-editor-context' import { HostTranscriptEditorProvider } from './transcript-editor-context' import { EmbeddedEditorHostRuntime } from './runtime' +import { mountHostShortcutSettings } from './shortcut-settings' import '@/index.css' interface HostSurfaceState { @@ -40,27 +41,47 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { useEffect(() => { let cancelled = false let unsubscribe: (() => void) | undefined + let unmountShortcutSettings: (() => void) | undefined setState(null) setError(null) - void Promise.all([Promise.resolve(host.load()), i18nReady]) - .then(([snapshot]) => { - if (cancelled) return + + const initialize = async () => { + unmountShortcutSettings = await mountHostShortcutSettings(host) + if (cancelled) { + unmountShortcutSettings() + unmountShortcutSettings = undefined + return + } + + const [snapshot] = await Promise.all([Promise.resolve(host.load()), i18nReady]) + if (!cancelled) { const runtime = new EmbeddedEditorHostRuntime(host, snapshot) - // An out-of-band host revision enters through the same controller the - // result of a submitted edit does, so the surface adopts it in place - // rather than being remounted with a new `host`. + // Host-pushed snapshots and submitted edits share one authoritative + // controller, while shortcut settings retain their independent port. unsubscribe = host.subscribe?.((next) => runtime.controller.replaceAuthoritativeSnapshot(next), ) setState({ snapshot, runtime }) - }) + } + } + + void initialize() + .then(() => undefined) .catch((caught) => { - if (!cancelled) setError(caught instanceof Error ? caught : new Error(String(caught))) + unsubscribe?.() + unsubscribe = undefined + unmountShortcutSettings?.() + unmountShortcutSettings = undefined + if (cancelled) return + setError(caught instanceof Error ? caught : new Error(String(caught))) }) + return () => { cancelled = true unsubscribe?.() unsubscribe = undefined + unmountShortcutSettings?.() + unmountShortcutSettings = undefined } }, [host]) diff --git a/src/features/editor/host/index.ts b/src/features/editor/host/index.ts index 63835412b..55ddefa59 100644 --- a/src/features/editor/host/index.ts +++ b/src/features/editor/host/index.ts @@ -7,6 +7,8 @@ export type { EditorHostContextValue } from './context' export type { EditorHostProviderProps } from './context-provider' export { DEFAULT_HOST_CAPABILITIES, + HOST_SHORTCUTS_SCHEMA, + HOST_SHORTCUTS_VERSION, MAX_TRANSCRIPT_CURSOR_LENGTH, MAX_TRANSCRIPT_COMMAND_TEXT_BYTES, MAX_TRANSCRIPT_DURATION_US, @@ -16,6 +18,7 @@ export { MAX_TRANSCRIPT_SELECTIONS, SUPPORTED_HOST_COMMANDS, capabilityForCommand, + createHostShortcutSettings, createLocalEditorHost, isHostCapabilityEnabled, } from './contract' @@ -24,6 +27,7 @@ export type { EditorCapabilityMap, EditorHost, EditorHostNavigation, + EditorShortcutPort, EmbeddedEditorAsset, EmbeddedEditorProject, EmbeddedEditorSnapshot, @@ -35,6 +39,7 @@ export type { HostEditResult, HostMediaKind, HostNotice, + HostShortcutSettings, HostTranscriptCommandAction, HostTranscriptCommandPreview, HostTranscriptCommandPreviewRequest, diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts new file mode 100644 index 000000000..eb5f3652b --- /dev/null +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment jsdom + +import { createElement } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useSettingsStore } from '@/features/editor/deps/settings' +import { useHostTimelineShortcuts } from '@/features/editor/deps/timeline-hooks' +import { usePlaybackStore } from '@/shared/state/playback' +import { createHostShortcutSettings, type EditorHost, type HostShortcutSettings } from './contract' +import { mountHostShortcutSettings } from './shortcut-settings' + +function HostShortcutHarness() { + useHostTimelineShortcuts() + return null +} + +function createShortcutHost(initial: HostShortcutSettings) { + const listeners = new Set<(settings: HostShortcutSettings) => void>() + const setSettings = vi.fn() + const notify = vi.fn() + const host: EditorHost = { + capabilities: {}, + load: vi.fn(() => { + throw new Error('not used') + }), + resolveMedia: vi.fn(() => null), + submitEdit: vi.fn(() => { + throw new Error('not used') + }), + shortcuts: { + getSettings: vi.fn(() => initial), + setSettings, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + }, + notify, + } + + return { + host, + setSettings, + notify, + emit: (settings: HostShortcutSettings) => { + for (const listener of listeners) listener(settings) + }, + } +} + +describe('host shortcut settings round trip', () => { + beforeEach(() => { + useSettingsStore.getState().resetHotkeys() + usePlaybackStore.setState({ + isPlaying: false, + playbackRate: 1, + transportMode: 'normal', + }) + }) + + it('hydrates host bindings, persists UI changes, and accepts agent updates', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) + const harness = createShortcutHost( + createHostShortcutSettings({ + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', + }), + ) + + const unmount = await mountHostShortcutSettings(harness.host) + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', + }) + + render(createElement(HostShortcutHarness)) + fireEvent.keyDown(document, { key: 'e', code: 'KeyE' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: 1, + transportMode: 'shuttle', + }) + fireEvent.keyDown(document, { key: 'w', code: 'KeyW' }) + expect(usePlaybackStore.getState().isPlaying).toBe(false) + fireEvent.keyDown(document, { key: 'q', code: 'KeyQ' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: -1, + transportMode: 'shuttle', + }) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + + await waitFor(() => + expect(harness.setSettings).toHaveBeenLastCalledWith( + createHostShortcutSettings({ + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'x', + SHUTTLE_FORWARD: 'e', + }), + ), + ) + + harness.emit( + createHostShortcutSettings({ + SHUTTLE_REVERSE: 'a', + SHUTTLE_PAUSE: 's', + SHUTTLE_FORWARD: 'd', + }), + ) + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + SHUTTLE_REVERSE: 'a', + SHUTTLE_PAUSE: 's', + SHUTTLE_FORWARD: 'd', + }) + expect(harness.notify).not.toHaveBeenCalled() + + unmount() + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + PLAY_PAUSE: 'shift+space', + }) + }) +}) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts new file mode 100644 index 000000000..df8cc3ff5 --- /dev/null +++ b/src/features/editor/host/shortcut-settings.ts @@ -0,0 +1,86 @@ +import { sanitizeHotkeyOverrides, type HotkeyOverrideMap } from '@/config/hotkeys' +import { useSettingsStore } from '@/features/editor/deps/settings' +import { + HOST_SHORTCUTS_SCHEMA, + HOST_SHORTCUTS_VERSION, + createHostShortcutSettings, + type EditorHost, + type HostShortcutSettings, +} from './contract' + +function normalizeHostShortcutSettings(settings: HostShortcutSettings): HostShortcutSettings { + if (settings.schema !== HOST_SHORTCUTS_SCHEMA || settings.version !== HOST_SHORTCUTS_VERSION) { + throw new Error('Unsupported host shortcut settings schema') + } + + return createHostShortcutSettings(sanitizeHotkeyOverrides(settings.overrides)) +} + +function copyOverrides(overrides: HotkeyOverrideMap): HotkeyOverrideMap { + return { ...overrides } +} + +/** + * Hydrates host-owned shortcuts before the editor mounts, then keeps UI and + * host/agent changes synchronized for the lifetime of the embedded surface. + */ +export async function mountHostShortcutSettings(host: EditorHost): Promise<() => void> { + const port = host.shortcuts + if (!port) { + return () => undefined + } + + const standaloneOverrides = copyOverrides(useSettingsStore.getState().hotkeyOverrides) + let applyingHostSettings = false + let disposed = false + let writeQueue = Promise.resolve() + + const reportFailure = (message: string) => { + host.notify?.({ kind: 'error', message }) + } + + const applyHostSettings = (settings: HostShortcutSettings) => { + if (disposed) return + const normalized = normalizeHostShortcutSettings(settings) + applyingHostSettings = true + try { + useSettingsStore.getState().replaceHotkeyOverrides(normalized.overrides) + } finally { + applyingHostSettings = false + } + } + + applyHostSettings(await Promise.resolve(port.getSettings())) + + const unsubscribeHost = port.subscribe?.((settings) => { + try { + applyHostSettings(settings) + } catch { + reportFailure('Could not apply keyboard shortcuts from the host.') + } + }) + + const unsubscribeStore = useSettingsStore.subscribe((state, previousState) => { + if ( + disposed || + applyingHostSettings || + state.hotkeyOverrides === previousState.hotkeyOverrides + ) { + return + } + + const settings = createHostShortcutSettings(copyOverrides(state.hotkeyOverrides)) + writeQueue = writeQueue + .then(() => Promise.resolve(port.setSettings(settings))) + .catch(() => { + reportFailure('Could not save keyboard shortcuts to the host.') + }) + }) + + return () => { + disposed = true + unsubscribeStore() + unsubscribeHost?.() + useSettingsStore.getState().replaceHotkeyOverrides(standaloneOverrides) + } +} diff --git a/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx b/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx index f3b1f2b92..5e8fee251 100644 --- a/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx +++ b/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx @@ -1,19 +1,19 @@ -import { fireEvent, render } from "@testing-library/react"; -import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; -import { DopesheetEditor } from "./index"; +import { fireEvent, render } from '@testing-library/react' +import { beforeAll, describe, expect, it, vi } from 'vite-plus/test' +import { DopesheetEditor } from './index' -describe("DopesheetEditor shortcuts", () => { +describe('DopesheetEditor shortcuts', () => { beforeAll(() => { class ResizeObserverMock { observe() {} unobserve() {} disconnect() {} } - vi.stubGlobal("ResizeObserver", ResizeObserverMock); - }); + vi.stubGlobal('ResizeObserver', ResizeObserverMock) + }) - it("adds a keyframe through the active property handler", () => { - const onAddKeyframe = vi.fn(); + it('adds a keyframe through the active property handler', () => { + const onAddKeyframe = vi.fn() render( { onAddKeyframe={onAddKeyframe} shortcutsEnabled shortcuts={{ - addKeyframe: "k", - previousKeyframe: "alt+bracketleft", - nextKeyframe: "alt+bracketright", - toggleAutoKey: "a", - fitKeyframes: "f", + addKeyframe: 'shift+k', + previousKeyframe: 'alt+bracketleft', + nextKeyframe: 'alt+bracketright', + toggleAutoKey: 'a', + fitKeyframes: 'f', }} />, - ); + ) - fireEvent.keyDown(document, { key: "k", code: "KeyK" }); + fireEvent.keyDown(document, { key: 'K', code: 'KeyK', shiftKey: true }) - expect(onAddKeyframe).toHaveBeenCalledWith("x", 24); - }); + expect(onAddKeyframe).toHaveBeenCalledWith('x', 24) + }) - it("does not remove an existing keyframe when adding with the shortcut", () => { - const onAddKeyframe = vi.fn(); - const onRemoveKeyframes = vi.fn(); + it('does not remove an existing keyframe when adding with the shortcut', () => { + const onAddKeyframe = vi.fn() + const onRemoveKeyframes = vi.fn() render( { onRemoveKeyframes={onRemoveKeyframes} shortcutsEnabled shortcuts={{ - addKeyframe: "k", - previousKeyframe: "alt+bracketleft", - nextKeyframe: "alt+bracketright", - toggleAutoKey: "a", - fitKeyframes: "f", + addKeyframe: 'shift+k', + previousKeyframe: 'alt+bracketleft', + nextKeyframe: 'alt+bracketright', + toggleAutoKey: 'a', + fitKeyframes: 'f', }} />, - ); + ) - fireEvent.keyDown(document, { key: "k", code: "KeyK" }); + fireEvent.keyDown(document, { key: 'K', code: 'KeyK', shiftKey: true }) - expect(onAddKeyframe).not.toHaveBeenCalled(); - expect(onRemoveKeyframes).not.toHaveBeenCalled(); - }); + expect(onAddKeyframe).not.toHaveBeenCalled() + expect(onRemoveKeyframes).not.toHaveBeenCalled() + }) - it("does not fire editor shortcuts while they are out of scope", () => { - const onAddKeyframe = vi.fn(); + it('does not fire editor shortcuts while they are out of scope', () => { + const onAddKeyframe = vi.fn() render( { onAddKeyframe={onAddKeyframe} shortcutsEnabled={false} shortcuts={{ - addKeyframe: "k", - previousKeyframe: "alt+bracketleft", - nextKeyframe: "alt+bracketright", - toggleAutoKey: "a", - fitKeyframes: "f", + addKeyframe: 'shift+k', + previousKeyframe: 'alt+bracketleft', + nextKeyframe: 'alt+bracketright', + toggleAutoKey: 'a', + fitKeyframes: 'f', }} />, - ); + ) - fireEvent.keyDown(document, { key: "k", code: "KeyK" }); + fireEvent.keyDown(document, { key: 'K', code: 'KeyK', shiftKey: true }) - expect(onAddKeyframe).not.toHaveBeenCalled(); - }); + expect(onAddKeyframe).not.toHaveBeenCalled() + }) - it("keeps only the Edit add shortcut active outside editor focus", () => { - const onAddKeyframe = vi.fn(); - const onNavigateToKeyframe = vi.fn(); + it('keeps only the Edit add shortcut active outside editor focus', () => { + const onAddKeyframe = vi.fn() + const onNavigateToKeyframe = vi.fn() render( { shortcutsEnabled={false} addKeyframeShortcutEnabled shortcuts={{ - addKeyframe: "k", - previousKeyframe: "alt+bracketleft", - nextKeyframe: "alt+bracketright", - toggleAutoKey: "a", - fitKeyframes: "f", + addKeyframe: 'shift+k', + previousKeyframe: 'alt+bracketleft', + nextKeyframe: 'alt+bracketright', + toggleAutoKey: 'a', + fitKeyframes: 'f', }} />, - ); + ) - fireEvent.keyDown(document, { key: "k", code: "KeyK" }); + fireEvent.keyDown(document, { key: 'K', code: 'KeyK', shiftKey: true }) fireEvent.keyDown(document, { - key: "[", - code: "BracketLeft", + key: '[', + code: 'BracketLeft', altKey: true, - }); + }) - expect(onAddKeyframe).toHaveBeenCalledWith("x", 24); - expect(onNavigateToKeyframe).not.toHaveBeenCalled(); - }); -}); + expect(onAddKeyframe).toHaveBeenCalledWith('x', 24) + expect(onNavigateToKeyframe).not.toHaveBeenCalled() + }) + + it('does not add a keyframe on plain K', () => { + const onAddKeyframe = vi.fn() + + render( + , + ) + + fireEvent.keyDown(document, { key: 'k', code: 'KeyK' }) + + expect(onAddKeyframe).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/settings/components/hotkey-editor-sections.ts b/src/features/settings/components/hotkey-editor-sections.ts index 26479d236..0b108656b 100644 --- a/src/features/settings/components/hotkey-editor-sections.ts +++ b/src/features/settings/components/hotkey-editor-sections.ts @@ -3,45 +3,42 @@ import { normalizeHotkeyBinding, type HotkeyBindingMap, type HotkeyKey, -} from "@/config/hotkeys"; +} from '@/config/hotkeys' export interface HotkeyEditorItem { /** i18n key for the command label */ - labelKey: string; - keys: readonly HotkeyKey[]; + labelKey: string + keys: readonly HotkeyKey[] } export interface HotkeyEditorSection { /** i18n key for the section title */ - titleKey: string; + titleKey: string /** i18n key for the section blurb */ - blurbKey: string; + blurbKey: string /** * i18n key describing where these shortcuts are active, for sections whose * commands only fire while a specific panel owns focus. Omitted for globally * active sections. */ - scopeKey?: string; - items: readonly HotkeyEditorItem[]; + scopeKey?: string + items: readonly HotkeyEditorItem[] } export interface HotkeyEditorSearchResult { - section: HotkeyEditorSection; - item: HotkeyEditorItem; + section: HotkeyEditorSection + item: HotkeyEditorItem } interface HotkeyEditorSearchOptions { - query: string; - sections: readonly HotkeyEditorSection[]; - hotkeys: HotkeyBindingMap; - translate: (key: string) => string; + query: string + sections: readonly HotkeyEditorSection[] + hotkeys: HotkeyBindingMap + translate: (key: string) => string } -export function getHotkeyBindingDisplayLabel( - binding: string, - unassignedLabel: string, -): string { - return binding ? formatHotkeyBinding(binding) : unassignedLabel; +export function getHotkeyBindingDisplayLabel(binding: string, unassignedLabel: string): string { + return binding ? formatHotkeyBinding(binding) : unassignedLabel } export function getHotkeyEditorSearchResults({ @@ -50,315 +47,315 @@ export function getHotkeyEditorSearchResults({ hotkeys, translate, }: HotkeyEditorSearchOptions): HotkeyEditorSearchResult[] { - const normalizedQuery = query.trim().toLowerCase(); + const normalizedQuery = query.trim().toLowerCase() if (!normalizedQuery) { - return []; + return [] } - const normalizedBindingQuery = normalizeHotkeyBinding(normalizedQuery); + const normalizedBindingQuery = normalizeHotkeyBinding(normalizedQuery) return sections.flatMap((section) => { - const sectionLabel = translate(section.titleKey).toLowerCase(); + const sectionLabel = translate(section.titleKey).toLowerCase() return section.items .filter((item) => { - const itemLabel = translate(item.labelKey).toLowerCase(); - const bindings = item.keys.map((key) => hotkeys[key].toLowerCase()); + const itemLabel = translate(item.labelKey).toLowerCase() + const bindings = item.keys.map((key) => hotkeys[key].toLowerCase()) return ( itemLabel.includes(normalizedQuery) || sectionLabel.includes(normalizedQuery) || - item.keys.some((key) => - key.toLowerCase().includes(normalizedQuery), - ) || + item.keys.some((key) => key.toLowerCase().includes(normalizedQuery)) || bindings.some( (binding) => binding.includes(normalizedQuery) || - (normalizedBindingQuery.length > 0 && - binding === normalizedBindingQuery), + (normalizedBindingQuery.length > 0 && binding === normalizedBindingQuery), ) - ); + ) }) - .map((item) => ({ section, item })); - }); + .map((item) => ({ section, item })) + }) } export const HOTKEY_EDITOR_SECTIONS: readonly HotkeyEditorSection[] = [ { - titleKey: "projects.settings.hotkeys.sections.playback.title", - blurbKey: "projects.settings.hotkeys.sections.playback.blurb", + titleKey: 'projects.settings.hotkeys.sections.playback.title', + blurbKey: 'projects.settings.hotkeys.sections.playback.blurb', items: [ { - labelKey: "projects.settings.hotkeys.items.playPause", - keys: ["PLAY_PAUSE"], + labelKey: 'projects.settings.hotkeys.items.playPause', + keys: ['PLAY_PAUSE'], }, { - labelKey: "projects.settings.hotkeys.items.previousFrame", - keys: ["PREVIOUS_FRAME"], + labelKey: 'projects.settings.hotkeys.items.shuttleReverse', + keys: ['SHUTTLE_REVERSE'], }, { - labelKey: "projects.settings.hotkeys.items.nextFrame", - keys: ["NEXT_FRAME"], + labelKey: 'projects.settings.hotkeys.items.shuttlePause', + keys: ['SHUTTLE_PAUSE'], }, { - labelKey: "projects.settings.hotkeys.items.goToStart", - keys: ["GO_TO_START"], + labelKey: 'projects.settings.hotkeys.items.shuttleForward', + keys: ['SHUTTLE_FORWARD'], }, { - labelKey: "projects.settings.hotkeys.items.goToEnd", - keys: ["GO_TO_END"], + labelKey: 'projects.settings.hotkeys.items.previousFrame', + keys: ['PREVIOUS_FRAME'], }, { - labelKey: "projects.settings.hotkeys.items.previousSnapPoint", - keys: ["PREVIOUS_SNAP_POINT"], + labelKey: 'projects.settings.hotkeys.items.nextFrame', + keys: ['NEXT_FRAME'], }, { - labelKey: "projects.settings.hotkeys.items.nextSnapPoint", - keys: ["NEXT_SNAP_POINT"], + labelKey: 'projects.settings.hotkeys.items.goToStart', + keys: ['GO_TO_START'], + }, + { + labelKey: 'projects.settings.hotkeys.items.goToEnd', + keys: ['GO_TO_END'], + }, + { + labelKey: 'projects.settings.hotkeys.items.previousSnapPoint', + keys: ['PREVIOUS_SNAP_POINT'], + }, + { + labelKey: 'projects.settings.hotkeys.items.nextSnapPoint', + keys: ['NEXT_SNAP_POINT'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.editing.title", - blurbKey: "projects.settings.hotkeys.sections.editing.blurb", + titleKey: 'projects.settings.hotkeys.sections.editing.title', + blurbKey: 'projects.settings.hotkeys.sections.editing.blurb', items: [ { - labelKey: "projects.settings.hotkeys.items.splitAtPlayhead", - keys: ["SPLIT_AT_PLAYHEAD_ALT"], + labelKey: 'projects.settings.hotkeys.items.splitAtPlayhead', + keys: ['SPLIT_AT_PLAYHEAD', 'SPLIT_AT_PLAYHEAD_ALT'], }, { - labelKey: "projects.settings.hotkeys.items.joinSelectedClips", - keys: ["JOIN_ITEMS"], + labelKey: 'projects.settings.hotkeys.items.joinSelectedClips', + keys: ['JOIN_ITEMS'], }, { - labelKey: "projects.settings.hotkeys.items.deleteSelectedItems", - keys: ["DELETE_SELECTED", "DELETE_SELECTED_ALT"], + labelKey: 'projects.settings.hotkeys.items.deleteSelectedItems', + keys: ['DELETE_SELECTED', 'DELETE_SELECTED_ALT'], }, { - labelKey: "projects.settings.hotkeys.items.rippleDeleteSelectedItems", - keys: ["RIPPLE_DELETE", "RIPPLE_DELETE_ALT"], + labelKey: 'projects.settings.hotkeys.items.rippleDeleteSelectedItems', + keys: ['RIPPLE_DELETE', 'RIPPLE_DELETE_ALT'], }, { - labelKey: "projects.settings.hotkeys.items.insertFreezeFrame", - keys: ["FREEZE_FRAME"], + labelKey: 'projects.settings.hotkeys.items.insertFreezeFrame', + keys: ['FREEZE_FRAME'], }, { - labelKey: "projects.settings.hotkeys.items.linkSelectedClips", - keys: ["LINK_AUDIO_VIDEO"], + labelKey: 'projects.settings.hotkeys.items.linkSelectedClips', + keys: ['LINK_AUDIO_VIDEO'], }, { - labelKey: "projects.settings.hotkeys.items.unlinkSelectedClips", - keys: ["UNLINK_AUDIO_VIDEO"], + labelKey: 'projects.settings.hotkeys.items.unlinkSelectedClips', + keys: ['UNLINK_AUDIO_VIDEO'], }, { - labelKey: "projects.settings.hotkeys.items.toggleLinkedSelection", - keys: ["TOGGLE_LINKED_SELECTION"], + labelKey: 'projects.settings.hotkeys.items.toggleLinkedSelection', + keys: ['TOGGLE_LINKED_SELECTION'], }, { - labelKey: "projects.settings.hotkeys.items.nudge1px", - keys: ["NUDGE_LEFT", "NUDGE_RIGHT", "NUDGE_UP", "NUDGE_DOWN"], + labelKey: 'projects.settings.hotkeys.items.nudge1px', + keys: ['NUDGE_LEFT', 'NUDGE_RIGHT', 'NUDGE_UP', 'NUDGE_DOWN'], }, { - labelKey: "projects.settings.hotkeys.items.nudge10px", - keys: [ - "NUDGE_LEFT_LARGE", - "NUDGE_RIGHT_LARGE", - "NUDGE_UP_LARGE", - "NUDGE_DOWN_LARGE", - ], + labelKey: 'projects.settings.hotkeys.items.nudge10px', + keys: ['NUDGE_LEFT_LARGE', 'NUDGE_RIGHT_LARGE', 'NUDGE_UP_LARGE', 'NUDGE_DOWN_LARGE'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.tools.title", - blurbKey: "projects.settings.hotkeys.sections.tools.blurb", + titleKey: 'projects.settings.hotkeys.sections.tools.title', + blurbKey: 'projects.settings.hotkeys.sections.tools.blurb', items: [ { - labelKey: "projects.settings.hotkeys.items.selectionTool", - keys: ["SELECTION_TOOL"], - }, - { - labelKey: "projects.settings.hotkeys.items.trimEditTool", - keys: ["TRIM_EDIT_TOOL"], + labelKey: 'projects.settings.hotkeys.items.selectionTool', + keys: ['SELECTION_TOOL'], }, { - labelKey: "projects.settings.hotkeys.items.razorTool", - keys: ["RAZOR_TOOL"], + labelKey: 'projects.settings.hotkeys.items.trimEditTool', + keys: ['TRIM_EDIT_TOOL'], }, { - labelKey: "projects.settings.hotkeys.items.splitAtCursor", - keys: ["SPLIT_AT_CURSOR"], + labelKey: 'projects.settings.hotkeys.items.razorTool', + keys: ['RAZOR_TOOL'], }, { - labelKey: "projects.settings.hotkeys.items.rateStretchTool", - keys: ["RATE_STRETCH_TOOL"], + labelKey: 'projects.settings.hotkeys.items.rateStretchTool', + keys: ['RATE_STRETCH_TOOL'], }, { - labelKey: "projects.settings.hotkeys.items.slipTool", - keys: ["SLIP_TOOL"], + labelKey: 'projects.settings.hotkeys.items.slipTool', + keys: ['SLIP_TOOL'], }, { - labelKey: "projects.settings.hotkeys.items.slideTool", - keys: ["SLIDE_TOOL"], + labelKey: 'projects.settings.hotkeys.items.slideTool', + keys: ['SLIDE_TOOL'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.historyAndUi.title", - blurbKey: "projects.settings.hotkeys.sections.historyAndUi.blurb", + titleKey: 'projects.settings.hotkeys.sections.historyAndUi.title', + blurbKey: 'projects.settings.hotkeys.sections.historyAndUi.blurb', items: [ - { labelKey: "projects.settings.hotkeys.items.undo", keys: ["UNDO"] }, - { labelKey: "projects.settings.hotkeys.items.redo", keys: ["REDO"] }, - { labelKey: "projects.settings.hotkeys.items.zoomIn", keys: ["ZOOM_IN"] }, + { labelKey: 'projects.settings.hotkeys.items.undo', keys: ['UNDO'] }, + { labelKey: 'projects.settings.hotkeys.items.redo', keys: ['REDO'] }, + { labelKey: 'projects.settings.hotkeys.items.zoomIn', keys: ['ZOOM_IN'] }, { - labelKey: "projects.settings.hotkeys.items.zoomOut", - keys: ["ZOOM_OUT"], + labelKey: 'projects.settings.hotkeys.items.zoomOut', + keys: ['ZOOM_OUT'], }, { - labelKey: "projects.settings.hotkeys.items.zoomToFit", - keys: ["ZOOM_TO_FIT"], + labelKey: 'projects.settings.hotkeys.items.zoomToFit', + keys: ['ZOOM_TO_FIT'], }, { - labelKey: "projects.settings.hotkeys.items.zoomTo100", - keys: ["ZOOM_TO_100", "ZOOM_TO_100_ALT"], + labelKey: 'projects.settings.hotkeys.items.zoomTo100', + keys: ['ZOOM_TO_100', 'ZOOM_TO_100_ALT'], }, { - labelKey: "projects.settings.hotkeys.items.toggleSnap", - keys: ["TOGGLE_SNAP"], + labelKey: 'projects.settings.hotkeys.items.toggleSnap', + keys: ['TOGGLE_SNAP'], }, { - labelKey: "projects.settings.hotkeys.items.toggleCanvasSnap", - keys: ["TOGGLE_CANVAS_SNAP"], + labelKey: 'projects.settings.hotkeys.items.toggleCanvasSnap', + keys: ['TOGGLE_CANVAS_SNAP'], }, { - labelKey: "projects.settings.hotkeys.items.editWorkspace", - keys: ["WORKSPACE_EDIT"], + labelKey: 'projects.settings.hotkeys.items.editWorkspace', + keys: ['WORKSPACE_EDIT'], }, { - labelKey: "projects.settings.hotkeys.items.colorWorkspace", - keys: ["WORKSPACE_COLOR"], + labelKey: 'projects.settings.hotkeys.items.colorWorkspace', + keys: ['WORKSPACE_COLOR'], }, { - labelKey: "toolbar.workspaces.motion", - keys: ["WORKSPACE_ANIMATE"], + labelKey: 'toolbar.workspaces.motion', + keys: ['WORKSPACE_ANIMATE'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.clipboard.title", - blurbKey: "projects.settings.hotkeys.sections.clipboard.blurb", + titleKey: 'projects.settings.hotkeys.sections.clipboard.title', + blurbKey: 'projects.settings.hotkeys.sections.clipboard.blurb', items: [ - { labelKey: "projects.settings.hotkeys.items.copy", keys: ["COPY"] }, - { labelKey: "projects.settings.hotkeys.items.cut", keys: ["CUT"] }, - { labelKey: "projects.settings.hotkeys.items.paste", keys: ["PASTE"] }, + { labelKey: 'projects.settings.hotkeys.items.copy', keys: ['COPY'] }, + { labelKey: 'projects.settings.hotkeys.items.cut', keys: ['CUT'] }, + { labelKey: 'projects.settings.hotkeys.items.paste', keys: ['PASTE'] }, ], }, { - titleKey: "projects.settings.hotkeys.sections.markers.title", - blurbKey: "projects.settings.hotkeys.sections.markers.blurb", + titleKey: 'projects.settings.hotkeys.sections.markers.title', + blurbKey: 'projects.settings.hotkeys.sections.markers.blurb', items: [ { - labelKey: "projects.settings.hotkeys.items.addMarker", - keys: ["ADD_MARKER"], + labelKey: 'projects.settings.hotkeys.items.addMarker', + keys: ['ADD_MARKER'], }, { - labelKey: "projects.settings.hotkeys.items.removeMarker", - keys: ["REMOVE_MARKER"], + labelKey: 'projects.settings.hotkeys.items.removeMarker', + keys: ['REMOVE_MARKER'], }, { - labelKey: "projects.settings.hotkeys.items.previousMarker", - keys: ["PREVIOUS_MARKER"], + labelKey: 'projects.settings.hotkeys.items.previousMarker', + keys: ['PREVIOUS_MARKER'], }, { - labelKey: "projects.settings.hotkeys.items.nextMarker", - keys: ["NEXT_MARKER"], + labelKey: 'projects.settings.hotkeys.items.nextMarker', + keys: ['NEXT_MARKER'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.keyframes.title", - blurbKey: "projects.settings.hotkeys.sections.keyframes.blurb", - scopeKey: "projects.settings.hotkeys.scopes.keyframes", + titleKey: 'projects.settings.hotkeys.sections.keyframes.title', + blurbKey: 'projects.settings.hotkeys.sections.keyframes.blurb', + scopeKey: 'projects.settings.hotkeys.scopes.keyframes', items: [ { - labelKey: "projects.settings.hotkeys.items.clearKeyframes", - keys: ["CLEAR_KEYFRAMES"], + labelKey: 'projects.settings.hotkeys.items.clearKeyframes', + keys: ['CLEAR_KEYFRAMES'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeEditorGraph", - keys: ["KEYFRAME_EDITOR_GRAPH"], + labelKey: 'projects.settings.hotkeys.items.keyframeEditorGraph', + keys: ['KEYFRAME_EDITOR_GRAPH'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeEditorDopesheet", - keys: ["KEYFRAME_EDITOR_DOPESHEET"], + labelKey: 'projects.settings.hotkeys.items.keyframeEditorDopesheet', + keys: ['KEYFRAME_EDITOR_DOPESHEET'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeEditorSplit", - keys: ["KEYFRAME_EDITOR_SPLIT"], + labelKey: 'projects.settings.hotkeys.items.keyframeEditorSplit', + keys: ['KEYFRAME_EDITOR_SPLIT'], }, { - labelKey: "projects.settings.hotkeys.items.editKeyframeAdd", - keys: ["EDIT_KEYFRAME_ADD"], + labelKey: 'projects.settings.hotkeys.items.editKeyframeAdd', + keys: ['EDIT_KEYFRAME_ADD'], }, { - labelKey: "projects.settings.hotkeys.items.keyframePrevious", - keys: ["KEYFRAME_PREVIOUS"], + labelKey: 'projects.settings.hotkeys.items.keyframePrevious', + keys: ['KEYFRAME_PREVIOUS'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeNext", - keys: ["KEYFRAME_NEXT"], + labelKey: 'projects.settings.hotkeys.items.keyframeNext', + keys: ['KEYFRAME_NEXT'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeToggleAuto", - keys: ["KEYFRAME_TOGGLE_AUTO"], + labelKey: 'projects.settings.hotkeys.items.keyframeToggleAuto', + keys: ['KEYFRAME_TOGGLE_AUTO'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeFit", - keys: ["KEYFRAME_FIT"], + labelKey: 'projects.settings.hotkeys.items.keyframeFit', + keys: ['KEYFRAME_FIT'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.sourceMonitor.title", - blurbKey: "projects.settings.hotkeys.sections.sourceMonitor.blurb", - scopeKey: "projects.settings.hotkeys.scopes.sourceMonitor", + titleKey: 'projects.settings.hotkeys.sections.sourceMonitor.title', + blurbKey: 'projects.settings.hotkeys.sections.sourceMonitor.blurb', + scopeKey: 'projects.settings.hotkeys.scopes.sourceMonitor', items: [ - { labelKey: "projects.settings.hotkeys.items.markIn", keys: ["MARK_IN"] }, + { labelKey: 'projects.settings.hotkeys.items.markIn', keys: ['MARK_IN'] }, { - labelKey: "projects.settings.hotkeys.items.markOut", - keys: ["MARK_OUT"], + labelKey: 'projects.settings.hotkeys.items.markOut', + keys: ['MARK_OUT'], }, { - labelKey: "projects.settings.hotkeys.items.clearInOut", - keys: ["CLEAR_IN_OUT"], + labelKey: 'projects.settings.hotkeys.items.clearInOut', + keys: ['CLEAR_IN_OUT'], }, { - labelKey: "projects.settings.hotkeys.items.insertEdit", - keys: ["INSERT_EDIT"], + labelKey: 'projects.settings.hotkeys.items.insertEdit', + keys: ['INSERT_EDIT'], }, { - labelKey: "projects.settings.hotkeys.items.overwriteEdit", - keys: ["OVERWRITE_EDIT"], + labelKey: 'projects.settings.hotkeys.items.overwriteEdit', + keys: ['OVERWRITE_EDIT'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.project.title", - blurbKey: "projects.settings.hotkeys.sections.project.blurb", + titleKey: 'projects.settings.hotkeys.sections.project.title', + blurbKey: 'projects.settings.hotkeys.sections.project.blurb', items: [ { - labelKey: "projects.settings.hotkeys.items.saveProject", - keys: ["SAVE"], + labelKey: 'projects.settings.hotkeys.items.saveProject', + keys: ['SAVE'], }, { - labelKey: "projects.settings.hotkeys.items.exportVideo", - keys: ["EXPORT"], + labelKey: 'projects.settings.hotkeys.items.exportVideo', + keys: ['EXPORT'], }, { - labelKey: "projects.settings.hotkeys.items.openSceneBrowser", - keys: ["OPEN_SCENE_BROWSER"], + labelKey: 'projects.settings.hotkeys.items.openSceneBrowser', + keys: ['OPEN_SCENE_BROWSER'], }, ], }, -] as const; +] as const diff --git a/src/features/timeline/components/timeline-header.test.tsx b/src/features/timeline/components/timeline-header.test.tsx index edf6e090b..9110dd176 100644 --- a/src/features/timeline/components/timeline-header.test.tsx +++ b/src/features/timeline/components/timeline-header.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { ZOOM_MAX, ZOOM_MIN } from '../constants' import { useZoomStore } from '../stores/zoom-store' import { useSelectionStore } from '@/shared/state/selection' +import { useSettingsStore } from '@/features/timeline/deps/settings' import { TimelineHeader } from './timeline-header' const { micRenderSpy, sliderRenderSpy, sliderInput } = vi.hoisted(() => ({ @@ -91,6 +92,7 @@ describe('TimelineHeader zoom slider', () => { micRenderSpy.mockClear() sliderRenderSpy.mockClear() sliderInput.value = 0.75 + useSettingsStore.getState().resetHotkeys() useZoomStore.getState().setZoomLevelSynchronized(1) useSelectionStore.setState({ selectedItemIds: [], @@ -327,4 +329,33 @@ describe('TimelineHeader zoom slider', () => { 'true', ) }) + + it('shows resolved tool, split, ripple-trim, and rolling-trim shortcuts', () => { + useSettingsStore.getState().replaceHotkeyOverrides({ + SELECTION_TOOL: 'q', + TRIM_EDIT_TOOL: 'w', + RAZOR_TOOL: 'e', + SPLIT_AT_PLAYHEAD: 'shift+x', + RATE_STRETCH_TOOL: 'd', + }) + + render() + + expect(screen.getByRole('button', { name: 'Select Tool (Q)' })).toHaveAttribute( + 'data-tooltip', + 'Select Tool (Q)', + ) + expect( + screen.getByRole('button', { + name: 'Trim Edit Tool (W) · Ripple: Shift-drag · Roll: Alt-drag', + }), + ).toHaveAttribute('data-tooltip', 'Trim Edit Tool (W) · Ripple: Shift-drag · Roll: Alt-drag') + expect( + screen.getByRole('button', { name: 'Razor Tool (E) · Split: Shift + X' }), + ).toHaveAttribute('data-tooltip', 'Razor Tool (E) · Split: Shift + X') + expect(screen.getByRole('button', { name: 'Rate Stretch Tool (D)' })).toHaveAttribute( + 'data-tooltip', + 'Rate Stretch Tool (D)', + ) + }) }) diff --git a/src/features/timeline/components/timeline-header.tsx b/src/features/timeline/components/timeline-header.tsx index f2a704279..e03552f5e 100644 --- a/src/features/timeline/components/timeline-header.tsx +++ b/src/features/timeline/components/timeline-header.tsx @@ -59,6 +59,11 @@ function TrimEditIcon({ className }: { className?: string }) { ) } +function labelWithShortcut(label: string, binding: string): string { + const shortcut = formatHotkeyBinding(binding) + return shortcut ? `${label} (${shortcut})` : label +} + const InlineKeyframesToggle = memo(function InlineKeyframesToggle({ isOpen, onToggle, @@ -449,9 +454,6 @@ export const TimelineHeader = memo(function TimelineHeader({ const { t } = useTranslation() const hostMode = useEditorStore((s) => s.hostMode) const hotkeys = useResolvedHotkeys() - const razorShortcut = formatHotkeyBinding(hotkeys.RAZOR_TOOL) - const splitAtPlayheadShortcut = formatHotkeyBinding(hotkeys.SPLIT_AT_PLAYHEAD_ALT) - const razorTooltip = `${t('timeline.header.razorToolTooltip')} (${razorShortcut}) · ${t('projects.settings.hotkeys.items.splitAtPlayhead')} (${splitAtPlayheadShortcut})` const snapEnabled = useTimelineStore((s) => s.snapEnabled) const toggleSnap = useTimelineStore((s) => s.toggleSnap) const audioSkimmingEnabled = useTimelineStore((s) => s.audioSkimmingEnabled) @@ -486,6 +488,30 @@ export const TimelineHeader = memo(function TimelineHeader({ width: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, } as const + const selectToolTooltip = labelWithShortcut( + t('timeline.header.selectToolTooltip'), + hotkeys.SELECTION_TOOL, + ) + const trimEditToolTooltip = [ + labelWithShortcut(t('timeline.header.trimEditToolTooltip'), hotkeys.TRIM_EDIT_TOOL), + t('timeline.header.rippleTrimHint', { modifier: formatHotkeyBinding('shift') }), + t('timeline.header.rollingTrimHint', { modifier: formatHotkeyBinding('alt') }), + ].join(' · ') + const razorToolTooltipParts = [ + labelWithShortcut(t('timeline.header.razorToolTooltip'), hotkeys.RAZOR_TOOL), + ] + const razorShortcut = formatHotkeyBinding(hotkeys.RAZOR_TOOL) + const splitAtPlayheadShortcut = formatHotkeyBinding(hotkeys.SPLIT_AT_PLAYHEAD) + if (splitAtPlayheadShortcut) { + razorToolTooltipParts.push( + t('timeline.header.splitAtPlayheadHint', { shortcut: splitAtPlayheadShortcut }), + ) + } + const razorToolTooltip = razorToolTooltipParts.join(' · ') + const rateStretchToolTooltip = labelWithShortcut( + t('timeline.header.rateStretchToolTooltip'), + hotkeys.RATE_STRETCH_TOOL, + ) const handleUndo = () => { useTimelineStore.temporal.getState().undo() @@ -525,8 +551,8 @@ export const TimelineHeader = memo(function TimelineHeader({ : '' } onClick={() => setActiveTool('select')} - aria-label={t('timeline.header.selectTool')} - data-tooltip={t('timeline.header.selectToolTooltip')} + aria-label={selectToolTooltip} + data-tooltip={selectToolTooltip} > @@ -541,8 +567,8 @@ export const TimelineHeader = memo(function TimelineHeader({ : '' } onClick={() => setActiveTool(activeTool === 'trim-edit' ? 'select' : 'trim-edit')} - aria-label={t('timeline.header.trimEditTool')} - data-tooltip={t('timeline.header.trimEditToolTooltip')} + aria-label={trimEditToolTooltip} + data-tooltip={trimEditToolTooltip} > @@ -557,9 +583,9 @@ export const TimelineHeader = memo(function TimelineHeader({ : '' } onClick={() => setActiveTool(activeTool === 'razor' ? 'select' : 'razor')} - aria-label={`${t('timeline.header.razorTool')} (${razorShortcut})`} + aria-label={razorToolTooltip} aria-keyshortcuts={razorShortcut} - data-tooltip={razorTooltip} + data-tooltip={razorToolTooltip} > @@ -577,8 +603,8 @@ export const TimelineHeader = memo(function TimelineHeader({ onClick={() => setActiveTool(activeTool === 'rate-stretch' ? 'select' : 'rate-stretch') } - aria-label={t('timeline.header.rateStretchTool')} - data-tooltip={t('timeline.header.rateStretchToolTooltip')} + aria-label={rateStretchToolTooltip} + data-tooltip={rateStretchToolTooltip} > diff --git a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx index 2de34bbd1..c67971c01 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx @@ -47,11 +47,13 @@ vi.mock('@/features/timeline/deps/analysis', () => ({ })) vi.mock('@/features/timeline/deps/settings', () => ({ - useResolvedHotkeys: () => ({}), + useResolvedHotkeys: () => ({ + RIPPLE_DELETE: 'mod+backspace', + }), })) vi.mock('@/config/hotkeys', () => ({ - formatHotkeyBinding: () => '', + formatHotkeyBinding: (binding: string) => (binding === 'mod+backspace' ? 'Ctrl + Backspace' : ''), })) function renderContextMenu(overrides: Partial> = {}) { @@ -121,6 +123,12 @@ describe('ItemContextMenu scene detection', () => { expect(screen.getByRole('button', { name: 'AI (Liquid Vision)' })).toBeInTheDocument() }) + it('shows the resolved ripple-delete keycap', () => { + renderContextMenu() + + expect(screen.getByText('Ctrl + Backspace')).toBeInTheDocument() + }) + it('dispatches the selected verification model when a scene detection option is clicked', () => { const { onDetectScenes } = renderContextMenu() diff --git a/src/features/timeline/components/timeline-item/item-context-menu.tsx b/src/features/timeline/components/timeline-item/item-context-menu.tsx index 8c087f571..daca97a06 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.tsx @@ -705,6 +705,7 @@ function CompositionActions({ function DestructiveActions({ t, + hotkeys, isSelected, canRippleDelete = true, canDelete = true, @@ -720,7 +721,7 @@ function DestructiveActions({ className="text-destructive focus:text-destructive" > {t('timeline.contextMenu.rippleDelete')} - Ctrl+Del + {formatHotkeyBinding(hotkeys.RIPPLE_DELETE)} )} {canDelete && ( diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx index 87abbe379..3e0190139 100644 --- a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx @@ -1,160 +1,135 @@ -import { act, render } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { HOTKEYS } from '@/config/hotkeys' +import { useSettingsStore } from '@/features/timeline/deps/settings' +import { usePlaybackStore } from '@/shared/state/playback' +import { useSourcePlayerStore } from '@/shared/state/source-player' import type { SourcePlayerMethods } from '@/shared/state/source-player/types' -import type { VideoItem } from '@/types/timeline' import { usePlaybackShortcuts } from './use-playback-shortcuts' -const { itemsState, playbackState, sourcePlayerState, useHotkeysMock } = vi.hoisted(() => { - const playbackState = { - currentFrame: 0, - isPlaying: false, - togglePlayPause: vi.fn(), - shuttleForward: vi.fn(), - shuttleReverse: vi.fn(), - pause: vi.fn(), - setCurrentFrame: vi.fn((frame: number) => { - playbackState.currentFrame = frame - }), - setPreviewFrame: vi.fn(), - } - - return { - itemsState: { items: [] as Array<{ from: number; durationInFrames: number }> }, - playbackState, - sourcePlayerState: { - hoveredPanel: null as 'source' | null, - playerMethods: null as SourcePlayerMethods | null, - }, - useHotkeysMock: vi.fn(), - } -}) +function PlaybackShortcutHarness() { + usePlaybackShortcuts({}) + return +} -vi.mock('react-hotkeys-hook', () => ({ - useHotkeys: useHotkeysMock, -})) - -vi.mock('@/features/timeline/deps/settings', () => ({ - useResolvedHotkeys: () => ({ - PLAY_PAUSE: 'space', - PREVIOUS_FRAME: 'left', - NEXT_FRAME: 'right', - GO_TO_START: 'home', - GO_TO_END: 'end', - NEXT_SNAP_POINT: 'down', - PREVIOUS_SNAP_POINT: 'up', - }), -})) - -vi.mock('@/shared/state/playback', () => ({ - usePlaybackStore: Object.assign( - (selector: (state: typeof playbackState) => unknown) => selector(playbackState), - { getState: () => playbackState }, - ), -})) - -vi.mock('@/shared/state/preview-bridge', () => ({ - usePreviewBridgeStore: (selector: (state: { setDisplayedFrame: () => void }) => unknown) => - selector({ setDisplayedFrame: vi.fn() }), -})) - -vi.mock('@/shared/state/source-player', () => ({ - useSourcePlayerStore: { - getState: () => sourcePlayerState, - }, -})) - -vi.mock('../../stores/items-store', () => ({ - useItemsStore: { - getState: () => itemsState, - }, -})) - -type HotkeyCallback = (event: { preventDefault: () => void }) => void - -function makeVideoItem(overrides: Partial = {}): VideoItem { +function sourcePlayerMethods(): SourcePlayerMethods { return { - id: 'clip-1', - type: 'video', - trackId: 'track-1', - from: 10, - durationInFrames: 5, - label: 'Clip', - src: 'clip.mp4', - ...overrides, + toggle: vi.fn(), + pause: vi.fn(), + isPlaying: vi.fn(() => true), + shuttleForward: vi.fn(), + shuttleReverse: vi.fn(), + seek: vi.fn(), + frameBack: vi.fn(), + frameForward: vi.fn(), + getDurationInFrames: vi.fn(() => 300), } } -function ShortcutHarness() { - usePlaybackShortcuts({}) - return null -} +describe('usePlaybackShortcuts transport routing', () => { + beforeEach(() => { + useSettingsStore.getState().resetHotkeys() + usePlaybackStore.setState({ + isPlaying: false, + playbackRate: 1, + transportMode: 'normal', + currentFrame: 0, + previewFrame: null, + previewItemId: null, + }) + useSourcePlayerStore.setState({ + hoveredPanel: null, + playerMethods: null, + }) + }) -function getHotkeyCallback(binding: string): HotkeyCallback { - const registration = useHotkeysMock.mock.calls.find(([keys]) => keys === binding) - expect(registration).toBeDefined() - return registration?.[1] as HotkeyCallback -} + it('routes J, K, and L to reverse, pause, and forward program transport', () => { + render() + + fireEvent.keyDown(document, { key: 'l', code: 'KeyL' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: 1, + transportMode: 'shuttle', + }) + + fireEvent.keyDown(document, { key: 'k', code: 'KeyK' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: false, + playbackRate: 1, + transportMode: 'normal', + }) + + fireEvent.keyDown(document, { key: 'j', code: 'KeyJ' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: -1, + transportMode: 'shuttle', + }) + }) -function trigger(callback: HotkeyCallback) { - act(() => callback({ preventDefault: vi.fn() })) -} + it('claims K as pause even when program transport is already paused', () => { + render() -describe('usePlaybackShortcuts frame boundaries', () => { - beforeEach(() => { - vi.clearAllMocks() - playbackState.currentFrame = 0 - playbackState.isPlaying = false - itemsState.items = [ - makeVideoItem(), - makeVideoItem({ id: 'clip-2', from: 0, durationInFrames: 7 }), - ] - sourcePlayerState.hoveredPanel = null - sourcePlayerState.playerMethods = null + expect(fireEvent.keyDown(document, { key: 'k', code: 'KeyK' })).toBe(false) + expect(usePlaybackStore.getState().isPlaying).toBe(false) }) - it('clamps timeline ArrowRight to the final valid frame', () => { - playbackState.currentFrame = 13 - render() + it('routes J, K, and L to the source monitor while it is hovered', () => { + const playerMethods = sourcePlayerMethods() + useSourcePlayerStore.setState({ hoveredPanel: 'source', playerMethods }) + render() - const nextFrame = getHotkeyCallback(HOTKEYS.NEXT_FRAME) - trigger(nextFrame) - expect(playbackState.currentFrame).toBe(14) + fireEvent.keyDown(document, { key: 'j', code: 'KeyJ' }) + fireEvent.keyDown(document, { key: 'k', code: 'KeyK' }) + fireEvent.keyDown(document, { key: 'l', code: 'KeyL' }) - trigger(nextFrame) - expect(playbackState.currentFrame).toBe(14) + expect(playerMethods.shuttleReverse).toHaveBeenCalledTimes(1) + expect(playerMethods.pause).toHaveBeenCalledTimes(1) + expect(playerMethods.shuttleForward).toHaveBeenCalledTimes(1) + expect(usePlaybackStore.getState().isPlaying).toBe(false) }) - it('seeks timeline End to the maximum inclusive item frame, or zero when empty', () => { - render() + it('protects editable fields from transport shortcuts', () => { + render() + const input = screen.getByRole('textbox', { name: 'Editable title' }) - const goToEnd = getHotkeyCallback(HOTKEYS.GO_TO_END) - trigger(goToEnd) - expect(playbackState.currentFrame).toBe(14) + fireEvent.keyDown(input, { key: 'j', code: 'KeyJ' }) + fireEvent.keyDown(input, { key: 'k', code: 'KeyK' }) + fireEvent.keyDown(input, { key: 'l', code: 'KeyL' }) - itemsState.items = [] - trigger(goToEnd) - expect(playbackState.currentFrame).toBe(0) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: false, + playbackRate: 1, + transportMode: 'normal', + }) }) - it('clamps source-player End to a nonnegative frame', () => { - const playerMethods: SourcePlayerMethods = { - toggle: vi.fn(), - pause: vi.fn(), - isPlaying: vi.fn(() => false), - shuttleForward: vi.fn(), - shuttleReverse: vi.fn(), - seek: vi.fn(), - frameBack: vi.fn(), - frameForward: vi.fn(), - getDurationInFrames: vi.fn(() => 0), - } - sourcePlayerState.hoveredPanel = 'source' - sourcePlayerState.playerMethods = playerMethods - render() - - trigger(getHotkeyCallback(HOTKEYS.GO_TO_END)) - - expect(playerMethods.seek).toHaveBeenCalledWith(0) + it('routes customized transport bindings instead of the defaults', () => { + useSettingsStore.getState().replaceHotkeyOverrides({ + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', + }) + render() + + fireEvent.keyDown(document, { key: 'l', code: 'KeyL' }) + expect(usePlaybackStore.getState().isPlaying).toBe(false) + + fireEvent.keyDown(document, { key: 'e', code: 'KeyE' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: 1, + transportMode: 'shuttle', + }) + + fireEvent.keyDown(document, { key: 'w', code: 'KeyW' }) + expect(usePlaybackStore.getState().isPlaying).toBe(false) + + fireEvent.keyDown(document, { key: 'q', code: 'KeyQ' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: -1, + transportMode: 'shuttle', + }) }) }) diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts index 5dbccd54b..b9ac22cb0 100644 --- a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts @@ -80,10 +80,10 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { [togglePlayPause, isPlaying, callbacks], ) - // Shuttle: L advances forward through 1x, 2x, and 4x. Ignore browser key + // Shuttle forward advances through 1x, 2x, and 4x. Ignore browser key // repeat so one physical press produces one transport transition. useHotkeys( - 'l', + hotkeys.SHUTTLE_FORWARD, (event) => { if (event.repeat) return event.preventDefault() @@ -102,10 +102,10 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { [callbacks, shuttleForward], ) - // Shuttle: J mirrors L in reverse. Browser media stays on a paused visual + // Shuttle reverse mirrors forward playback. Browser media stays on a paused visual // seek path for negative rates; the Clock still advances at display cadence. useHotkeys( - 'j', + hotkeys.SHUTTLE_REVERSE, (event) => { if (event.repeat) return event.preventDefault() @@ -124,25 +124,24 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { [callbacks, shuttleReverse], ) - // K owns pause only while a transport is active. When already paused it - // yields to the existing Edit keyframe shortcut. + // Pause always owns its binding, including while already paused, so transport + // routing cannot fall through to another command. useHotkeys( - 'k', + hotkeys.SHUTTLE_PAUSE, (event) => { if (event.repeat) return + event.preventDefault() + event.stopPropagation() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() if (hoveredPanel === 'source' && playerMethods) { - if (!playerMethods.isPlaying()) return - event.preventDefault() - event.stopPropagation() playerMethods.pause() return } - if (!usePlaybackStore.getState().isPlaying) return - event.preventDefault() - event.stopPropagation() + const wasPlaying = usePlaybackStore.getState().isPlaying pause() - callbacks.onPause?.() + if (wasPlaying) { + callbacks.onPause?.() + } }, { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } }, [callbacks, pause], diff --git a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts index 9dc5a4c1c..27170fa2e 100644 --- a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts @@ -1,5 +1,5 @@ /** - * Tool shortcuts: V (Select), T (Trim Edit), C (Razor), Shift+C (Split at cursor), R (Rate Stretch). + * Tool shortcuts: V (Select), T (Trim Edit), C (Razor), Shift+C (Split at playhead), R (Rate Stretch). */ import { useHotkeys } from 'react-hotkeys-hook' @@ -51,7 +51,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: Shift+C - Split hovered item at gray playhead (or main playhead) useHotkeys( - hotkeys.SPLIT_AT_CURSOR, + hotkeys.SPLIT_AT_PLAYHEAD, (event) => { event.preventDefault() const { previewFrame, previewItemId, currentFrame } = usePlaybackStore.getState() diff --git a/src/features/timeline/hooks/use-host-timeline-shortcuts.test.tsx b/src/features/timeline/hooks/use-host-timeline-shortcuts.test.tsx index 3e2518986..cc5bee457 100644 --- a/src/features/timeline/hooks/use-host-timeline-shortcuts.test.tsx +++ b/src/features/timeline/hooks/use-host-timeline-shortcuts.test.tsx @@ -130,6 +130,25 @@ describe('useHostTimelineShortcuts', () => { expect(useTimelineStore.getState().items).toHaveLength(0) }) + it('splits the hovered clip at the playhead on Shift+C', () => { + usePlaybackStore.setState({ + currentFrame: 15, + previewFrame: null, + previewItemId: 'clip-1', + }) + render() + + fireEvent.keyDown(document, { key: 'C', code: 'KeyC', shiftKey: true }) + + expect(useTimelineStore.getState().items).toHaveLength(2) + expect(useTimelineStore.getState().items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'clip-1', from: 0, durationInFrames: 15 }), + expect.objectContaining({ from: 15, durationInFrames: 15 }), + ]), + ) + }) + it('does not undo timeline edits on Mod+Z in host mode', () => { useTimelineStore.getState().moveItem('clip-1', 30) expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) diff --git a/src/i18n/locales/partials/de/projects.json b/src/i18n/locales/partials/de/projects.json index 5e76a71c0..de56958f6 100644 --- a/src/i18n/locales/partials/de/projects.json +++ b/src/i18n/locales/partials/de/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Wiedergabe/Pause", + "shuttleReverse": "Rückwärtswiedergabe", + "shuttlePause": "Transport pausieren", + "shuttleForward": "Vorwärtswiedergabe", "previousFrame": "Vorheriges Bild", "nextFrame": "Nächstes Bild", "goToStart": "Zum Anfang gehen", diff --git a/src/i18n/locales/partials/de/timeline.json b/src/i18n/locales/partials/de/timeline.json index 2aa1cb918..15bfdb50d 100644 --- a/src/i18n/locales/partials/de/timeline.json +++ b/src/i18n/locales/partials/de/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "Verknüpfte Auswahl: {{state}} ({{shortcut}})", "rateStretchTool": "Rate Strecken Werkzeug", "rateStretchToolTooltip": "Rate Strecken Werkzeug", + "rippleTrimHint": "Ripple: {{modifier}}-Ziehen", + "rollingTrimHint": "Rollen: {{modifier}}-Ziehen", + "splitAtPlayheadHint": "Teilen: {{shortcut}}", "razorTool": "Rasiermesser Werkzeug", "razorToolTooltip": "Rasiermesser Werkzeug", "redo": "Wiederholen", diff --git a/src/i18n/locales/partials/en/projects.json b/src/i18n/locales/partials/en/projects.json index 900737ba0..1eaa80d17 100644 --- a/src/i18n/locales/partials/en/projects.json +++ b/src/i18n/locales/partials/en/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Play/Pause", + "shuttleReverse": "Shuttle reverse", + "shuttlePause": "Pause transport", + "shuttleForward": "Shuttle forward", "previousFrame": "Previous frame", "nextFrame": "Next frame", "goToStart": "Go to start", diff --git a/src/i18n/locales/partials/en/timeline.json b/src/i18n/locales/partials/en/timeline.json index 9c7cd3037..afa1b9cc6 100644 --- a/src/i18n/locales/partials/en/timeline.json +++ b/src/i18n/locales/partials/en/timeline.json @@ -159,6 +159,9 @@ "linkedSelectionTooltip": "Linked selection: {{state}} ({{shortcut}})", "rateStretchTool": "Rate Stretch Tool", "rateStretchToolTooltip": "Rate Stretch Tool", + "rippleTrimHint": "Ripple: {{modifier}}-drag", + "rollingTrimHint": "Roll: {{modifier}}-drag", + "splitAtPlayheadHint": "Split: {{shortcut}}", "razorTool": "Razor Tool", "razorToolTooltip": "Razor Tool", "redo": "Redo", diff --git a/src/i18n/locales/partials/es/projects.json b/src/i18n/locales/partials/es/projects.json index 2fbda2df9..917df419d 100644 --- a/src/i18n/locales/partials/es/projects.json +++ b/src/i18n/locales/partials/es/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Reproducir/Pausar", + "shuttleReverse": "Reproducción inversa", + "shuttlePause": "Pausar transporte", + "shuttleForward": "Reproducción hacia delante", "previousFrame": "Fotograma anterior", "nextFrame": "Fotograma siguiente", "goToStart": "Ir al inicio", diff --git a/src/i18n/locales/partials/es/timeline.json b/src/i18n/locales/partials/es/timeline.json index ffd360249..63266030b 100644 --- a/src/i18n/locales/partials/es/timeline.json +++ b/src/i18n/locales/partials/es/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "Selección vinculada: {{state}} ({{shortcut}})", "rateStretchTool": "tasa estirar herramienta", "rateStretchToolTooltip": "tasa estirar herramienta", + "rippleTrimHint": "Ripple: arrastrar con {{modifier}}", + "rollingTrimHint": "Rodar: arrastrar con {{modifier}}", + "splitAtPlayheadHint": "Dividir: {{shortcut}}", "razorTool": "cuchilla herramienta", "razorToolTooltip": "cuchilla herramienta", "redo": "Rehacer", diff --git a/src/i18n/locales/partials/fr/projects.json b/src/i18n/locales/partials/fr/projects.json index c922c2204..4d991eeca 100644 --- a/src/i18n/locales/partials/fr/projects.json +++ b/src/i18n/locales/partials/fr/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Lecture/Pause", + "shuttleReverse": "Lecture arrière", + "shuttlePause": "Mettre le transport en pause", + "shuttleForward": "Lecture avant", "previousFrame": "Image précédente", "nextFrame": "Image suivante", "goToStart": "Aller au début", diff --git a/src/i18n/locales/partials/fr/timeline.json b/src/i18n/locales/partials/fr/timeline.json index 5bb415a38..9a1c40766 100644 --- a/src/i18n/locales/partials/fr/timeline.json +++ b/src/i18n/locales/partials/fr/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "Sélection liée : {{state}} ({{shortcut}})", "rateStretchTool": "vitesse etirer outil", "rateStretchToolTooltip": "vitesse etirer outil", + "rippleTrimHint": "Ripple : {{modifier}}-glisser", + "rollingTrimHint": "Roll : {{modifier}}-glisser", + "splitAtPlayheadHint": "Scinder : {{shortcut}}", "razorTool": "rasoir outil", "razorToolTooltip": "rasoir outil", "redo": "Retablir", diff --git a/src/i18n/locales/partials/ja/projects.json b/src/i18n/locales/partials/ja/projects.json index 97c4295d6..6a15f0336 100644 --- a/src/i18n/locales/partials/ja/projects.json +++ b/src/i18n/locales/partials/ja/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "再生/一時停止", + "shuttleReverse": "逆方向シャトル", + "shuttlePause": "トランスポートを一時停止", + "shuttleForward": "順方向シャトル", "previousFrame": "前のフレーム", "nextFrame": "次のフレーム", "goToStart": "先頭に移動", diff --git a/src/i18n/locales/partials/ja/timeline.json b/src/i18n/locales/partials/ja/timeline.json index e5584ee17..3c16ff3ce 100644 --- a/src/i18n/locales/partials/ja/timeline.json +++ b/src/i18n/locales/partials/ja/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "リンク選択: {{state}} ({{shortcut}})", "rateStretchTool": "レート調整ツール", "rateStretchToolTooltip": "レート調整ツール", + "rippleTrimHint": "リップル: {{modifier}}+ドラッグ", + "rollingTrimHint": "ロール: {{modifier}}+ドラッグ", + "splitAtPlayheadHint": "分割: {{shortcut}}", "razorTool": "レーザーツール", "razorToolTooltip": "レーザーツール", "redo": "やり直し", diff --git a/src/i18n/locales/partials/ko/projects.json b/src/i18n/locales/partials/ko/projects.json index d55ae52d5..02c7830d5 100644 --- a/src/i18n/locales/partials/ko/projects.json +++ b/src/i18n/locales/partials/ko/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "재생/일시정지", + "shuttleReverse": "역방향 셔틀", + "shuttlePause": "전송 일시 정지", + "shuttleForward": "정방향 셔틀", "previousFrame": "이전 프레임", "nextFrame": "다음 프레임", "goToStart": "처음으로 이동", diff --git a/src/i18n/locales/partials/ko/timeline.json b/src/i18n/locales/partials/ko/timeline.json index c8658b4f9..604bce00d 100644 --- a/src/i18n/locales/partials/ko/timeline.json +++ b/src/i18n/locales/partials/ko/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "연결된 선택: {{state}} ({{shortcut}})", "rateStretchTool": "속도 늘이기 도구", "rateStretchToolTooltip": "속도 늘이기 도구", + "rippleTrimHint": "리플: {{modifier}}+드래그", + "rollingTrimHint": "롤: {{modifier}}+드래그", + "splitAtPlayheadHint": "분할: {{shortcut}}", "razorTool": "자르기 도구", "razorToolTooltip": "자르기 도구", "redo": "다시 실행", diff --git a/src/i18n/locales/partials/pt-BR/projects.json b/src/i18n/locales/partials/pt-BR/projects.json index ef4148de9..dfc1693d8 100644 --- a/src/i18n/locales/partials/pt-BR/projects.json +++ b/src/i18n/locales/partials/pt-BR/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Reproduzir/Pausar", + "shuttleReverse": "Shuttle reverso", + "shuttlePause": "Pausar transporte", + "shuttleForward": "Shuttle para frente", "previousFrame": "Quadro anterior", "nextFrame": "Próximo quadro", "goToStart": "Ir para o início", diff --git a/src/i18n/locales/partials/pt-BR/timeline.json b/src/i18n/locales/partials/pt-BR/timeline.json index 6df049842..0a4175726 100644 --- a/src/i18n/locales/partials/pt-BR/timeline.json +++ b/src/i18n/locales/partials/pt-BR/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "Seleção vinculada: {{state}} ({{shortcut}})", "rateStretchTool": "Ferramenta de esticar taxa", "rateStretchToolTooltip": "Ferramenta de esticar taxa", + "rippleTrimHint": "Ripple: arraste com {{modifier}}", + "rollingTrimHint": "Rolagem: arraste com {{modifier}}", + "splitAtPlayheadHint": "Dividir: {{shortcut}}", "razorTool": "Ferramenta navalha", "razorToolTooltip": "Ferramenta navalha", "redo": "Refazer", diff --git a/src/i18n/locales/partials/tr/projects.json b/src/i18n/locales/partials/tr/projects.json index d5fd71de1..bd9aca174 100644 --- a/src/i18n/locales/partials/tr/projects.json +++ b/src/i18n/locales/partials/tr/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Oynat/Duraklat", + "shuttleReverse": "Geri sarma", + "shuttlePause": "Oynatmayı duraklat", + "shuttleForward": "İleri sarma", "previousFrame": "Önceki kare", "nextFrame": "Sonraki kare", "goToStart": "Başa git", diff --git a/src/i18n/locales/partials/tr/timeline.json b/src/i18n/locales/partials/tr/timeline.json index 3f3f47097..c45a3a93e 100644 --- a/src/i18n/locales/partials/tr/timeline.json +++ b/src/i18n/locales/partials/tr/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "Bağlı seçim: {{state}} ({{shortcut}})", "rateStretchTool": "Hız uzatma aracı", "rateStretchToolTooltip": "Hız uzatma aracı", + "rippleTrimHint": "Ripple: {{modifier}} ile sürükle", + "rollingTrimHint": "Roll: {{modifier}} ile sürükle", + "splitAtPlayheadHint": "Böl: {{shortcut}}", "razorTool": "Kesici aracı", "razorToolTooltip": "Kesici aracı", "redo": "Yinele", diff --git a/src/i18n/locales/partials/zh/projects.json b/src/i18n/locales/partials/zh/projects.json index 8cef84c29..9ea3e9e35 100644 --- a/src/i18n/locales/partials/zh/projects.json +++ b/src/i18n/locales/partials/zh/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "播放/暂停", + "shuttleReverse": "反向穿梭", + "shuttlePause": "暂停传输", + "shuttleForward": "正向穿梭", "previousFrame": "上一帧", "nextFrame": "下一帧", "goToStart": "跳到开头", diff --git a/src/i18n/locales/partials/zh/timeline.json b/src/i18n/locales/partials/zh/timeline.json index cbfd24510..5ffec6364 100644 --- a/src/i18n/locales/partials/zh/timeline.json +++ b/src/i18n/locales/partials/zh/timeline.json @@ -159,6 +159,9 @@ "linkedSelectionTooltip": "联动选择:{{state}} ({{shortcut}})", "rateStretchTool": "速率拉伸工具", "rateStretchToolTooltip": "速率拉伸工具", + "rippleTrimHint": "波纹: {{modifier}}+拖动", + "rollingTrimHint": "滚动: {{modifier}}+拖动", + "splitAtPlayheadHint": "分割: {{shortcut}}", "razorTool": "剃刀工具", "razorToolTooltip": "剃刀工具", "redo": "重做", From bb3e404f137156fd685b9cc3eaac7561253e2168 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:19:29 -0700 Subject: [PATCH 36/64] fix(host): guard shortcut settings ownership epochs (cherry picked from commit fe2bdef322bdb6a036e5e722887738e366efbc7f) --- .../editor/host/shortcut-settings.test.ts | 51 +++++++++++++++++++ src/features/editor/host/shortcut-settings.ts | 47 +++++++++++++++-- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index eb5f3652b..ce3a062c5 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -42,6 +42,7 @@ function createShortcutHost(initial: HostShortcutSettings) { host, setSettings, notify, + listenerCount: () => listeners.size, emit: (settings: HostShortcutSettings) => { for (const listener of listeners) listener(settings) }, @@ -124,4 +125,54 @@ describe('host shortcut settings round trip', () => { PLAY_PAUSE: 'shift+space', }) }) + + it('keeps late hydration from host A inert after host B replaces it', async () => { + let resolveA!: (settings: HostShortcutSettings) => void + const hostA = createShortcutHost(createHostShortcutSettings({ SHUTTLE_REVERSE: 'a' })) + hostA.host.shortcuts!.getSettings = vi.fn( + () => new Promise((resolve) => (resolveA = resolve)), + ) + const mountA = mountHostShortcutSettings(hostA.host) + + const hostB = createShortcutHost(createHostShortcutSettings({ SHUTTLE_REVERSE: 'b' })) + const unmountB = await mountHostShortcutSettings(hostB.host) + resolveA(createHostShortcutSettings({ SHUTTLE_REVERSE: 'a' })) + const unmountA = await mountA + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_REVERSE: 'b' }) + expect(hostA.listenerCount()).toBe(0) + unmountA() + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_REVERSE: 'b' }) + unmountB() + }) + + it('does not execute a queued write after its host is disposed', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const unmount = await mountHostShortcutSettings(host.host) + const pending = Promise.resolve() + host.setSettings.mockReturnValueOnce(pending) + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + unmount() + await Promise.resolve() + expect(host.setSettings).not.toHaveBeenCalled() + }) + + it('drops an older outbound write when newer host input arrives', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const unmount = await mountHostShortcutSettings(host.host) + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'y' })) + await Promise.resolve() + expect(host.setSettings).not.toHaveBeenCalled() + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'y' }) + unmount() + }) + + it('removes the host subscriber on unmount', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const unmount = await mountHostShortcutSettings(host.host) + expect(host.listenerCount()).toBe(1) + unmount() + expect(host.listenerCount()).toBe(0) + }) }) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts index df8cc3ff5..a7685a1f6 100644 --- a/src/features/editor/host/shortcut-settings.ts +++ b/src/features/editor/host/shortcut-settings.ts @@ -20,6 +20,14 @@ function copyOverrides(overrides: HotkeyOverrideMap): HotkeyOverrideMap { return { ...overrides } } +interface ShortcutOwnership { + epoch: number + standaloneOverrides: HotkeyOverrideMap +} + +let nextOwnershipEpoch = 0 +let currentOwnership: ShortcutOwnership | null = null + /** * Hydrates host-owned shortcuts before the editor mounts, then keeps UI and * host/agent changes synchronized for the lifetime of the embedded surface. @@ -30,18 +38,29 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => return () => undefined } - const standaloneOverrides = copyOverrides(useSettingsStore.getState().hotkeyOverrides) + const ownership: ShortcutOwnership = { + epoch: ++nextOwnershipEpoch, + standaloneOverrides: copyOverrides( + currentOwnership?.standaloneOverrides ?? useSettingsStore.getState().hotkeyOverrides, + ), + } + currentOwnership = ownership let applyingHostSettings = false let disposed = false + let inboundRevision = 0 let writeQueue = Promise.resolve() + const isCurrent = () => + !disposed && currentOwnership?.epoch === ownership.epoch + const reportFailure = (message: string) => { host.notify?.({ kind: 'error', message }) } const applyHostSettings = (settings: HostShortcutSettings) => { - if (disposed) return + if (!isCurrent()) return const normalized = normalizeHostShortcutSettings(settings) + inboundRevision += 1 applyingHostSettings = true try { useSettingsStore.getState().replaceHotkeyOverrides(normalized.overrides) @@ -50,9 +69,20 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => } } - applyHostSettings(await Promise.resolve(port.getSettings())) + let initialSettings: HostShortcutSettings + try { + initialSettings = await Promise.resolve(port.getSettings()) + } catch (error) { + if (currentOwnership?.epoch === ownership.epoch) currentOwnership = null + throw error + } + if (!isCurrent()) { + return () => undefined + } + applyHostSettings(initialSettings) const unsubscribeHost = port.subscribe?.((settings) => { + if (!isCurrent()) return try { applyHostSettings(settings) } catch { @@ -70,8 +100,12 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => } const settings = createHostShortcutSettings(copyOverrides(state.hotkeyOverrides)) + const revisionAtQueue = inboundRevision writeQueue = writeQueue - .then(() => Promise.resolve(port.setSettings(settings))) + .then(() => { + if (!isCurrent() || inboundRevision !== revisionAtQueue) return undefined + return Promise.resolve(port.setSettings(settings)) + }) .catch(() => { reportFailure('Could not save keyboard shortcuts to the host.') }) @@ -79,8 +113,11 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => return () => { disposed = true + inboundRevision += 1 unsubscribeStore() unsubscribeHost?.() - useSettingsStore.getState().replaceHotkeyOverrides(standaloneOverrides) + if (currentOwnership?.epoch !== ownership.epoch) return + currentOwnership = null + useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides) } } From ca50c49c3d9e7c893f663e366176c0ab985edda6 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:20:33 -0700 Subject: [PATCH 37/64] fix: make shortcut labels reactive in editor menus (cherry picked from commit 7ebce1a12e181a0ea4fbf87433f7c43d519fae9d) --- .../components/source-monitor.test.tsx | 70 ++++++++++++++++++- .../preview/components/source-monitor.tsx | 50 +++++++------ .../preview/deps/settings-contract.ts | 1 + .../timeline-item/item-context-menu.test.tsx | 11 ++- .../timeline-item/item-context-menu.tsx | 12 ++-- 5 files changed, 116 insertions(+), 28 deletions(-) diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx index 1e3f128c4..512d5931e 100644 --- a/src/features/preview/components/source-monitor.test.tsx +++ b/src/features/preview/components/source-monitor.test.tsx @@ -75,6 +75,21 @@ const clockState = vi.hoisted(() => ({ playbackRate: 1, })) +const resolvedHotkeysState = vi.hoisted(() => ({ + hotkeys: { + MARK_IN: 'i', + MARK_OUT: 'o', + CLEAR_IN_OUT: 'alt+x', + GO_TO_START: 'home', + PREVIOUS_FRAME: 'left', + PLAY_PAUSE: 'space', + NEXT_FRAME: 'right', + GO_TO_END: 'end', + INSERT_EDIT: 'comma', + OVERWRITE_EDIT: 'period', + }, +})) + vi.mock('@/features/preview/deps/player-context', () => ({ PlayerEmitterProvider: ({ children }: { children: ReactNode }) => <>{children}, ClockBridgeProvider: ({ children }: { children: ReactNode }) => <>{children}, @@ -172,7 +187,10 @@ vi.mock('@/features/preview/deps/settings', () => { { getState: () => settingsState }, ) - return { useSettingsStore } + return { + useSettingsStore, + useResolvedHotkeys: () => resolvedHotkeysState.hotkeys, + } }) vi.mock('@/shared/state/editor', () => { @@ -240,6 +258,56 @@ describe('SourceMonitor current media ownership', () => { editorStoreState.sourcePreviewMediaId = 'media-1' clockState.currentFrame = 0 clockState.isPlaying = false + resolvedHotkeysState.hotkeys = { + MARK_IN: 'i', + MARK_OUT: 'o', + CLEAR_IN_OUT: 'alt+x', + GO_TO_START: 'home', + PREVIOUS_FRAME: 'left', + PLAY_PAUSE: 'space', + NEXT_FRAME: 'right', + GO_TO_END: 'end', + INSERT_EDIT: 'comma', + OVERWRITE_EDIT: 'period', + } + }) + + it('updates visible shortcut labels after remap and reset', async () => { + const rendered = render() + + await waitFor(() => expect(rendered.getByLabelText('Mark In (I)')).toBeInTheDocument()) + + resolvedHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + MARK_IN: 'shift+f', + } + rendered.rerender() + expect(rendered.getByLabelText('Mark In (Shift + F)')).toBeInTheDocument() + + resolvedHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + MARK_IN: 'i', + } + rendered.rerender() + expect(rendered.getByLabelText('Mark In (I)')).toBeInTheDocument() + }) + + it('uses macOS modifier names in visible shortcut labels', async () => { + const originalPlatform = navigator.platform + Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel' }) + resolvedHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + CLEAR_IN_OUT: 'alt+x', + } + + try { + const rendered = render() + await waitFor(() => + expect(rendered.getByLabelText('Clear In/Out (Option + X)')).toBeInTheDocument(), + ) + } finally { + Object.defineProperty(navigator, 'platform', { configurable: true, value: originalPlatform }) + } }) it('does not release the current media during the initial Strict Mode remount', async () => { diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index 6791c60c3..a0c7beb06 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -53,7 +53,7 @@ import { } from '../utils/source-io' import { useMediaLibraryStore, getMediaType } from '@/features/preview/deps/media-library' import { useItemsStore } from '@/features/preview/deps/timeline-store' -import { useSettingsStore } from '@/features/preview/deps/settings' +import { useResolvedHotkeys, useSettingsStore } from '@/features/preview/deps/settings' import { useEditorStore } from '@/shared/state/editor' import { useSourcePlayerStore } from '@/shared/state/source-player' import { getNextShuttleRate } from '@/shared/state/playback/shuttle' @@ -71,6 +71,7 @@ import { formatTimecodeCompact } from '@/shared/utils/time-utils' import { getPreviewPixelSnapSize } from '../utils/preview-pixel-snap' import type { TimelineTrack } from '@/types/timeline' import { useBlobUrlEpoch } from '@/infrastructure/browser/blob-url-manager' +import { formatHotkeyBinding } from '@/config/hotkeys' interface SourceMonitorProps { mediaId: string @@ -207,6 +208,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ const [blobUrl, setBlobUrl] = useState('') const media = useMediaLibraryStore((s) => s.mediaById[mediaId]) const blobUrlEpoch = useBlobUrlEpoch(mediaId) + const hotkeys = useResolvedHotkeys() // Sync current media ID into source player store for I/O points useEffect(() => { @@ -552,6 +554,7 @@ function SourceMonitorInner({ hasAudio={hasAudio} interactive={interactive} seekFrame={seekFrame} + hotkeys={hotkeys} />
) @@ -566,6 +569,7 @@ function SourcePlaybackControls({ hasAudio, interactive, seekFrame, + hotkeys, }: { durationInFrames: number fps: number @@ -573,6 +577,7 @@ function SourcePlaybackControls({ hasAudio: boolean interactive: boolean seekFrame: number | null + hotkeys: ReturnType }) { const clock = useClock() const player = usePlayer(durationInFrames) @@ -595,6 +600,7 @@ function SourcePlaybackControls({ const currentTimeRef = useRef(null) const outPointRef = useRef(useSourcePlayerStore.getState().outPoint) const [showFrames, setShowFrames] = useState(false) + const shortcutLabel = (binding: string) => formatHotkeyBinding(binding) const showFramesRef = useRef(showFrames) showFramesRef.current = showFrames @@ -1297,12 +1303,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleMarkIn} - aria-label="Mark In (I)" + aria-label={`Mark In (${shortcutLabel(hotkeys.MARK_IN)})`} > - Mark In (I) + Mark In ({shortcutLabel(hotkeys.MARK_IN)}) @@ -1314,12 +1320,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleMarkOut} - aria-label="Mark Out (O)" + aria-label={`Mark Out (${shortcutLabel(hotkeys.MARK_OUT)})`} > - Mark Out (O) + Mark Out ({shortcutLabel(hotkeys.MARK_OUT)}) @@ -1331,12 +1337,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleClearIO} - aria-label="Clear In/Out (Alt+X)" + aria-label={`Clear In/Out (${shortcutLabel(hotkeys.CLEAR_IN_OUT)})`} > - Clear In/Out (Alt+X) + Clear In/Out ({shortcutLabel(hotkeys.CLEAR_IN_OUT)})
)} @@ -1379,12 +1385,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleGoToStart} - aria-label="Go to start (Home)" + aria-label={`Go to start (${shortcutLabel(hotkeys.GO_TO_START)})`} > - Go to start (Home) + Go to start ({shortcutLabel(hotkeys.GO_TO_START)}) @@ -1396,12 +1402,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleStepBack} - aria-label="Previous frame (Left Arrow)" + aria-label={`Previous frame (${shortcutLabel(hotkeys.PREVIOUS_FRAME)})`} > - Previous frame (Left Arrow) + Previous frame ({shortcutLabel(hotkeys.PREVIOUS_FRAME)}) @@ -1412,7 +1418,7 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleTogglePlayback} - aria-label={playing ? 'Pause (Space)' : 'Play (Space)'} + aria-label={`${playing ? 'Pause' : 'Play'} (${shortcutLabel(hotkeys.PLAY_PAUSE)})`} > {playing ? ( @@ -1421,7 +1427,9 @@ function SourcePlaybackControls({ )} - {playing ? 'Pause' : 'Play'} (Space) + + {playing ? 'Pause' : 'Play'} ({shortcutLabel(hotkeys.PLAY_PAUSE)}) + @@ -1433,12 +1441,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleStepForward} - aria-label="Next frame (Right Arrow)" + aria-label={`Next frame (${shortcutLabel(hotkeys.NEXT_FRAME)})`} > - Next frame (Right Arrow) + Next frame ({shortcutLabel(hotkeys.NEXT_FRAME)}) @@ -1450,12 +1458,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleGoToEnd} - aria-label="Go to end (End)" + aria-label={`Go to end (${shortcutLabel(hotkeys.GO_TO_END)})`} > - Go to end (End) + Go to end ({shortcutLabel(hotkeys.GO_TO_END)})
@@ -1553,12 +1561,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={() => performInsertEdit()} - aria-label="Insert (,)" + aria-label={`Insert (${shortcutLabel(hotkeys.INSERT_EDIT)})`} > - Insert (,) + Insert ({shortcutLabel(hotkeys.INSERT_EDIT)}) @@ -1570,12 +1578,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={() => performOverwriteEdit()} - aria-label="Overwrite (.)" + aria-label={`Overwrite (${shortcutLabel(hotkeys.OVERWRITE_EDIT)})`} > - Overwrite (.) + Overwrite ({shortcutLabel(hotkeys.OVERWRITE_EDIT)})
) : ( diff --git a/src/features/preview/deps/settings-contract.ts b/src/features/preview/deps/settings-contract.ts index 75f2cb318..7f300ac99 100644 --- a/src/features/preview/deps/settings-contract.ts +++ b/src/features/preview/deps/settings-contract.ts @@ -4,3 +4,4 @@ */ export { useSettingsStore } from '@/features/settings/stores/settings-store' +export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' diff --git a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx index c67971c01..3b39ed0d8 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx @@ -48,12 +48,21 @@ vi.mock('@/features/timeline/deps/analysis', () => ({ vi.mock('@/features/timeline/deps/settings', () => ({ useResolvedHotkeys: () => ({ + JOIN_ITEMS: 'shift+j', + FREEZE_FRAME: 'shift+f', + DELETE_SELECTED: 'delete', RIPPLE_DELETE: 'mod+backspace', }), })) vi.mock('@/config/hotkeys', () => ({ - formatHotkeyBinding: (binding: string) => (binding === 'mod+backspace' ? 'Ctrl + Backspace' : ''), + formatHotkeyBinding: (binding: string) => + ({ + 'mod+backspace': 'Ctrl + Backspace', + 'shift+j': 'Shift + J', + 'shift+f': 'Shift + F', + delete: 'Delete', + })[binding] ?? '', })) function renderContextMenu(overrides: Partial> = {}) { diff --git a/src/features/timeline/components/timeline-item/item-context-menu.tsx b/src/features/timeline/components/timeline-item/item-context-menu.tsx index daca97a06..71c11c0b2 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.tsx @@ -395,6 +395,7 @@ function GradeActions({ t }: { t: ReturnType['t'] }) { function JoinActions({ t, + hotkeys, canJoinSelected, hasJoinableLeft, hasJoinableRight, @@ -414,19 +415,19 @@ function JoinActions({ {showJoinLeft && ( {t('timeline.contextMenu.joinWithPrevious')} - J + {formatHotkeyBinding(hotkeys.JOIN_ITEMS)} )} {showJoinRight && ( {t('timeline.contextMenu.joinWithNext')} - J + {formatHotkeyBinding(hotkeys.JOIN_ITEMS)} )} {canJoinSelected && ( {t('timeline.contextMenu.joinSelected')} - J + {formatHotkeyBinding(hotkeys.JOIN_ITEMS)} )} @@ -513,6 +514,7 @@ function LayoutActions({ t, selectedCount, onBentoLayout }: LayoutActionsProps) function MediaActions({ t, + hotkeys, canReverse, isReversed, isVideoItem, @@ -542,7 +544,7 @@ function MediaActions({ <> {t('timeline.contextMenu.insertFreezeFrame')} - Shift+F + {formatHotkeyBinding(hotkeys.FREEZE_FRAME)} @@ -731,7 +733,7 @@ function DestructiveActions({ className="text-destructive focus:text-destructive" > {t('common.delete')} - Del + {formatHotkeyBinding(hotkeys.DELETE_SELECTED)} )} From b4cc7a94deba277c8a4d299c5c0f9867078c5f53 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:25:10 -0700 Subject: [PATCH 38/64] fix: resolve duplicate shortcut bindings (cherry picked from commit 172f11cb26e47bea151f3b46761bc8c52ac322b2) --- src/config/hotkeys.test.ts | 62 ++++++++++- src/config/hotkeys.ts | 102 ++++++++++++++++-- .../editor/host/shortcut-settings.test.ts | 35 ++++++ src/features/editor/host/shortcut-settings.ts | 33 ++++-- .../settings/components/hotkey-editor.tsx | 5 + .../settings/stores/settings-store.ts | 51 +++++---- 6 files changed, 244 insertions(+), 44 deletions(-) diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index 28e53c697..f63556ec7 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -13,6 +13,7 @@ import { getHotkeyPrimaryTokenFromEventData, normalizeHotkeyBinding, parseHotkeyImportDocument, + resolveHotkeyConfiguration, resolveHotkeys, sanitizeHotkeyOverrides, } from './hotkeys' @@ -150,6 +151,52 @@ describe('findHotkeyConflicts', () => { }) }) +describe('resolveHotkeyConfiguration', () => { + it('keeps every runtime binding unique and falls back a conflicting override', () => { + const result = resolveHotkeyConfiguration({ EDIT_KEYFRAME_ADD: 'k' }) + + expect(result.bindings.SHUTTLE_PAUSE).toBe('k') + expect(result.bindings.EDIT_KEYFRAME_ADD).toBe('shift+k') + expect(result.overrides).toEqual({}) + expect(result.warnings).toEqual([ + { + code: 'duplicate_binding', + command: 'EDIT_KEYFRAME_ADD', + binding: 'k', + resolution: 'fallback', + conflictingCommand: 'SHUTTLE_PAUSE', + }, + ]) + }) + + it('rejects an earlier override instead of disabling a later default command', () => { + const result = resolveHotkeyConfiguration({ PLAY_PAUSE: 'k' }) + + expect(result.bindings.PLAY_PAUSE).toBe('space') + expect(result.bindings.SHUTTLE_PAUSE).toBe('k') + expect(result.overrides).toEqual({}) + expect(result.warnings).toEqual([ + expect.objectContaining({ + command: 'PLAY_PAUSE', + conflictingCommand: 'SHUTTLE_PAUSE', + resolution: 'fallback', + }), + ]) + }) + + it('accepts a conflict-free swap regardless of canonical command order', () => { + const result = resolveHotkeyConfiguration({ + PLAY_PAUSE: 'k', + SHUTTLE_PAUSE: 'space', + }) + + expect(result.bindings.PLAY_PAUSE).toBe('k') + expect(result.bindings.SHUTTLE_PAUSE).toBe('space') + expect(result.overrides).toEqual({ PLAY_PAUSE: 'k', SHUTTLE_PAUSE: 'space' }) + expect(result.warnings).toEqual([]) + }) +}) + describe('sanitizeHotkeyOverrides', () => { it('keeps only supported commands with normalized non-default bindings', () => { expect( @@ -384,7 +431,7 @@ describe('parseHotkeyImportDocument', () => { }) }) - it('preserves an intentional plain-K keyframe override in a v2 preset', () => { + it('falls back a v2 plain-K keyframe override that conflicts with transport', () => { expect( parseHotkeyImportDocument({ schema: HOTKEY_EXPORT_SCHEMA, @@ -394,13 +441,20 @@ describe('parseHotkeyImportDocument', () => { }, }), ).toEqual({ - overrides: { - EDIT_KEYFRAME_ADD: 'k', - }, + overrides: {}, importedCommandCount: 1, ignoredCommandCount: 0, remappedCommandCount: 0, sourceVersion: 2, + conflictWarnings: [ + { + code: 'duplicate_binding', + command: 'EDIT_KEYFRAME_ADD', + binding: 'k', + resolution: 'fallback', + conflictingCommand: 'SHUTTLE_PAUSE', + }, + ], }) }) }) diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index 9cc74b8bd..f4fbeb101 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -139,6 +139,21 @@ export interface HotkeyImportResult { ignoredCommandCount: number remappedCommandCount: number sourceVersion: number | null + conflictWarnings?: HotkeyConflictWarning[] +} + +export interface HotkeyConflictWarning { + code: 'duplicate_binding' + command: HotkeyKey + binding: string + resolution: 'fallback' | 'unassigned' + conflictingCommand: HotkeyKey +} + +export interface HotkeyResolution { + bindings: HotkeyBindingMap + overrides: HotkeyOverrideMap + warnings: HotkeyConflictWarning[] } export interface BrowserHostileHotkey { @@ -405,11 +420,69 @@ function getHotkeyPlatform(platformValue?: string): HotkeyPlatform { : 'windows' } -export function resolveHotkeys(overrides: HotkeyOverrideMap = {}): HotkeyBindingMap { - return { - ...HOTKEYS, - ...sanitizeHotkeyOverrides(overrides), +export function resolveHotkeyConfiguration(overrides: unknown = {}): HotkeyResolution { + const requested = normalizeHotkeyOverrides(overrides) + const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[] + const rejectedOverrides = new Set() + let bindings = {} as HotkeyBindingMap + const effectiveOverrides: HotkeyOverrideMap = {} + const warnings: HotkeyConflictWarning[] = [] + + // Resolve the complete candidate map before assigning priority. This accepts + // valid swaps (for example Space <-> K), while any remaining collision rejects + // the participating custom binding(s) back to their unique canonical defaults. + // Re-run because one fallback can expose a collision with another custom value. + while (true) { + bindings = Object.fromEntries( + commandKeys.map((key) => [ + key, + !rejectedOverrides.has(key) && key in requested ? requested[key]! : HOTKEYS[key], + ]), + ) as HotkeyBindingMap + + const conflicts = Object.values(getHotkeyConflictMap(bindings)).filter( + (commands) => commands.length > 1, + ) + if (conflicts.length === 0) break + + let rejectedInPass = false + for (const commands of conflicts) { + for (const key of commands) { + if (rejectedOverrides.has(key) || !(key in requested)) continue + + const requestedBinding = requested[key]! + if (requestedBinding === HOTKEYS[key]) continue + + const conflictingCommand = commands.find((command) => command !== key)! + rejectedOverrides.add(key) + warnings.push({ + code: 'duplicate_binding', + command: key, + binding: normalizeHotkeyBinding(requestedBinding), + resolution: 'fallback', + conflictingCommand, + }) + rejectedInPass = true + } + } + + if (!rejectedInPass) { + throw new Error('Default keyboard shortcut bindings must be unique') + } } + + for (const key of commandKeys) { + const binding = bindings[key] + if (binding !== HOTKEYS[key]) { + effectiveOverrides[key] = binding + } + } + + return { bindings, overrides: effectiveOverrides, warnings } +} + +export function resolveHotkeys(overrides: HotkeyOverrideMap = {}): HotkeyBindingMap { + return resolveHotkeyConfiguration(overrides).bindings } function isExplicitlyUnassignedHotkey(rawBinding: string): boolean { @@ -531,6 +604,10 @@ export function normalizeHotkeyBinding(binding: string): string { } export function sanitizeHotkeyOverrides(overrides: unknown): HotkeyOverrideMap { + return resolveHotkeyConfiguration(overrides).overrides +} + +function normalizeHotkeyOverrides(overrides: unknown): HotkeyOverrideMap { if (!overrides || typeof overrides !== 'object') { return {} } @@ -785,12 +862,14 @@ function collectImportedOverrides(source: unknown): HotkeyImportResult { } } + const resolution = resolveHotkeyConfiguration(normalizedOverrides) return { - overrides: normalizedOverrides, + overrides: resolution.overrides, importedCommandCount, ignoredCommandCount, remappedCommandCount, sourceVersion: null, + ...(resolution.warnings.length > 0 ? { conflictWarnings: resolution.warnings } : {}), } } @@ -801,7 +880,14 @@ function migrateLegacyHotkeyImport(result: HotkeyImportResult): HotkeyImportResu ) { const overrides = { ...result.overrides } delete overrides.EDIT_KEYFRAME_ADD - return { ...result, overrides } + const conflictWarnings = result.conflictWarnings?.filter( + (warning) => warning.command !== 'EDIT_KEYFRAME_ADD', + ) + return { + ...result, + overrides, + ...(conflictWarnings && conflictWarnings.length > 0 ? { conflictWarnings } : {}), + } } return result @@ -874,12 +960,14 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { } } + const resolution = resolveHotkeyConfiguration(importedOverrides) return migrateLegacyHotkeyImport({ - overrides: sanitizeHotkeyOverrides(importedOverrides), + overrides: resolution.overrides, importedCommandCount, ignoredCommandCount, remappedCommandCount, sourceVersion, + ...(resolution.warnings.length > 0 ? { conflictWarnings: resolution.warnings } : {}), }) } diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index ce3a062c5..37ad672d4 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -3,6 +3,9 @@ import { createElement } from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { fireEvent, render, waitFor } from '@testing-library/react' +import { useHotkeys } from 'react-hotkeys-hook' +import { HOTKEY_OPTIONS } from '@/config/hotkeys' +import { useResolvedHotkeys } from '@/features/editor/deps/settings' import { useSettingsStore } from '@/features/editor/deps/settings' import { useHostTimelineShortcuts } from '@/features/editor/deps/timeline-hooks' import { usePlaybackStore } from '@/shared/state/playback' @@ -14,6 +17,13 @@ function HostShortcutHarness() { return null } +function ConflictingShortcutHarness({ onAddKeyframe }: { onAddKeyframe: () => void }) { + const hotkeys = useResolvedHotkeys() + useHotkeys(hotkeys.EDIT_KEYFRAME_ADD, onAddKeyframe, HOTKEY_OPTIONS, [onAddKeyframe]) + useHostTimelineShortcuts() + return null +} + function createShortcutHost(initial: HostShortcutSettings) { const listeners = new Set<(settings: HostShortcutSettings) => void>() const setSettings = vi.fn() @@ -175,4 +185,29 @@ describe('host shortcut settings round trip', () => { unmount() expect(host.listenerCount()).toBe(0) }) + + it('resolves a host collision so capture and bubbling listeners fire one intended action', async () => { + const harness = createShortcutHost({ + schema: 'freecut-host-shortcuts', + version: 1, + overrides: { + SHUTTLE_PAUSE: 'k', + EDIT_KEYFRAME_ADD: 'k', + }, + }) + const unmount = await mountHostShortcutSettings(harness.host) + const addKeyframe = vi.fn() + + render(createElement(ConflictingShortcutHarness, { onAddKeyframe: addKeyframe })) + usePlaybackStore.setState({ isPlaying: true }) + fireEvent.keyDown(document, { key: 'k', code: 'KeyK' }) + expect(usePlaybackStore.getState().isPlaying).toBe(false) + expect(addKeyframe).not.toHaveBeenCalled() + + fireEvent.keyDown(document, { key: 'K', code: 'KeyK', shiftKey: true }) + expect(addKeyframe).toHaveBeenCalledTimes(1) + expect(harness.notify).toHaveBeenCalledWith(expect.objectContaining({ kind: 'conflict' })) + + unmount() + }) }) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts index a7685a1f6..5ec0e85d6 100644 --- a/src/features/editor/host/shortcut-settings.ts +++ b/src/features/editor/host/shortcut-settings.ts @@ -1,4 +1,8 @@ -import { sanitizeHotkeyOverrides, type HotkeyOverrideMap } from '@/config/hotkeys' +import { + resolveHotkeyConfiguration, + type HotkeyConflictWarning, + type HotkeyOverrideMap, +} from '@/config/hotkeys' import { useSettingsStore } from '@/features/editor/deps/settings' import { HOST_SHORTCUTS_SCHEMA, @@ -8,12 +12,19 @@ import { type HostShortcutSettings, } from './contract' -function normalizeHostShortcutSettings(settings: HostShortcutSettings): HostShortcutSettings { +function normalizeHostShortcutSettings(settings: HostShortcutSettings): { + settings: HostShortcutSettings + warnings: HotkeyConflictWarning[] +} { if (settings.schema !== HOST_SHORTCUTS_SCHEMA || settings.version !== HOST_SHORTCUTS_VERSION) { throw new Error('Unsupported host shortcut settings schema') } - return createHostShortcutSettings(sanitizeHotkeyOverrides(settings.overrides)) + const resolution = resolveHotkeyConfiguration(settings.overrides) + return { + settings: createHostShortcutSettings(resolution.overrides), + warnings: resolution.warnings, + } } function copyOverrides(overrides: HotkeyOverrideMap): HotkeyOverrideMap { @@ -50,8 +61,7 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => let inboundRevision = 0 let writeQueue = Promise.resolve() - const isCurrent = () => - !disposed && currentOwnership?.epoch === ownership.epoch + const isCurrent = () => !disposed && currentOwnership?.epoch === ownership.epoch const reportFailure = (message: string) => { host.notify?.({ kind: 'error', message }) @@ -61,9 +71,20 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => if (!isCurrent()) return const normalized = normalizeHostShortcutSettings(settings) inboundRevision += 1 + if (normalized.warnings.length > 0) { + for (const warning of normalized.warnings) { + host.notify?.({ + kind: 'conflict', + message: + warning.resolution === 'fallback' + ? `Shortcut conflict for ${warning.command}; using its default binding.` + : `Shortcut conflict for ${warning.command}; the binding was disabled.`, + }) + } + } applyingHostSettings = true try { - useSettingsStore.getState().replaceHotkeyOverrides(normalized.overrides) + useSettingsStore.getState().replaceHotkeyOverrides(normalized.settings.overrides) } finally { applyingHostSettings = false } diff --git a/src/features/settings/components/hotkey-editor.tsx b/src/features/settings/components/hotkey-editor.tsx index b2462147b..b198efb37 100644 --- a/src/features/settings/components/hotkey-editor.tsx +++ b/src/features/settings/components/hotkey-editor.tsx @@ -936,6 +936,11 @@ export function HotkeyEditor() { try { const contents = await readTextFile(file) const importResult = parseHotkeyImportDocument(JSON.parse(contents)) + if (importResult.conflictWarnings?.length) { + toast.warning( + `${importResult.conflictWarnings.length} imported shortcut conflict(s) were resolved to keep commands reachable.`, + ) + } const changes = buildImportChanges(hotkeys, importResult.overrides) if (changes.length === 0) { diff --git a/src/features/settings/stores/settings-store.ts b/src/features/settings/stores/settings-store.ts index 1afa70f9a..5170fa21b 100644 --- a/src/features/settings/stores/settings-store.ts +++ b/src/features/settings/stores/settings-store.ts @@ -12,6 +12,7 @@ import { DEFAULT_EDITOR_DENSITY_PRESET, normalizeEditorDensityPreset } from '@/c import { HOTKEYS, normalizeHotkeyBinding, + resolveHotkeyConfiguration, sanitizeHotkeyOverrides, type HotkeyKey, type HotkeyOverrideMap, @@ -219,39 +220,36 @@ export const useSettingsStore = create()( set((state) => { const normalizedBinding = normalizeHotkeyBinding(binding) if (!normalizedBinding || normalizedBinding === HOTKEYS[key]) { - if (!(key in state.hotkeyOverrides)) { - return state - } - const remainingOverrides = { ...state.hotkeyOverrides } delete remainingOverrides[key] - return { hotkeyOverrides: remainingOverrides } + const resolved = resolveHotkeyConfiguration(remainingOverrides).overrides + return areHotkeyOverridesEqual(state.hotkeyOverrides, resolved) + ? state + : { hotkeyOverrides: resolved } } - if (state.hotkeyOverrides[key] === normalizedBinding) { + const resolution = resolveHotkeyConfiguration({ + ...state.hotkeyOverrides, + [key]: normalizedBinding, + }) + const nextOverrides = resolution.overrides + + if (areHotkeyOverridesEqual(state.hotkeyOverrides, nextOverrides)) { return state } - return { - hotkeyOverrides: { - ...state.hotkeyOverrides, - [key]: normalizedBinding, - }, - } + return { hotkeyOverrides: nextOverrides } }), unbindHotkeyBinding: (key) => set((state) => { - if (state.hotkeyOverrides[key] === '') { - return state - } - - return { - hotkeyOverrides: { - ...state.hotkeyOverrides, - [key]: '', - }, - } + const resolved = resolveHotkeyConfiguration({ + ...state.hotkeyOverrides, + [key]: '', + }).overrides + return areHotkeyOverridesEqual(state.hotkeyOverrides, resolved) + ? state + : { hotkeyOverrides: resolved } }), replaceHotkeyOverrides: (overrides) => @@ -267,13 +265,12 @@ export const useSettingsStore = create()( resetHotkeyBinding: (key) => set((state) => { - if (!(key in state.hotkeyOverrides)) { - return state - } - const remainingOverrides = { ...state.hotkeyOverrides } delete remainingOverrides[key] - return { hotkeyOverrides: remainingOverrides } + const resolved = resolveHotkeyConfiguration(remainingOverrides).overrides + return areHotkeyOverridesEqual(state.hotkeyOverrides, resolved) + ? state + : { hotkeyOverrides: resolved } }), resetHotkeys: () => From e3f0d16431265598be7509f58a1265bdca2d72cb Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:22:31 -0700 Subject: [PATCH 39/64] fix(shortcuts): guard dialog control key events (cherry picked from commit 830fd0369d8caee82c85405bfc7be89068d7f323) --- src/config/hotkeys-dom-guard.test.ts | 79 ++++++++++++++++++++++++++++ src/config/hotkeys.ts | 29 +++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/config/hotkeys-dom-guard.test.ts diff --git a/src/config/hotkeys-dom-guard.test.ts b/src/config/hotkeys-dom-guard.test.ts new file mode 100644 index 000000000..d690403be --- /dev/null +++ b/src/config/hotkeys-dom-guard.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from 'vite-plus/test' +import { shouldIgnoreGlobalHotkey } from './hotkeys' + +describe('global shortcut DOM guards', () => { + afterEach(() => { + document.body.replaceChildren() + }) + + function dispatchFrom(markup: string, selector: string, key: string) { + document.body.innerHTML = markup + const target = document.querySelector(selector) + if (!(target instanceof HTMLElement)) throw new Error(`Missing ${selector}`) + + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + let captureSawEvent = false + const captureListener = (capturedEvent: KeyboardEvent) => { + captureSawEvent = true + if (!shouldIgnoreGlobalHotkey(capturedEvent)) capturedEvent.preventDefault() + } + document.addEventListener('keydown', captureListener, { capture: true }) + target.dispatchEvent(event) + document.removeEventListener('keydown', captureListener, { capture: true }) + return { captureSawEvent, defaultPrevented: event.defaultPrevented } + } + + it('still receives events in capture phase but does not handle contenteditable targets', () => { + const result = dispatchFrom( + '
text
', + '#editor', + 'j', + ) + + expect(result).toEqual({ captureSawEvent: true, defaultPrevented: false }) + }) + + it.each(['button', 'input', 'textarea', 'select'])('guards dialog %s controls', (tagName) => { + const result = dispatchFrom( + `
<${tagName} id="control">
`, + '#control', + 'j', + ) + + expect(result).toEqual({ captureSawEvent: true, defaultPrevented: false }) + }) + + it('allows an explicitly opted-in dialog control', () => { + const result = dispatchFrom( + '
', + '#control', + 'j', + ) + + expect(result).toEqual({ captureSawEvent: true, defaultPrevented: true }) + }) + + it('preserves dialog K events without preventDefault or propagation swallowing', () => { + document.body.innerHTML = '
' + const target = document.querySelector('#control') as HTMLButtonElement + const bubble = vi.fn() + document.body.addEventListener('keydown', bubble) + const event = new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true }) + const captureListener = (capturedEvent: KeyboardEvent) => { + if (!shouldIgnoreGlobalHotkey(capturedEvent)) { + capturedEvent.preventDefault() + capturedEvent.stopPropagation() + } + } + + document.addEventListener('keydown', captureListener, { capture: true }) + target.dispatchEvent(event) + document.removeEventListener('keydown', captureListener, { capture: true }) + document.body.removeEventListener('keydown', bubble) + + expect(event.defaultPrevented).toBe(false) + expect(bubble).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index f4fbeb101..d0368d013 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -971,11 +971,38 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { }) } +const GLOBAL_HOTKEY_OPT_IN = '[data-global-hotkeys="allow"]' +const DIALOG_SELECTOR = '[role="dialog"], dialog' +const DIALOG_CONTROL_SELECTOR = + 'button, input, textarea, select, [role="button"], [contenteditable="true"], [contenteditable=""]' + +function isContentEditableTarget(target: Element): boolean { + const editable = target.closest('[contenteditable]') + return editable !== null && editable.getAttribute('contenteditable') !== 'false' +} + +/** + * Returns true when a global shortcut should be ignored for the focused DOM + * target. Ignoring here is intentional: react-hotkeys-hook then leaves the + * event alone, preserving dialog controls' default actions and propagation. + */ +export function shouldIgnoreGlobalHotkey(event: KeyboardEvent): boolean { + const target = event.target + if (typeof Element === 'undefined' || !(target instanceof Element)) return false + if (target.closest(GLOBAL_HOTKEY_OPT_IN)) return false + if (isContentEditableTarget(target)) return true + + const dialog = target.closest(DIALOG_SELECTOR) + return dialog !== null && target.closest(DIALOG_CONTROL_SELECTOR) !== null +} + /** * Options for react-hotkeys-hook. - * Prevents shortcuts from firing in input fields. + * Prevents shortcuts from firing in editable fields and dialog controls. */ export const HOTKEY_OPTIONS = { enableOnFormTags: false, + enableOnContentEditable: false, + ignoreEventWhen: shouldIgnoreGlobalHotkey, preventDefault: true, } as const From f5217b886a597d3807d7fc8160fa2592006da141 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:28:35 -0700 Subject: [PATCH 40/64] fix(shortcuts): align reactive authority and guards (cherry picked from commit 850e39008b4511b40bfa1e02fdcc6cc05f697caf) --- src/config/hotkeys-dom-guard.test.ts | 54 ++++++++++++++++++- src/config/hotkeys.ts | 41 ++++++++------ .../editor/host/shortcut-settings.test.ts | 16 +++--- .../components/source-monitor.test.tsx | 21 +++++++- .../preview/components/source-monitor.tsx | 47 +++++++++++----- 5 files changed, 138 insertions(+), 41 deletions(-) diff --git a/src/config/hotkeys-dom-guard.test.ts b/src/config/hotkeys-dom-guard.test.ts index d690403be..cb34ee8ae 100644 --- a/src/config/hotkeys-dom-guard.test.ts +++ b/src/config/hotkeys-dom-guard.test.ts @@ -1,7 +1,22 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it } from 'vite-plus/test' -import { shouldIgnoreGlobalHotkey } from './hotkeys' +import { createElement } from 'react' +import { render, screen } from '@testing-library/react' +import { useHotkeys } from 'react-hotkeys-hook' +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +import { HOTKEY_OPTIONS, shouldIgnoreGlobalHotkey } from './hotkeys' + +function CaptureHotkeyHarness({ onHotkey }: { onHotkey: () => void }) { + useHotkeys('k', onHotkey, { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } }, [ + onHotkey, + ]) + + return createElement( + 'div', + { role: 'dialog' }, + createElement('button', { type: 'button' }, 'Pause'), + ) +} describe('global shortcut DOM guards', () => { afterEach(() => { @@ -55,6 +70,19 @@ describe('global shortcut DOM guards', () => { expect(result).toEqual({ captureSawEvent: true, defaultPrevented: true }) }) + it.each([ + ['input', '
'], + [ + 'contenteditable', + '
', + ], + ])('allows explicitly opted-in %s targets', (_name, markup) => { + expect(dispatchFrom(markup, '#control', 'j')).toEqual({ + captureSawEvent: true, + defaultPrevented: true, + }) + }) + it('preserves dialog K events without preventDefault or propagation swallowing', () => { document.body.innerHTML = '
' const target = document.querySelector('#control') as HTMLButtonElement @@ -76,4 +104,26 @@ describe('global shortcut DOM guards', () => { expect(event.defaultPrevented).toBe(false) expect(bubble).toHaveBeenCalledTimes(1) }) + + it('keeps the real capture-phase hotkey listener inert for dialog K events', () => { + const onHotkey = vi.fn() + const rendered = render(createElement(CaptureHotkeyHarness, { onHotkey })) + const target = screen.getByRole('button', { name: 'Pause' }) + const bubble = vi.fn() + document.body.addEventListener('keydown', bubble) + const event = new KeyboardEvent('keydown', { + key: 'k', + code: 'KeyK', + bubbles: true, + cancelable: true, + }) + + target.dispatchEvent(event) + + document.body.removeEventListener('keydown', bubble) + rendered.unmount() + expect(onHotkey).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + expect(bubble).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index d0368d013..099de6b3c 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -862,29 +862,40 @@ function collectImportedOverrides(source: unknown): HotkeyImportResult { } } - const resolution = resolveHotkeyConfiguration(normalizedOverrides) return { - overrides: resolution.overrides, + overrides: normalizedOverrides, importedCommandCount, ignoredCommandCount, remappedCommandCount, sourceVersion: null, + } +} + +function resolveHotkeyImportResult(result: HotkeyImportResult): HotkeyImportResult { + const resolution = resolveHotkeyConfiguration(result.overrides) + return { + ...result, + overrides: resolution.overrides, ...(resolution.warnings.length > 0 ? { conflictWarnings: resolution.warnings } : {}), } } function migrateLegacyHotkeyImport(result: HotkeyImportResult): HotkeyImportResult { - if ( - (result.sourceVersion === null || result.sourceVersion < 2) && - result.overrides.EDIT_KEYFRAME_ADD === 'k' - ) { + const hasLegacyKeyframeBinding = + result.overrides.EDIT_KEYFRAME_ADD === 'k' || + result.conflictWarnings?.some( + (warning) => warning.command === 'EDIT_KEYFRAME_ADD' && warning.binding === 'k', + ) + + if ((result.sourceVersion === null || result.sourceVersion < 2) && hasLegacyKeyframeBinding) { const overrides = { ...result.overrides } delete overrides.EDIT_KEYFRAME_ADD const conflictWarnings = result.conflictWarnings?.filter( (warning) => warning.command !== 'EDIT_KEYFRAME_ADD', ) + const { conflictWarnings: _discardedWarnings, ...resultWithoutWarnings } = result return { - ...result, + ...resultWithoutWarnings, overrides, ...(conflictWarnings && conflictWarnings.length > 0 ? { conflictWarnings } : {}), } @@ -899,7 +910,7 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { } if (source.schema !== HOTKEY_EXPORT_SCHEMA) { - return migrateLegacyHotkeyImport(collectImportedOverrides(source)) + return migrateLegacyHotkeyImport(resolveHotkeyImportResult(collectImportedOverrides(source))) } const sourceVersion = typeof source.version === 'number' ? source.version : null @@ -973,8 +984,7 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { const GLOBAL_HOTKEY_OPT_IN = '[data-global-hotkeys="allow"]' const DIALOG_SELECTOR = '[role="dialog"], dialog' -const DIALOG_CONTROL_SELECTOR = - 'button, input, textarea, select, [role="button"], [contenteditable="true"], [contenteditable=""]' +const FORM_CONTROL_SELECTOR = 'input, textarea, select' function isContentEditableTarget(target: Element): boolean { const editable = target.closest('[contenteditable]') @@ -991,9 +1001,8 @@ export function shouldIgnoreGlobalHotkey(event: KeyboardEvent): boolean { if (typeof Element === 'undefined' || !(target instanceof Element)) return false if (target.closest(GLOBAL_HOTKEY_OPT_IN)) return false if (isContentEditableTarget(target)) return true - - const dialog = target.closest(DIALOG_SELECTOR) - return dialog !== null && target.closest(DIALOG_CONTROL_SELECTOR) !== null + if (target.closest(FORM_CONTROL_SELECTOR)) return true + return target.closest(DIALOG_SELECTOR) !== null } /** @@ -1001,8 +1010,10 @@ export function shouldIgnoreGlobalHotkey(event: KeyboardEvent): boolean { * Prevents shortcuts from firing in editable fields and dialog controls. */ export const HOTKEY_OPTIONS = { - enableOnFormTags: false, - enableOnContentEditable: false, + // Route normally excluded targets through ignoreEventWhen so the explicit + // data-global-hotkeys="allow" escape hatch works for those targets too. + enableOnFormTags: true, + enableOnContentEditable: true, ignoreEventWhen: shouldIgnoreGlobalHotkey, preventDefault: true, } as const diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index 37ad672d4..dbb3aa10d 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -117,16 +117,16 @@ describe('host shortcut settings round trip', () => { harness.emit( createHostShortcutSettings({ - SHUTTLE_REVERSE: 'a', - SHUTTLE_PAUSE: 's', - SHUTTLE_FORWARD: 'd', + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', }), ) expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ - SHUTTLE_REVERSE: 'a', - SHUTTLE_PAUSE: 's', - SHUTTLE_FORWARD: 'd', + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', }) expect(harness.notify).not.toHaveBeenCalled() @@ -171,10 +171,10 @@ describe('host shortcut settings round trip', () => { const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) const unmount = await mountHostShortcutSettings(host.host) useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') - host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'y' })) + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'w' })) await Promise.resolve() expect(host.setSettings).not.toHaveBeenCalled() - expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'y' }) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'w' }) unmount() }) diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx index 512d5931e..f4d0477ff 100644 --- a/src/features/preview/components/source-monitor.test.tsx +++ b/src/features/preview/components/source-monitor.test.tsx @@ -281,17 +281,34 @@ describe('SourceMonitor current media ownership', () => { ...resolvedHotkeysState.hotkeys, MARK_IN: 'shift+f', } - rendered.rerender() + rendered.rerender() expect(rendered.getByLabelText('Mark In (Shift + F)')).toBeInTheDocument() resolvedHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys, MARK_IN: 'i', } - rendered.rerender() + rendered.rerender() expect(rendered.getByLabelText('Mark In (I)')).toBeInTheDocument() }) + it('uses the same reactive binding for local source-monitor actions', async () => { + resolvedHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + MARK_IN: 'shift+f', + } + sourcePlayerStoreState.currentSourceFrame = 42 + const rendered = render() + await waitFor(() => expect(rendered.getByLabelText('Mark In (Shift + F)')).toBeInTheDocument()) + const monitor = rendered.container.firstElementChild! + + fireEvent.keyDown(monitor, { key: 'i', code: 'KeyI' }) + expect(sourcePlayerStoreState.setInPoint).not.toHaveBeenCalled() + + fireEvent.keyDown(monitor, { key: 'F', code: 'KeyF', shiftKey: true }) + expect(sourcePlayerStoreState.setInPoint).toHaveBeenCalledWith(42) + }) + it('uses macOS modifier names in visible shortcut labels', async () => { const originalPlatform = navigator.platform Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel' }) diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index a0c7beb06..4a43128c0 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -71,7 +71,7 @@ import { formatTimecodeCompact } from '@/shared/utils/time-utils' import { getPreviewPixelSnapSize } from '../utils/preview-pixel-snap' import type { TimelineTrack } from '@/types/timeline' import { useBlobUrlEpoch } from '@/infrastructure/browser/blob-url-manager' -import { formatHotkeyBinding } from '@/config/hotkeys' +import { formatHotkeyBinding, getHotkeyBindingFromEventData } from '@/config/hotkeys' interface SourceMonitorProps { mediaId: string @@ -263,7 +263,6 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ const mediaWidth = media.width || 640 const mediaHeight = media.height || 360 const durationInFrames = mediaType === 'image' ? 1 : Math.max(1, Math.round(media.duration * fps)) - return ( {}}> @@ -287,6 +286,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ interactive={interactive} seekFrame={seekFrame} onClose={onClose} + hotkeys={hotkeys} /> @@ -310,6 +310,7 @@ interface SourceMonitorInnerProps { interactive: boolean seekFrame: number | null onClose?: () => void + hotkeys: ReturnType } function SourceMonitorInner({ @@ -326,6 +327,7 @@ function SourceMonitorInner({ interactive, seekFrame, onClose, + hotkeys, }: SourceMonitorInnerProps) { const containerRef = useRef(null) const contentHostRef = useRef(null) @@ -456,23 +458,24 @@ function SourceMonitorInner({ (e: React.KeyboardEvent) => { if (!interactive) return if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + const binding = getHotkeyBindingFromEventData(e) const { currentSourceFrame, setInPoint, setOutPoint, clearInOutPoints } = useSourcePlayerStore.getState() - if (e.key === 'i' || e.key === 'I') { + if (binding === hotkeys.MARK_IN) { e.preventDefault() e.stopPropagation() setInPoint(currentSourceFrame) - } else if (e.key === 'o' || e.key === 'O') { + } else if (binding === hotkeys.MARK_OUT) { e.preventDefault() e.stopPropagation() setOutPoint(getExclusiveSourceOutPoint(currentSourceFrame, durationInFrames)) - } else if (e.altKey && (e.key === 'x' || e.key === 'X')) { + } else if (binding === hotkeys.CLEAR_IN_OUT) { e.preventDefault() e.stopPropagation() clearInOutPoints() } }, - [durationInFrames, interactive], + [durationInFrames, hotkeys, interactive], ) const handleMouseEnter = useCallback(() => { @@ -1325,7 +1328,9 @@ function SourcePlaybackControls({ - Mark Out ({shortcutLabel(hotkeys.MARK_OUT)}) + + Mark Out ({shortcutLabel(hotkeys.MARK_OUT)}) + @@ -1342,7 +1347,9 @@ function SourcePlaybackControls({ - Clear In/Out ({shortcutLabel(hotkeys.CLEAR_IN_OUT)}) + + Clear In/Out ({shortcutLabel(hotkeys.CLEAR_IN_OUT)}) +
)} @@ -1390,7 +1397,9 @@ function SourcePlaybackControls({ - Go to start ({shortcutLabel(hotkeys.GO_TO_START)}) + + Go to start ({shortcutLabel(hotkeys.GO_TO_START)}) + @@ -1407,7 +1416,9 @@ function SourcePlaybackControls({ - Previous frame ({shortcutLabel(hotkeys.PREVIOUS_FRAME)}) + + Previous frame ({shortcutLabel(hotkeys.PREVIOUS_FRAME)}) + @@ -1446,7 +1457,9 @@ function SourcePlaybackControls({ - Next frame ({shortcutLabel(hotkeys.NEXT_FRAME)}) + + Next frame ({shortcutLabel(hotkeys.NEXT_FRAME)}) + @@ -1463,7 +1476,9 @@ function SourcePlaybackControls({ - Go to end ({shortcutLabel(hotkeys.GO_TO_END)}) + + Go to end ({shortcutLabel(hotkeys.GO_TO_END)}) +
@@ -1566,7 +1581,9 @@ function SourcePlaybackControls({ - Insert ({shortcutLabel(hotkeys.INSERT_EDIT)}) + + Insert ({shortcutLabel(hotkeys.INSERT_EDIT)}) + @@ -1583,7 +1600,9 @@ function SourcePlaybackControls({ - Overwrite ({shortcutLabel(hotkeys.OVERWRITE_EDIT)}) + + Overwrite ({shortcutLabel(hotkeys.OVERWRITE_EDIT)}) +
) : ( From 0c7dea97fa38aa7762e2ee76f8bfcbbafd320f9a Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:30:48 -0700 Subject: [PATCH 41/64] fix(host): cancel deferred shortcut ownership (cherry picked from commit ebcad189683fa1a7fde44e862784b1ee4e14c326) --- src/features/editor/host/editor-surface.tsx | 8 ++- .../editor/host/shortcut-settings.test.ts | 44 ++++++++++++++ src/features/editor/host/shortcut-settings.ts | 60 ++++++++++++------- 3 files changed, 91 insertions(+), 21 deletions(-) diff --git a/src/features/editor/host/editor-surface.tsx b/src/features/editor/host/editor-surface.tsx index 456cb64e9..535eba118 100644 --- a/src/features/editor/host/editor-surface.tsx +++ b/src/features/editor/host/editor-surface.tsx @@ -42,11 +42,15 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { let cancelled = false let unsubscribe: (() => void) | undefined let unmountShortcutSettings: (() => void) | undefined + const shortcutSettingsAbortController = new AbortController() setState(null) setError(null) const initialize = async () => { - unmountShortcutSettings = await mountHostShortcutSettings(host) + unmountShortcutSettings = await mountHostShortcutSettings( + host, + shortcutSettingsAbortController.signal, + ) if (cancelled) { unmountShortcutSettings() unmountShortcutSettings = undefined @@ -70,6 +74,7 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { .catch((caught) => { unsubscribe?.() unsubscribe = undefined + shortcutSettingsAbortController.abort() unmountShortcutSettings?.() unmountShortcutSettings = undefined if (cancelled) return @@ -80,6 +85,7 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { cancelled = true unsubscribe?.() unsubscribe = undefined + shortcutSettingsAbortController.abort() unmountShortcutSettings?.() unmountShortcutSettings = undefined } diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index dbb3aa10d..937d34df9 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -156,6 +156,50 @@ describe('host shortcut settings round trip', () => { unmountB() }) + it('invalidates deferred host A when replacement B omits the optional shortcut port', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) + let resolveA!: (settings: HostShortcutSettings) => void + const hostA = createShortcutHost(createHostShortcutSettings({ SHUTTLE_REVERSE: 'a' })) + hostA.host.shortcuts!.getSettings = vi.fn( + () => new Promise((resolve) => (resolveA = resolve)), + ) + const mountA = mountHostShortcutSettings(hostA.host) + const hostB = { ...createShortcutHost(createHostShortcutSettings({})).host } + delete hostB.shortcuts + + const unmountB = await mountHostShortcutSettings(hostB) + resolveA(createHostShortcutSettings({ SHUTTLE_REVERSE: 'a' })) + const unmountA = await mountA + + expect(hostA.listenerCount()).toBe(0) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + PLAY_PAUSE: 'shift+space', + }) + unmountA() + unmountB() + }) + + it('cancels deferred hydration on unmount before subscribing', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) + let resolveSettings!: (settings: HostShortcutSettings) => void + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_REVERSE: 'q' })) + host.host.shortcuts!.getSettings = vi.fn( + () => new Promise((resolve) => (resolveSettings = resolve)), + ) + const controller = new AbortController() + const mounting = mountHostShortcutSettings(host.host, controller.signal) + + controller.abort() + resolveSettings(createHostShortcutSettings({ SHUTTLE_REVERSE: 'q' })) + const unmount = await mounting + + expect(host.listenerCount()).toBe(0) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + PLAY_PAUSE: 'shift+space', + }) + unmount() + }) + it('does not execute a queued write after its host is disposed', async () => { const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) const unmount = await mountHostShortcutSettings(host.host) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts index 5ec0e85d6..bb5e99f80 100644 --- a/src/features/editor/host/shortcut-settings.ts +++ b/src/features/editor/host/shortcut-settings.ts @@ -43,12 +43,10 @@ let currentOwnership: ShortcutOwnership | null = null * Hydrates host-owned shortcuts before the editor mounts, then keeps UI and * host/agent changes synchronized for the lifetime of the embedded surface. */ -export async function mountHostShortcutSettings(host: EditorHost): Promise<() => void> { - const port = host.shortcuts - if (!port) { - return () => undefined - } - +export async function mountHostShortcutSettings( + host: EditorHost, + signal?: AbortSignal, +): Promise<() => void> { const ownership: ShortcutOwnership = { epoch: ++nextOwnershipEpoch, standaloneOverrides: copyOverrides( @@ -60,9 +58,39 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => let disposed = false let inboundRevision = 0 let writeQueue = Promise.resolve() + let unsubscribeHost: (() => void) | undefined + let unsubscribeStore: (() => void) | undefined const isCurrent = () => !disposed && currentOwnership?.epoch === ownership.epoch + const dispose = () => { + if (disposed) return + disposed = true + inboundRevision += 1 + unsubscribeStore?.() + unsubscribeHost?.() + signal?.removeEventListener('abort', dispose) + if (currentOwnership?.epoch !== ownership.epoch) return + currentOwnership = null + useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides) + } + + if (signal?.aborted) { + dispose() + return dispose + } + signal?.addEventListener('abort', dispose, { once: true }) + + // Replacing a host invalidates the previous epoch immediately, including + // while either host is still resolving getSettings. Keep the standalone + // snapshot visible until this owner has authoritative settings to apply. + useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides) + + const port = host.shortcuts + if (!port) { + return dispose + } + const reportFailure = (message: string) => { host.notify?.({ kind: 'error', message }) } @@ -94,15 +122,15 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => try { initialSettings = await Promise.resolve(port.getSettings()) } catch (error) { - if (currentOwnership?.epoch === ownership.epoch) currentOwnership = null + dispose() throw error } if (!isCurrent()) { - return () => undefined + return dispose } applyHostSettings(initialSettings) - const unsubscribeHost = port.subscribe?.((settings) => { + unsubscribeHost = port.subscribe?.((settings) => { if (!isCurrent()) return try { applyHostSettings(settings) @@ -111,7 +139,7 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => } }) - const unsubscribeStore = useSettingsStore.subscribe((state, previousState) => { + unsubscribeStore = useSettingsStore.subscribe((state, previousState) => { if ( disposed || applyingHostSettings || @@ -128,17 +156,9 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => return Promise.resolve(port.setSettings(settings)) }) .catch(() => { - reportFailure('Could not save keyboard shortcuts to the host.') + if (isCurrent()) reportFailure('Could not save keyboard shortcuts to the host.') }) }) - return () => { - disposed = true - inboundRevision += 1 - unsubscribeStore() - unsubscribeHost?.() - if (currentOwnership?.epoch !== ownership.epoch) return - currentOwnership = null - useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides) - } + return dispose } From 1d7a805d5191d16d321d9213238c608ad790b439 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:35:20 -0700 Subject: [PATCH 42/64] test(shortcuts): enforce unique imported bindings (cherry picked from commit d7a400cbdfcc5c87536ce55b77de5b5ee18b94d9) --- .../hotkey-editor-reset-dialog.test.tsx | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx b/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx index 0099dff4f..bb3124215 100644 --- a/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx +++ b/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx @@ -120,34 +120,28 @@ describe('HotkeyEditor reset all confirmation', () => { }) }) - it('restores partial conflict overwrites when capture is cancelled', async () => { - useSettingsStore.setState({ - hotkeyOverrides: { - PLAY_PAUSE: 'shift+k', - PREVIOUS_FRAME: 'right', - }, + it('repairs duplicate overrides before presenting conflict choices', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ + PLAY_PAUSE: 'shift+space', + PREVIOUS_FRAME: 'right', + }) + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + PLAY_PAUSE: 'shift+space', }) click(getButton('Record')) await waitForText('Listening') keyDown('ArrowRight', 'ArrowRight') - await waitForBodyText('Conflicts with Previous frame') await waitForBodyText('Conflicts with Next frame') - click(getButton('Overwrite')) - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(useSettingsStore.getState().hotkeyOverrides).not.toEqual({ - PLAY_PAUSE: 'shift+k', - PREVIOUS_FRAME: 'right', - }) + expect(document.body.textContent).not.toContain('Conflicts with Previous frame') keyDown('Escape', 'Escape') await new Promise((resolve) => setTimeout(resolve, 0)) expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ - PLAY_PAUSE: 'shift+k', - PREVIOUS_FRAME: 'right', + PLAY_PAUSE: 'shift+space', }) }) From 9507e63673b5346ceb6fa5d8e31f7b1af8cea4db Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:41:03 -0700 Subject: [PATCH 43/64] refactor(shortcuts): keep conflict resolution bounded (cherry picked from commit d514f1909be62d568ac80eb0ebab9df2d1d11729) --- src/config/hotkeys.ts | 99 ++++++++++--------- src/features/editor/host/contract.ts | 4 +- .../settings/stores/settings-store.ts | 5 +- 3 files changed, 56 insertions(+), 52 deletions(-) diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index 099de6b3c..ce91fa8de 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -421,11 +421,10 @@ function getHotkeyPlatform(platformValue?: string): HotkeyPlatform { } export function resolveHotkeyConfiguration(overrides: unknown = {}): HotkeyResolution { - const requested = normalizeHotkeyOverrides(overrides) + const requested = sanitizeHotkeyOverrides(overrides) const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[] const rejectedOverrides = new Set() - let bindings = {} as HotkeyBindingMap - const effectiveOverrides: HotkeyOverrideMap = {} + let bindings = createResolvedHotkeyBindings(commandKeys, requested, rejectedOverrides) const warnings: HotkeyConflictWarning[] = [] // Resolve the complete candidate map before assigning priority. This accepts @@ -433,52 +432,62 @@ export function resolveHotkeyConfiguration(overrides: unknown = {}): HotkeyResol // the participating custom binding(s) back to their unique canonical defaults. // Re-run because one fallback can expose a collision with another custom value. while (true) { - bindings = Object.fromEntries( - commandKeys.map((key) => [ - key, - !rejectedOverrides.has(key) && key in requested ? requested[key]! : HOTKEYS[key], - ]), - ) as HotkeyBindingMap - - const conflicts = Object.values(getHotkeyConflictMap(bindings)).filter( - (commands) => commands.length > 1, - ) + const conflicts = getDuplicateHotkeyCommandGroups(bindings) if (conflicts.length === 0) break - let rejectedInPass = false - for (const commands of conflicts) { - for (const key of commands) { - if (rejectedOverrides.has(key) || !(key in requested)) continue - - const requestedBinding = requested[key]! - if (requestedBinding === HOTKEYS[key]) continue - - const conflictingCommand = commands.find((command) => command !== key)! - rejectedOverrides.add(key) - warnings.push({ - code: 'duplicate_binding', - command: key, - binding: normalizeHotkeyBinding(requestedBinding), - resolution: 'fallback', - conflictingCommand, - }) - rejectedInPass = true - } - } - - if (!rejectedInPass) { + const passWarnings = createConflictFallbackWarnings(conflicts, requested, rejectedOverrides) + if (passWarnings.length === 0) { throw new Error('Default keyboard shortcut bindings must be unique') } + for (const warning of passWarnings) rejectedOverrides.add(warning.command) + warnings.push(...passWarnings) + bindings = createResolvedHotkeyBindings(commandKeys, requested, rejectedOverrides) } - for (const key of commandKeys) { - const binding = bindings[key] - if (binding !== HOTKEYS[key]) { - effectiveOverrides[key] = binding - } - } + return { bindings, overrides: getEffectiveHotkeyOverrides(bindings), warnings } +} + +function createResolvedHotkeyBindings( + commandKeys: HotkeyKey[], + requested: HotkeyOverrideMap, + rejected: Set, +): HotkeyBindingMap { + return Object.fromEntries( + commandKeys.map((key) => [ + key, + !rejected.has(key) && key in requested ? requested[key]! : HOTKEYS[key], + ]), + ) as HotkeyBindingMap +} - return { bindings, overrides: effectiveOverrides, warnings } +function getDuplicateHotkeyCommandGroups(bindings: HotkeyBindingMap): HotkeyKey[][] { + return Object.values(getHotkeyConflictMap(bindings)).filter((commands) => commands.length > 1) +} + +function createConflictFallbackWarnings( + conflicts: HotkeyKey[][], + requested: HotkeyOverrideMap, + rejected: Set, +): HotkeyConflictWarning[] { + return conflicts.flatMap((commands) => + commands + .filter((key) => !rejected.has(key) && key in requested && requested[key] !== HOTKEYS[key]) + .map((key) => ({ + code: 'duplicate_binding' as const, + command: key, + binding: normalizeHotkeyBinding(requested[key]!), + resolution: 'fallback' as const, + conflictingCommand: commands.find((command) => command !== key)!, + })), + ) +} + +function getEffectiveHotkeyOverrides(bindings: HotkeyBindingMap): HotkeyOverrideMap { + return Object.fromEntries( + (Object.keys(HOTKEYS) as HotkeyKey[]) + .filter((key) => bindings[key] !== HOTKEYS[key]) + .map((key) => [key, bindings[key]]), + ) } export function resolveHotkeys(overrides: HotkeyOverrideMap = {}): HotkeyBindingMap { @@ -604,10 +613,6 @@ export function normalizeHotkeyBinding(binding: string): string { } export function sanitizeHotkeyOverrides(overrides: unknown): HotkeyOverrideMap { - return resolveHotkeyConfiguration(overrides).overrides -} - -function normalizeHotkeyOverrides(overrides: unknown): HotkeyOverrideMap { if (!overrides || typeof overrides !== 'object') { return {} } @@ -779,7 +784,7 @@ export function findHotkeyConflicts( export function createHotkeyExportDocument( overrides: HotkeyOverrideMap = {}, ): HotkeyExportDocument { - const normalizedOverrides = sanitizeHotkeyOverrides(overrides) + const normalizedOverrides = resolveHotkeyConfiguration(overrides).overrides const bindings = resolveHotkeys(normalizedOverrides) const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[] diff --git a/src/features/editor/host/contract.ts b/src/features/editor/host/contract.ts index 294dcc9aa..5de309978 100644 --- a/src/features/editor/host/contract.ts +++ b/src/features/editor/host/contract.ts @@ -7,7 +7,7 @@ import type { TimelineRevision, } from '@/features/editor/codepress/contract' import type { FreeCutFrameDocument } from '@/features/editor/codepress/document' -import { sanitizeHotkeyOverrides, type HotkeyOverrideMap } from '@/config/hotkeys' +import { resolveHotkeyConfiguration, type HotkeyOverrideMap } from '@/config/hotkeys' /** * The browser surface is deliberately a port. It knows how to render and @@ -351,7 +351,7 @@ export function createHostShortcutSettings( return { schema: HOST_SHORTCUTS_SCHEMA, version: HOST_SHORTCUTS_VERSION, - overrides: sanitizeHotkeyOverrides(overrides), + overrides: resolveHotkeyConfiguration(overrides).overrides, } } diff --git a/src/features/settings/stores/settings-store.ts b/src/features/settings/stores/settings-store.ts index 5170fa21b..a4f9d4ea0 100644 --- a/src/features/settings/stores/settings-store.ts +++ b/src/features/settings/stores/settings-store.ts @@ -13,7 +13,6 @@ import { HOTKEYS, normalizeHotkeyBinding, resolveHotkeyConfiguration, - sanitizeHotkeyOverrides, type HotkeyKey, type HotkeyOverrideMap, } from '@/config/hotkeys' @@ -254,7 +253,7 @@ export const useSettingsStore = create()( replaceHotkeyOverrides: (overrides) => set((state) => { - const normalizedOverrides = sanitizeHotkeyOverrides(overrides) + const normalizedOverrides = resolveHotkeyConfiguration(overrides).overrides if (areHotkeyOverridesEqual(state.hotkeyOverrides, normalizedOverrides)) { return state @@ -315,7 +314,7 @@ export const useSettingsStore = create()( ...currentState, ...typedState, defaultWhisperModel: normalizeSelectableWhisperModel(typedState.defaultWhisperModel), - hotkeyOverrides: sanitizeHotkeyOverrides(typedState.hotkeyOverrides), + hotkeyOverrides: resolveHotkeyConfiguration(typedState.hotkeyOverrides).overrides, editorDensity: normalizeEditorDensityPreset(typedState.editorDensity), captioningIntervalUnit, captioningIntervalValue: clampCaptioningIntervalValue( From eada890148ef7897906744c7779f3ff8a1c43081 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:44:20 -0700 Subject: [PATCH 44/64] fix(host): surface shortcut conflict notices (cherry picked from commit 541085cd313f452de9038b9fd8a02ebcf6a4b70f) --- src/features/editor/host/contract.ts | 4 ++-- src/features/editor/host/shortcut-settings.test.ts | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/features/editor/host/contract.ts b/src/features/editor/host/contract.ts index 5de309978..294dcc9aa 100644 --- a/src/features/editor/host/contract.ts +++ b/src/features/editor/host/contract.ts @@ -7,7 +7,7 @@ import type { TimelineRevision, } from '@/features/editor/codepress/contract' import type { FreeCutFrameDocument } from '@/features/editor/codepress/document' -import { resolveHotkeyConfiguration, type HotkeyOverrideMap } from '@/config/hotkeys' +import { sanitizeHotkeyOverrides, type HotkeyOverrideMap } from '@/config/hotkeys' /** * The browser surface is deliberately a port. It knows how to render and @@ -351,7 +351,7 @@ export function createHostShortcutSettings( return { schema: HOST_SHORTCUTS_SCHEMA, version: HOST_SHORTCUTS_VERSION, - overrides: resolveHotkeyConfiguration(overrides).overrides, + overrides: sanitizeHotkeyOverrides(overrides), } } diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index 937d34df9..2e104e1ef 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -231,14 +231,12 @@ describe('host shortcut settings round trip', () => { }) it('resolves a host collision so capture and bubbling listeners fire one intended action', async () => { - const harness = createShortcutHost({ - schema: 'freecut-host-shortcuts', - version: 1, - overrides: { + const harness = createShortcutHost( + createHostShortcutSettings({ SHUTTLE_PAUSE: 'k', EDIT_KEYFRAME_ADD: 'k', - }, - }) + }), + ) const unmount = await mountHostShortcutSettings(harness.host) const addKeyframe = vi.fn() From 69a975dbc4c49d76666b9ad8660fbb5a26903a7c Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 02:52:53 -0700 Subject: [PATCH 45/64] fix(shortcuts): reconcile runtime ownership (cherry picked from commit 0f5602d9de745faa6186e17e915a125efe3e1f69) --- src/config/hotkeys-dom-guard.test.ts | 111 ++++++++++++- src/config/hotkeys.test.ts | 44 ++++++ src/config/hotkeys.ts | 149 +++++++++++++++--- .../editor/host/shortcut-settings.test.ts | 121 ++++++++++++++ src/features/editor/host/shortcut-settings.ts | 102 +++++++++--- .../hotkey-editor-reset-dialog.test.tsx | 28 ++++ .../timeline-item/trim-handles.test.tsx | 47 +++++- .../components/timeline-item/trim-handles.tsx | 8 +- .../shortcuts/runtime-conflicts.test.tsx | 71 +++++++++ .../hooks/shortcuts/use-in-out-shortcuts.ts | 25 +-- 10 files changed, 637 insertions(+), 69 deletions(-) create mode 100644 src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx diff --git a/src/config/hotkeys-dom-guard.test.ts b/src/config/hotkeys-dom-guard.test.ts index cb34ee8ae..8419e5721 100644 --- a/src/config/hotkeys-dom-guard.test.ts +++ b/src/config/hotkeys-dom-guard.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { createElement } from 'react' +import { createElement, type ReactNode } from 'react' import { render, screen } from '@testing-library/react' import { useHotkeys } from 'react-hotkeys-hook' import { afterEach, describe, expect, it, vi } from 'vite-plus/test' @@ -18,6 +18,19 @@ function CaptureHotkeyHarness({ onHotkey }: { onHotkey: () => void }) { ) } +function GlobalCaptureHarness({ + onHotkey, + children, +}: { + onHotkey: () => void + children?: ReactNode +}) { + useHotkeys('k', onHotkey, { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } }, [ + onHotkey, + ]) + return children ?? null +} + describe('global shortcut DOM guards', () => { afterEach(() => { document.body.replaceChildren() @@ -60,6 +73,50 @@ describe('global shortcut DOM guards', () => { expect(result).toEqual({ captureSawEvent: true, defaultPrevented: false }) }) + it.each([ + ['native button', ''], + ['native link', 'Project'], + ['summary', '
Details
'], + ['button role', '
Run
'], + ['menuitem role', ''], + ])('guards an interactive %s outside dialogs', (_name, markup) => { + expect(dispatchFrom(markup, '#control', 'k')).toEqual({ + captureSawEvent: true, + defaultPrevented: false, + }) + }) + + it('guards every dialog descendant, even when the target is a plain span', () => { + expect( + dispatchFrom('
Message
', '#control', 'j'), + ).toEqual({ captureSawEvent: true, defaultPrevented: false }) + }) + + it('uses the nearest contenteditable value for inherited editing and false islands', () => { + expect( + dispatchFrom( + '
text
', + '#editable', + 'j', + ), + ).toEqual({ captureSawEvent: true, defaultPrevented: false }) + + expect( + dispatchFrom( + '
clip
', + '#island', + 'j', + ), + ).toEqual({ captureSawEvent: true, defaultPrevented: true }) + }) + + it('keeps ordinary canvas targets eligible for editor shortcuts', () => { + expect(dispatchFrom('', '#timeline', 'k')).toEqual({ + captureSawEvent: true, + defaultPrevented: true, + }) + }) + it('allows an explicitly opted-in dialog control', () => { const result = dispatchFrom( '
', @@ -126,4 +183,56 @@ describe('global shortcut DOM guards', () => { expect(event.defaultPrevented).toBe(false) expect(bubble).toHaveBeenCalledTimes(1) }) + + it('keeps the real capture listener inert on a native button without swallowing bubbling', () => { + const onHotkey = vi.fn() + const rendered = render( + createElement( + GlobalCaptureHarness, + { onHotkey }, + createElement('button', { type: 'button' }, 'Run'), + ), + ) + const target = screen.getByRole('button', { name: 'Run' }) + const bubble = vi.fn() + document.body.addEventListener('keydown', bubble) + const event = new KeyboardEvent('keydown', { + key: 'k', + code: 'KeyK', + bubbles: true, + cancelable: true, + }) + + target.dispatchEvent(event) + + document.body.removeEventListener('keydown', bubble) + rendered.unmount() + expect(onHotkey).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + expect(bubble).toHaveBeenCalledTimes(1) + }) + + it('executes one real capture handler once on an ordinary canvas', () => { + const onHotkey = vi.fn() + const rendered = render( + createElement( + GlobalCaptureHarness, + { onHotkey }, + createElement('canvas', { 'aria-label': 'Timeline canvas' }), + ), + ) + const target = screen.getByLabelText('Timeline canvas') + const event = new KeyboardEvent('keydown', { + key: 'k', + code: 'KeyK', + bubbles: true, + cancelable: true, + }) + + target.dispatchEvent(event) + + rendered.unmount() + expect(onHotkey).toHaveBeenCalledTimes(1) + expect(event.defaultPrevented).toBe(true) + }) }) diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index f63556ec7..7af7efecb 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -149,6 +149,12 @@ describe('findHotkeyConflicts', () => { expect(findHotkeyConflicts(bindings, 'c', 'SELECTION_TOOL')).toEqual(['RAZOR_TOOL']) }) + + it('exposes derived preview variants that collide with runtime commands', () => { + const bindings = resolveHotkeys() + + expect(findHotkeyConflicts(bindings, 'j', 'MARK_IN')).toContain('JOIN_ITEMS') + }) }) describe('resolveHotkeyConfiguration', () => { @@ -195,6 +201,44 @@ describe('resolveHotkeyConfiguration', () => { expect(result.overrides).toEqual({ PLAY_PAUSE: 'k', SHUTTLE_PAUSE: 'space' }) expect(result.warnings).toEqual([]) }) + + it('rejects a MARK_IN and shuttle reverse swap that derives the JOIN_ITEMS chord', () => { + const result = resolveHotkeyConfiguration({ + MARK_IN: 'j', + SHUTTLE_REVERSE: 'i', + }) + + expect(result.bindings.MARK_IN).toBe('i') + expect(result.bindings.SHUTTLE_REVERSE).toBe('j') + expect(result.bindings.JOIN_ITEMS).toBe('shift+j') + expect(result.overrides).toEqual({}) + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: 'MARK_IN', + binding: 'shift+j', + conflictingCommand: 'JOIN_ITEMS', + resolution: 'fallback', + }), + expect.objectContaining({ + command: 'SHUTTLE_REVERSE', + binding: 'i', + conflictingCommand: 'MARK_IN', + resolution: 'fallback', + }), + ]), + ) + }) + + it('keeps ordinary remaps whose direct and derived runtime chords are unique', () => { + const result = resolveHotkeyConfiguration({ + MARK_IN: 'q', + SHUTTLE_REVERSE: 'g', + }) + + expect(result.overrides).toEqual({ MARK_IN: 'q', SHUTTLE_REVERSE: 'g' }) + expect(result.warnings).toEqual([]) + }) }) describe('sanitizeHotkeyOverrides', () => { diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index ce91fa8de..eec17a8f6 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -156,6 +156,14 @@ export interface HotkeyResolution { warnings: HotkeyConflictWarning[] } +type RuntimeHotkeyVariant = 'primary' | 'preview' + +interface RuntimeHotkeyClaim { + command: HotkeyKey + binding: string + variant: RuntimeHotkeyVariant +} + export interface BrowserHostileHotkey { binding: string browserAction: string @@ -432,7 +440,7 @@ export function resolveHotkeyConfiguration(overrides: unknown = {}): HotkeyResol // the participating custom binding(s) back to their unique canonical defaults. // Re-run because one fallback can expose a collision with another custom value. while (true) { - const conflicts = getDuplicateHotkeyCommandGroups(bindings) + const conflicts = getDuplicateRuntimeHotkeyGroups(bindings) if (conflicts.length === 0) break const passWarnings = createConflictFallbackWarnings(conflicts, requested, rejectedOverrides) @@ -460,26 +468,30 @@ function createResolvedHotkeyBindings( ) as HotkeyBindingMap } -function getDuplicateHotkeyCommandGroups(bindings: HotkeyBindingMap): HotkeyKey[][] { - return Object.values(getHotkeyConflictMap(bindings)).filter((commands) => commands.length > 1) +function getDuplicateRuntimeHotkeyGroups(bindings: HotkeyBindingMap): RuntimeHotkeyClaim[][] { + return Object.values(getRuntimeHotkeyConflictGraph(bindings)).filter( + (claims) => new Set(claims.map((claim) => claim.command)).size > 1, + ) } function createConflictFallbackWarnings( - conflicts: HotkeyKey[][], + conflicts: RuntimeHotkeyClaim[][], requested: HotkeyOverrideMap, rejected: Set, ): HotkeyConflictWarning[] { - return conflicts.flatMap((commands) => - commands + return conflicts.flatMap((claims) => { + const commands = [...new Set(claims.map((claim) => claim.command))] + const collisionBinding = claims[0]!.binding + return commands .filter((key) => !rejected.has(key) && key in requested && requested[key] !== HOTKEYS[key]) .map((key) => ({ code: 'duplicate_binding' as const, command: key, - binding: normalizeHotkeyBinding(requested[key]!), + binding: collisionBinding, resolution: 'fallback' as const, conflictingCommand: commands.find((command) => command !== key)!, - })), - ) + })) + }) } function getEffectiveHotkeyOverrides(bindings: HotkeyBindingMap): HotkeyOverrideMap { @@ -750,22 +762,63 @@ export function getHotkeyBindingFromEventData(eventData: HotkeyEventData): strin return normalizeHotkeyBinding(tokens.join('+')) } -function getHotkeyConflictMap(bindings: HotkeyBindingMap): Record { - const conflicts: Record = {} +function addShiftModifier(binding: string): string { + const tokens = splitHotkeyBinding(binding) + if (tokens.includes('shift')) return normalizeHotkeyBinding(binding) + const key = tokens.pop() + if (!key) return '' + return normalizeHotkeyBinding([...tokens, 'shift', key].join('+')) +} - for (const [key, binding] of Object.entries(bindings) as [HotkeyKey, string][]) { - const normalizedBinding = normalizeHotkeyBinding(binding) - if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - continue +function getCommandRuntimeHotkeyClaims(command: HotkeyKey, binding: string): RuntimeHotkeyClaim[] { + const normalizedBinding = normalizeHotkeyBinding(binding) + if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) return [] + + const claims: RuntimeHotkeyClaim[] = [{ command, binding: normalizedBinding, variant: 'primary' }] + if (command === 'MARK_IN' || command === 'MARK_OUT') { + const previewBinding = addShiftModifier(normalizedBinding) + if (previewBinding) { + claims.push({ command, binding: previewBinding, variant: 'preview' }) } + } + return claims +} - conflicts[normalizedBinding] ??= [] - conflicts[normalizedBinding].push(key) +/** + * Canonical graph of every physical chord registered at runtime, including + * modifier-derived variants. Claim insertion order is the deterministic owner + * order when defensive runtime claiming sees an unresolved collision. + */ +function getRuntimeHotkeyConflictGraph( + bindings: HotkeyBindingMap, +): Record { + const conflicts: Record = {} + + for (const [key, binding] of Object.entries(bindings) as [HotkeyKey, string][]) { + for (const claim of getCommandRuntimeHotkeyClaims(key, binding)) { + const bindingClaims = conflicts[claim.binding] ?? [] + bindingClaims.push(claim) + conflicts[claim.binding] = bindingClaims + } } return conflicts } +export function getRuntimeHotkeyBinding( + bindings: HotkeyBindingMap, + command: HotkeyKey, + variant: RuntimeHotkeyVariant = 'primary', +): string | null { + const claim = getCommandRuntimeHotkeyClaims(command, bindings[command]).find( + (candidate) => candidate.variant === variant, + ) + if (!claim) return null + + const owner = getRuntimeHotkeyConflictGraph(bindings)[claim.binding]?.[0] + return owner?.command === command && owner.variant === variant ? claim.binding : null +} + export function findHotkeyConflicts( bindings: HotkeyBindingMap, binding: string, @@ -776,9 +829,25 @@ export function findHotkeyConflicts( return [] } - return (getHotkeyConflictMap(bindings)[normalizedBinding] ?? []).filter( - (key) => key !== currentKey, - ) + if (!currentKey) { + return [ + ...new Set( + (getRuntimeHotkeyConflictGraph(bindings)[normalizedBinding] ?? []).map( + (claim) => claim.command, + ), + ), + ] + } + + const candidateBindings = { ...bindings, [currentKey]: normalizedBinding } + const graph = getRuntimeHotkeyConflictGraph(candidateBindings) + const conflicts = new Set() + for (const claim of getCommandRuntimeHotkeyClaims(currentKey, normalizedBinding)) { + for (const candidate of graph[claim.binding] ?? []) { + if (candidate.command !== currentKey) conflicts.add(candidate.command) + } + } + return (Object.keys(HOTKEYS) as HotkeyKey[]).filter((key) => conflicts.has(key)) } export function createHotkeyExportDocument( @@ -989,11 +1058,43 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { const GLOBAL_HOTKEY_OPT_IN = '[data-global-hotkeys="allow"]' const DIALOG_SELECTOR = '[role="dialog"], dialog' -const FORM_CONTROL_SELECTOR = 'input, textarea, select' +const INTERACTIVE_CONTROL_SELECTOR = [ + 'button', + 'a[href]', + 'summary', + 'input', + 'textarea', + 'select', + 'option', + 'audio[controls]', + 'video[controls]', + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="option"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[role="tab"]', + '[role="treeitem"]', + '[role="slider"]', + '[role="spinbutton"]', + '[role="textbox"]', + '[role="searchbox"]', + '[role="combobox"]', + '[role="listbox"]', +].join(', ') function isContentEditableTarget(target: Element): boolean { - const editable = target.closest('[contenteditable]') - return editable !== null && editable.getAttribute('contenteditable') !== 'false' + for (let current: Element | null = target; current; current = current.parentElement) { + if (!current.hasAttribute('contenteditable')) continue + const value = current.getAttribute('contenteditable')?.trim().toLowerCase() ?? '' + if (value === 'false') return false + if (value === '' || value === 'true' || value === 'plaintext-only') return true + } + return false } /** @@ -1006,7 +1107,7 @@ export function shouldIgnoreGlobalHotkey(event: KeyboardEvent): boolean { if (typeof Element === 'undefined' || !(target instanceof Element)) return false if (target.closest(GLOBAL_HOTKEY_OPT_IN)) return false if (isContentEditableTarget(target)) return true - if (target.closest(FORM_CONTROL_SELECTOR)) return true + if (target.closest(INTERACTIVE_CONTROL_SELECTOR)) return true return target.closest(DIALOG_SELECTOR) !== null } diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index 2e104e1ef..7af4ab4e8 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -59,6 +59,16 @@ function createShortcutHost(initial: HostShortcutSettings) { } } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + describe('host shortcut settings round trip', () => { beforeEach(() => { useSettingsStore.getState().resetHotkeys() @@ -222,6 +232,97 @@ describe('host shortcut settings round trip', () => { unmount() }) + it('reconciles newer subscribed state after an older write finishes last', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const firstWrite = createDeferred() + host.setSettings.mockReturnValueOnce(firstWrite.promise) + const unmount = await mountHostShortcutSettings(host.host) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await waitFor(() => expect(host.setSettings).toHaveBeenCalledTimes(1)) + + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'w' })) + firstWrite.resolve() + + await waitFor(() => + expect(host.setSettings).toHaveBeenLastCalledWith( + createHostShortcutSettings({ SHUTTLE_PAUSE: 'w' }), + ), + ) + expect(host.setSettings).toHaveBeenCalledTimes(2) + unmount() + }) + + it('retries the newest subscribed state after an older write rejects', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const firstWrite = createDeferred() + host.setSettings.mockReturnValueOnce(firstWrite.promise) + const unmount = await mountHostShortcutSettings(host.host) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await waitFor(() => expect(host.setSettings).toHaveBeenCalledTimes(1)) + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'w' })) + firstWrite.reject(new Error('old write failed')) + + await waitFor(() => + expect(host.setSettings).toHaveBeenLastCalledWith( + createHostShortcutSettings({ SHUTTLE_PAUSE: 'w' }), + ), + ) + expect(host.notify).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'error', message: expect.stringContaining('save') }), + ) + unmount() + }) + + it('fences in-flight host A work when host B replaces it', async () => { + const hostA = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'a' })) + const firstWrite = createDeferred() + hostA.setSettings.mockReturnValueOnce(firstWrite.promise) + const unmountA = await mountHostShortcutSettings(hostA.host) + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await waitFor(() => expect(hostA.setSettings).toHaveBeenCalledTimes(1)) + + const hostB = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'b' })) + const unmountB = await mountHostShortcutSettings(hostB.host) + expect(hostA.listenerCount()).toBe(0) + expect(hostB.listenerCount()).toBe(1) + + hostA.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'z' })) + firstWrite.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(hostA.setSettings).toHaveBeenCalledTimes(1) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'b' }) + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'y') + await waitFor(() => expect(hostB.setSettings).toHaveBeenCalledTimes(1)) + + unmountA() + expect(hostB.listenerCount()).toBe(1) + unmountB() + expect(hostB.listenerCount()).toBe(0) + }) + + it('suppresses equal subscription echoes without a redundant write loop', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const write = createDeferred() + host.setSettings.mockReturnValueOnce(write.promise) + const unmount = await mountHostShortcutSettings(host.host) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await waitFor(() => expect(host.setSettings).toHaveBeenCalledTimes(1)) + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'x' })) + write.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(host.setSettings).toHaveBeenCalledTimes(1) + expect(host.listenerCount()).toBe(1) + unmount() + expect(host.listenerCount()).toBe(0) + }) + it('removes the host subscriber on unmount', async () => { const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) const unmount = await mountHostShortcutSettings(host.host) @@ -252,4 +353,24 @@ describe('host shortcut settings round trip', () => { unmount() }) + + it('retains the last valid settings and reports derived host conflict metadata', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) + const harness = createShortcutHost( + createHostShortcutSettings({ + MARK_IN: 'j', + SHUTTLE_REVERSE: 'i', + }), + ) + + const unmount = await mountHostShortcutSettings(harness.host) + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ PLAY_PAUSE: 'shift+space' }) + expect(harness.notify).toHaveBeenCalledWith({ + kind: 'conflict', + message: expect.stringMatching(/shift\+j.*MARK_IN.*JOIN_ITEMS.*last valid/i), + }) + expect(harness.setSettings).not.toHaveBeenCalled() + unmount() + }) }) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts index bb5e99f80..d34ba86a3 100644 --- a/src/features/editor/host/shortcut-settings.ts +++ b/src/features/editor/host/shortcut-settings.ts @@ -34,6 +34,7 @@ function copyOverrides(overrides: HotkeyOverrideMap): HotkeyOverrideMap { interface ShortcutOwnership { epoch: number standaloneOverrides: HotkeyOverrideMap + dispose?: () => void } let nextOwnershipEpoch = 0 @@ -47,17 +48,18 @@ export async function mountHostShortcutSettings( host: EditorHost, signal?: AbortSignal, ): Promise<() => void> { + const previousOwnership = currentOwnership + const standaloneOverrides = copyOverrides( + previousOwnership?.standaloneOverrides ?? useSettingsStore.getState().hotkeyOverrides, + ) + previousOwnership?.dispose?.() const ownership: ShortcutOwnership = { epoch: ++nextOwnershipEpoch, - standaloneOverrides: copyOverrides( - currentOwnership?.standaloneOverrides ?? useSettingsStore.getState().hotkeyOverrides, - ), + standaloneOverrides, } currentOwnership = ownership let applyingHostSettings = false let disposed = false - let inboundRevision = 0 - let writeQueue = Promise.resolve() let unsubscribeHost: (() => void) | undefined let unsubscribeStore: (() => void) | undefined @@ -66,7 +68,6 @@ export async function mountHostShortcutSettings( const dispose = () => { if (disposed) return disposed = true - inboundRevision += 1 unsubscribeStore?.() unsubscribeHost?.() signal?.removeEventListener('abort', dispose) @@ -74,6 +75,7 @@ export async function mountHostShortcutSettings( currentOwnership = null useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides) } + ownership.dispose = dispose if (signal?.aborted) { dispose() @@ -95,20 +97,75 @@ export async function mountHostShortcutSettings( host.notify?.({ kind: 'error', message }) } + const settingsEqual = (left: HostShortcutSettings, right: HostShortcutSettings) => { + const leftKeys = Object.keys(left.overrides) + const rightKeys = Object.keys(right.overrides) + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => + left.overrides[key as keyof HotkeyOverrideMap] === + right.overrides[key as keyof HotkeyOverrideMap], + ) + ) + } + + let desiredSettings: HostShortcutSettings | null = null + let settledSettings: HostShortcutSettings | null = null + let inFlightSettings: HostShortcutSettings | null = null + let reconcileAfterFlight = false + let reconcileScheduled = false + + const canStartReconcile = () => { + if (!isCurrent()) return false + if (inFlightSettings || !desiredSettings) return false + if (reconcileAfterFlight || !settledSettings) return true + return !settingsEqual(desiredSettings, settledSettings) + } + + const finishReconcile = (settingsToWrite: HostShortcutSettings, succeeded: boolean) => { + if (!isCurrent()) return + if (succeeded) settledSettings = settingsToWrite + const desiredChanged = + desiredSettings !== null && !settingsEqual(desiredSettings, settingsToWrite) + inFlightSettings = null + if (desiredChanged || reconcileAfterFlight) scheduleReconcile() + } + + const persistDesiredSettings = async () => { + reconcileScheduled = false + if (!canStartReconcile()) return + + const settingsToWrite = desiredSettings! + inFlightSettings = settingsToWrite + reconcileAfterFlight = false + let succeeded = false + try { + await Promise.resolve(port.setSettings(settingsToWrite)) + succeeded = true + } catch { + if (isCurrent()) reportFailure('Could not save keyboard shortcuts to the host.') + } + finishReconcile(settingsToWrite, succeeded) + } + + function scheduleReconcile() { + if (reconcileScheduled || inFlightSettings || !desiredSettings) return + reconcileScheduled = true + void Promise.resolve().then(persistDesiredSettings) + } + const applyHostSettings = (settings: HostShortcutSettings) => { if (!isCurrent()) return const normalized = normalizeHostShortcutSettings(settings) - inboundRevision += 1 if (normalized.warnings.length > 0) { for (const warning of normalized.warnings) { host.notify?.({ kind: 'conflict', - message: - warning.resolution === 'fallback' - ? `Shortcut conflict for ${warning.command}; using its default binding.` - : `Shortcut conflict for ${warning.command}; the binding was disabled.`, + message: `Shortcut ${warning.binding} for ${warning.command} conflicts with ${warning.conflictingCommand}; retained the last valid shortcut settings.`, }) } + return } applyingHostSettings = true try { @@ -116,6 +173,14 @@ export async function mountHostShortcutSettings( } finally { applyingHostSettings = false } + desiredSettings = normalized.settings + if (inFlightSettings) { + reconcileAfterFlight = !settingsEqual(inFlightSettings, normalized.settings) + } else { + // A subscription is the host's persisted authority unless an older write + // can still complete after it and overwrite that state. + settledSettings = normalized.settings + } } let initialSettings: HostShortcutSettings @@ -128,6 +193,10 @@ export async function mountHostShortcutSettings( if (!isCurrent()) { return dispose } + desiredSettings = createHostShortcutSettings( + copyOverrides(useSettingsStore.getState().hotkeyOverrides), + ) + settledSettings = initialSettings applyHostSettings(initialSettings) unsubscribeHost = port.subscribe?.((settings) => { @@ -149,15 +218,8 @@ export async function mountHostShortcutSettings( } const settings = createHostShortcutSettings(copyOverrides(state.hotkeyOverrides)) - const revisionAtQueue = inboundRevision - writeQueue = writeQueue - .then(() => { - if (!isCurrent() || inboundRevision !== revisionAtQueue) return undefined - return Promise.resolve(port.setSettings(settings)) - }) - .catch(() => { - if (isCurrent()) reportFailure('Could not save keyboard shortcuts to the host.') - }) + desiredSettings = settings + scheduleReconcile() }) return dispose diff --git a/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx b/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx index bb3124215..0ca0603e5 100644 --- a/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx +++ b/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx @@ -36,6 +36,15 @@ function getButton(name: string): HTMLButtonElement { return button as HTMLButtonElement } +function getButtonContaining(text: string): HTMLButtonElement { + const button = [...document.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes(text), + ) + + expect(button).toBeTruthy() + return button as HTMLButtonElement +} + async function waitForText(text: string): Promise { for (let attempt = 0; attempt < 10; attempt += 1) { const element = [...document.querySelectorAll('body *')].find( @@ -145,6 +154,25 @@ describe('HotkeyEditor reset all confirmation', () => { }) }) + it('displays and rejects a conflict caused by the derived Shift preview chord', async () => { + const searchInput = document.querySelector( + 'input[placeholder="Search commands or shortcuts"]', + ) as HTMLInputElement | null + expect(searchInput).toBeTruthy() + changeInput(searchInput!, 'mark in') + await waitForText('1 result') + click(getButtonContaining('Mark In point')) + await new Promise((resolve) => setTimeout(resolve, 0)) + + click(getButton('Record')) + await waitForText('Listening') + keyDown('j', 'KeyJ') + + await waitForBodyText('Conflicts with Join selected clips') + expect(getButton('Save').disabled).toBe(true) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ PLAY_PAUSE: 'shift+k' }) + }) + it('keeps unbind explicit and disables it once the selected command is unassigned', async () => { click(getButton('Unbind')) await new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/src/features/timeline/components/timeline-item/trim-handles.test.tsx b/src/features/timeline/components/timeline-item/trim-handles.test.tsx index 4ab108638..a30618976 100644 --- a/src/features/timeline/components/timeline-item/trim-handles.test.tsx +++ b/src/features/timeline/components/timeline-item/trim-handles.test.tsx @@ -1,5 +1,6 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vite-plus/test' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { useSettingsStore } from '@/features/timeline/deps/settings' import { TrimHandles } from './trim-handles' import { VideoFadeHandles } from './video-fade-handles' import { AudioFadeHandles } from './audio-fade-handles' @@ -27,6 +28,20 @@ describe('TrimHandles', () => { onJoinRight: vi.fn(), } + const originalPlatform = Object.getOwnPropertyDescriptor(window.navigator, 'platform') + + beforeEach(() => { + useSettingsStore.getState().resetHotkeys() + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(window.navigator, 'platform', originalPlatform) + } else { + delete (window.navigator as { platform?: string }).platform + } + }) + it('fires onTrimStart on mousedown when the left handle is visible', () => { const onTrimStart = vi.fn() render() @@ -52,6 +67,34 @@ describe('TrimHandles', () => { fireEvent.mouseDown(rightHandle!) expect(onTrimStart).toHaveBeenCalledWith(expect.any(Object), 'end') }) + + it('updates the trim join menu from the live Windows shortcut binding', async () => { + Object.defineProperty(window.navigator, 'platform', { configurable: true, value: 'Win32' }) + const { container } = render( + , + ) + const leftHandle = container.querySelector('[class*="left-0"]') + expect(leftHandle).toBeTruthy() + fireEvent.contextMenu(leftHandle!) + expect(await screen.findByText('Shift + J')).toBeInTheDocument() + + useSettingsStore.getState().setHotkeyBinding('JOIN_ITEMS', 'mod+alt+j') + + await waitFor(() => expect(screen.getByText('Ctrl + Alt + J')).toBeInTheDocument()) + }) + + it('formats a remapped trim join shortcut for macOS', async () => { + Object.defineProperty(window.navigator, 'platform', { configurable: true, value: 'MacIntel' }) + useSettingsStore.getState().setHotkeyBinding('JOIN_ITEMS', 'mod+alt+j') + const { container } = render( + , + ) + const rightHandle = container.querySelector('[class*="right-0"]') + expect(rightHandle).toBeTruthy() + fireEvent.contextMenu(rightHandle!) + + expect(await screen.findByText('Cmd + Option + J')).toBeInTheDocument() + }) }) /** diff --git a/src/features/timeline/components/timeline-item/trim-handles.tsx b/src/features/timeline/components/timeline-item/trim-handles.tsx index 0fc43bd31..f1a01749a 100644 --- a/src/features/timeline/components/timeline-item/trim-handles.tsx +++ b/src/features/timeline/components/timeline-item/trim-handles.tsx @@ -7,6 +7,8 @@ import { ContextMenuTrigger, } from '@/components/ui/context-menu' import { cn } from '@/shared/ui/cn' +import { formatHotkeyBinding } from '@/config/hotkeys' +import { useResolvedHotkeys } from '@/features/timeline/deps/settings' import type { SmartTrimIntent } from '../../utils/smart-trim-zones' import { CONSTRAINED_COLORS, @@ -93,6 +95,8 @@ export const TrimHandles = memo(function TrimHandles({ onJoinLeft, onJoinRight, }: TrimHandlesProps) { + const hotkeys = useResolvedHotkeys() + const joinShortcutLabel = formatHotkeyBinding(hotkeys.JOIN_ITEMS) const isRollingStart = smartTrimIntent === 'roll-start' const isRollingEnd = smartTrimIntent === 'roll-end' const isNeighborRollStart = rollHoverEdge === 'start' @@ -196,7 +200,7 @@ export const TrimHandles = memo(function TrimHandles({ Join - J + {joinShortcutLabel} @@ -258,7 +262,7 @@ export const TrimHandles = memo(function TrimHandles({ Join - J + {joinShortcutLabel} diff --git a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx new file mode 100644 index 000000000..68221275f --- /dev/null +++ b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { useHotkeys } from 'react-hotkeys-hook' +import { HOTKEY_OPTIONS } from '@/config/hotkeys' +import { useResolvedHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' +import { usePlaybackStore } from '@/shared/state/playback' +import { useTimelineStore } from '../../stores/timeline-store' +import { useInOutShortcuts } from './use-in-out-shortcuts' +import { usePlaybackShortcuts } from './use-playback-shortcuts' + +function RuntimeConflictHarness({ onJoin }: { onJoin: () => void }) { + const hotkeys = useResolvedHotkeys() + usePlaybackShortcuts({}) + useInOutShortcuts() + useHotkeys(hotkeys.JOIN_ITEMS, onJoin, HOTKEY_OPTIONS, [onJoin, hotkeys.JOIN_ITEMS]) + return null +} + +describe('runtime shortcut ownership', () => { + beforeEach(() => { + useSettingsStore.getState().resetHotkeys() + usePlaybackStore.setState({ + currentFrame: 48, + previewFrame: 120, + previewItemId: null, + isPlaying: false, + playbackRate: 1, + transportMode: 'normal', + }) + useTimelineStore.setState({ inPoint: null, outPoint: null }) + }) + + it('executes only JOIN_ITEMS after rejecting the exact derived-chord swap', () => { + useSettingsStore.getState().replaceHotkeyOverrides({ + MARK_IN: 'j', + SHUTTLE_REVERSE: 'i', + }) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({}) + const onJoin = vi.fn() + render() + + fireEvent.keyDown(document, { key: 'J', code: 'KeyJ', shiftKey: true }) + + expect(onJoin).toHaveBeenCalledTimes(1) + expect(useTimelineStore.getState().inPoint).toBeNull() + expect(usePlaybackStore.getState().isPlaying).toBe(false) + }) + + it('keeps ordinary remaps distinct across capture and bubble handlers', () => { + useSettingsStore.getState().replaceHotkeyOverrides({ + MARK_IN: 'q', + SHUTTLE_REVERSE: 'g', + }) + const onJoin = vi.fn() + render() + + fireEvent.keyDown(document, { key: 'Q', code: 'KeyQ', shiftKey: true }) + expect(useTimelineStore.getState().inPoint).toBe(120) + expect(onJoin).not.toHaveBeenCalled() + + fireEvent.keyDown(document, { key: 'g', code: 'KeyG' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: -1, + transportMode: 'shuttle', + }) + + fireEvent.keyDown(document, { key: 'J', code: 'KeyJ', shiftKey: true }) + expect(onJoin).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts index 7c1c75e0e..172b92eb6 100644 --- a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts @@ -3,30 +3,15 @@ */ import { useHotkeys } from 'react-hotkeys-hook' -import { HOTKEY_OPTIONS } from '@/config/hotkeys' +import { HOTKEY_OPTIONS, getRuntimeHotkeyBinding } from '@/config/hotkeys' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' import { useResolvedHotkeys } from '@/features/timeline/deps/settings' -function addShiftModifier(binding: string): string { - const parts = binding - .split('+') - .map((part) => part.trim()) - .filter(Boolean) - - if (parts.some((part) => part.toLowerCase() === 'shift')) { - return binding - } - - const key = parts.pop() - if (!key) return `shift+${binding}` - return [...parts, 'shift', key].join('+') -} - export function useInOutShortcuts() { const hotkeys = useResolvedHotkeys() - const markInAtPreview = addShiftModifier(hotkeys.MARK_IN) - const markOutAtPreview = addShiftModifier(hotkeys.MARK_OUT) + const markInAtPreview = getRuntimeHotkeyBinding(hotkeys, 'MARK_IN', 'preview') + const markOutAtPreview = getRuntimeHotkeyBinding(hotkeys, 'MARK_OUT', 'preview') useHotkeys( hotkeys.MARK_IN, @@ -40,7 +25,7 @@ export function useInOutShortcuts() { ) useHotkeys( - markInAtPreview, + markInAtPreview ?? [], (event) => { event.preventDefault() const { previewFrame, currentFrame } = usePlaybackStore.getState() @@ -62,7 +47,7 @@ export function useInOutShortcuts() { ) useHotkeys( - markOutAtPreview, + markOutAtPreview ?? [], (event) => { event.preventDefault() const { previewFrame, currentFrame } = usePlaybackStore.getState() From eed11e395145f4671f2ee0c19c8dd0ed9270dda2 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 03:20:46 -0700 Subject: [PATCH 46/64] fix(shortcuts): retry host writes and alias collisions (cherry picked from commit 0e711268ccce8f91500846e57b0f1e8d79e594dd) --- src/config/hotkeys.test.ts | 84 ++++++++- src/config/hotkeys.ts | 111 ++++++++---- .../editor/host/shortcut-settings.test.ts | 171 +++++++++++++++++- src/features/editor/host/shortcut-settings.ts | 67 ++++++- .../settings/stores/settings-store.test.ts | 12 +- .../settings/stores/settings-store.ts | 3 + .../shortcuts/runtime-conflicts.test.tsx | 39 +++- .../hooks/shortcuts/use-editing-shortcuts.ts | 7 +- 8 files changed, 437 insertions(+), 57 deletions(-) diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index 7af7efecb..1ccf86bfb 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -11,6 +11,7 @@ import { getBrowserHostileHotkey, getHotkeyBindingFromEventData, getHotkeyPrimaryTokenFromEventData, + getRuntimeHotkeyBinding, normalizeHotkeyBinding, parseHotkeyImportDocument, resolveHotkeyConfiguration, @@ -58,7 +59,9 @@ describe('transport and editing defaults', () => { describe('normalizeHotkeyBinding', () => { it('orders modifiers consistently and normalizes aliases', () => { - expect(normalizeHotkeyBinding('Shift+Ctrl+ArrowLeft')).toBe('mod+shift+left') + expect(normalizeHotkeyBinding('Shift+Ctrl+ArrowLeft')).toBe('ctrl+shift+left') + expect(normalizeHotkeyBinding('SHIFT+Command+J')).toBe('meta+shift+j') + expect(normalizeHotkeyBinding('shift+MOD+j')).toBe('mod+shift+j') }) }) @@ -70,6 +73,11 @@ describe('formatHotkeyBinding', () => { it('formats punctuation bindings for windows', () => { expect(formatHotkeyBinding('mod+shift+comma', 'Win32')).toBe('Ctrl + Shift + ,') }) + + it('preserves explicit physical modifier labels', () => { + expect(formatHotkeyBinding('ctrl+meta+k', 'MacIntel')).toBe('Ctrl + Cmd + K') + expect(formatHotkeyBinding('meta+ctrl+k', 'Win32')).toBe('Ctrl + Meta + K') + }) }) describe('getBrowserHostileHotkey', () => { @@ -155,6 +163,12 @@ describe('findHotkeyConflicts', () => { expect(findHotkeyConflicts(bindings, 'j', 'MARK_IN')).toContain('JOIN_ITEMS') }) + + it('finds platform alias overlap in primary and derived chords', () => { + const bindings = resolveHotkeys({ MARK_IN: 'meta+j' }) + + expect(findHotkeyConflicts(bindings, 'mod+shift+j', 'JOIN_ITEMS')).toContain('MARK_IN') + }) }) describe('resolveHotkeyConfiguration', () => { @@ -230,6 +244,62 @@ describe('resolveHotkeyConfiguration', () => { ) }) + it('rejects meta versus mod collisions in derived runtime chords', () => { + const result = resolveHotkeyConfiguration({ + MARK_IN: 'meta+j', + JOIN_ITEMS: 'mod+shift+j', + }) + + expect(result.overrides).toEqual({}) + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: 'MARK_IN', + conflictingCommand: 'JOIN_ITEMS', + resolution: 'fallback', + }), + ]), + ) + }) + + it('rejects ctrl versus mod collisions on Windows and Linux', () => { + const result = resolveHotkeyConfiguration({ + MARK_IN: 'ctrl+j', + JOIN_ITEMS: 'mod+shift+j', + }) + + expect(result.overrides).toEqual({}) + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: 'MARK_IN', conflictingCommand: 'JOIN_ITEMS' }), + ]), + ) + }) + + it('keeps explicit meta and ctrl chords distinct', () => { + const result = resolveHotkeyConfiguration({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_PAUSE: 'ctrl+f10', + }) + + expect(result.overrides).toEqual({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_PAUSE: 'ctrl+f10', + }) + expect(result.warnings).toEqual([]) + }) + + it('deterministically suppresses one legacy runtime alias claimant', () => { + const bindings = { + ...resolveHotkeys(), + MARK_IN: 'meta+j', + JOIN_ITEMS: 'mod+shift+j', + } + + expect(getRuntimeHotkeyBinding(bindings, 'JOIN_ITEMS')).toBe('mod+shift+j') + expect(getRuntimeHotkeyBinding(bindings, 'MARK_IN', 'preview')).toBeNull() + }) + it('keeps ordinary remaps whose direct and derived runtime chords are unique', () => { const result = resolveHotkeyConfiguration({ MARK_IN: 'q', @@ -252,7 +322,7 @@ describe('sanitizeHotkeyOverrides', () => { }), ).toEqual({ PLAY_PAUSE: 'shift+space', - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', DELETE_SELECTED: '', }) }) @@ -279,7 +349,7 @@ describe('createHotkeyExportDocument', () => { expect(exportDocument.version).toBe(HOTKEY_EXPORT_VERSION) expect(exportDocument.overrides).toEqual({ PLAY_PAUSE: 'shift+space', - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', }) expect(exportDocument.commands).toContainEqual( expect.objectContaining({ @@ -300,7 +370,7 @@ describe('createHotkeyExportDocument', () => { expect(exportDocument.commands).toContainEqual( expect.objectContaining({ id: 'EXPORT', - binding: 'mod+e', + binding: 'ctrl+e', defaultBinding: 'mod+shift+e', isCustom: true, }), @@ -362,7 +432,7 @@ describe('parseHotkeyImportDocument', () => { ).toEqual({ overrides: { PLAY_PAUSE: 'shift+space', - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', }, importedCommandCount: 2, ignoredCommandCount: 1, @@ -409,7 +479,7 @@ describe('parseHotkeyImportDocument', () => { ).toEqual({ overrides: { PLAY_PAUSE: '', - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', }, importedCommandCount: 2, ignoredCommandCount: 0, @@ -429,7 +499,7 @@ describe('parseHotkeyImportDocument', () => { ).toEqual({ overrides: { PLAY_PAUSE: 'shift+space', - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', DELETE_SELECTED: '', }, importedCommandCount: 3, diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index eec17a8f6..67a2f56ea 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -164,6 +164,10 @@ interface RuntimeHotkeyClaim { variant: RuntimeHotkeyVariant } +interface RuntimePhysicalHotkeyClaim extends RuntimeHotkeyClaim { + physicalBinding: string +} + export interface BrowserHostileHotkey { binding: string browserAction: string @@ -174,17 +178,16 @@ interface HotkeyCommandLookup { byDefaultBinding: Map } -const HOTKEY_MODIFIERS = ['mod', 'alt', 'shift'] as const +const HOTKEY_MODIFIERS = ['mod', 'ctrl', 'meta', 'alt', 'shift'] as const const HOTKEY_MODIFIER_SET = new Set(HOTKEY_MODIFIERS) const HOTKEY_MODIFIER_ORDER = new Map( HOTKEY_MODIFIERS.map((token, index) => [token, index]), ) const HOTKEY_TOKEN_ALIASES: Record = { - cmd: 'mod', - command: 'mod', - ctrl: 'mod', - control: 'mod', + cmd: 'meta', + command: 'meta', + control: 'ctrl', option: 'alt', return: 'enter', esc: 'escape', @@ -224,6 +227,23 @@ const HOTKEY_KEY_LABELS: Record = { enter: 'Enter', } +const HOTKEY_MODIFIER_LABELS: Record> = { + mac: { + mod: 'Cmd', + ctrl: 'Ctrl', + meta: 'Cmd', + alt: 'Option', + shift: 'Shift', + }, + windows: { + mod: 'Ctrl', + ctrl: 'Ctrl', + meta: 'Meta', + alt: 'Alt', + shift: 'Shift', + }, +} + const HOTKEY_CODE_TOKEN_MAP: Record = { Space: 'space', Comma: 'comma', @@ -469,9 +489,16 @@ function createResolvedHotkeyBindings( } function getDuplicateRuntimeHotkeyGroups(bindings: HotkeyBindingMap): RuntimeHotkeyClaim[][] { - return Object.values(getRuntimeHotkeyConflictGraph(bindings)).filter( - (claims) => new Set(claims.map((claim) => claim.command)).size > 1, - ) + const duplicateGroups = new Map() + for (const claims of Object.values(getRuntimeHotkeyConflictGraph(bindings))) { + if (new Set(claims.map((claim) => claim.command)).size < 2) continue + const signature = claims + .map((claim) => `${claim.command}:${claim.variant}:${claim.binding}`) + .sort() + .join('|') + if (!duplicateGroups.has(signature)) duplicateGroups.set(signature, claims) + } + return [...duplicateGroups.values()] } function createConflictFallbackWarnings( @@ -481,13 +508,12 @@ function createConflictFallbackWarnings( ): HotkeyConflictWarning[] { return conflicts.flatMap((claims) => { const commands = [...new Set(claims.map((claim) => claim.command))] - const collisionBinding = claims[0]!.binding return commands .filter((key) => !rejected.has(key) && key in requested && requested[key] !== HOTKEYS[key]) .map((key) => ({ code: 'duplicate_binding' as const, command: key, - binding: collisionBinding, + binding: claims.find((claim) => claim.command === key)!.binding, resolution: 'fallback' as const, conflictingCommand: commands.find((command) => command !== key)!, })) @@ -662,17 +688,8 @@ export function hasHotkeyPrimaryToken(binding: string): boolean { } function formatHotkeyToken(token: string, platform: HotkeyPlatform): string { - if (token === 'mod') { - return platform === 'mac' ? 'Cmd' : 'Ctrl' - } - - if (token === 'alt') { - return platform === 'mac' ? 'Option' : 'Alt' - } - - if (token === 'shift') { - return 'Shift' - } + const modifierLabel = HOTKEY_MODIFIER_LABELS[platform][token] + if (modifierLabel) return modifierLabel if (HOTKEY_KEY_LABELS[token]) { return HOTKEY_KEY_LABELS[token] @@ -702,7 +719,15 @@ export function getBrowserHostileHotkey(binding: string): BrowserHostileHotkey | return null } - return BROWSER_HOSTILE_HOTKEY_MAP.get(normalizedBinding) ?? null + const directMatch = BROWSER_HOSTILE_HOTKEY_MAP.get(normalizedBinding) + if (directMatch) return directMatch + + const portableModifierBinding = normalizeHotkeyBinding( + splitHotkeyBinding(normalizedBinding) + .map((token) => (token === 'ctrl' || token === 'meta' ? 'mod' : token)) + .join('+'), + ) + return BROWSER_HOSTILE_HOTKEY_MAP.get(portableModifierBinding) ?? null } export function getHotkeyPrimaryTokenFromEventData(eventData: HotkeyEventData): string | null { @@ -784,6 +809,17 @@ function getCommandRuntimeHotkeyClaims(command: HotkeyKey, binding: string): Run return claims } +function getPhysicalHotkeyBindings(binding: string): string[] { + const tokens = splitHotkeyBinding(binding) + return (['mac', 'windows'] as const).map((platform) => { + const physicalTokens = tokens.map((token) => { + if (token !== 'mod') return token + return platform === 'mac' ? 'meta' : 'ctrl' + }) + return `${platform}:${normalizeHotkeyBinding(physicalTokens.join('+'))}` + }) +} + /** * Canonical graph of every physical chord registered at runtime, including * modifier-derived variants. Claim insertion order is the deterministic owner @@ -791,14 +827,16 @@ function getCommandRuntimeHotkeyClaims(command: HotkeyKey, binding: string): Run */ function getRuntimeHotkeyConflictGraph( bindings: HotkeyBindingMap, -): Record { - const conflicts: Record = {} +): Record { + const conflicts: Record = {} for (const [key, binding] of Object.entries(bindings) as [HotkeyKey, string][]) { for (const claim of getCommandRuntimeHotkeyClaims(key, binding)) { - const bindingClaims = conflicts[claim.binding] ?? [] - bindingClaims.push(claim) - conflicts[claim.binding] = bindingClaims + for (const physicalBinding of getPhysicalHotkeyBindings(claim.binding)) { + const bindingClaims = conflicts[physicalBinding] ?? [] + bindingClaims.push({ ...claim, physicalBinding }) + conflicts[physicalBinding] = bindingClaims + } } } @@ -815,8 +853,12 @@ export function getRuntimeHotkeyBinding( ) if (!claim) return null - const owner = getRuntimeHotkeyConflictGraph(bindings)[claim.binding]?.[0] - return owner?.command === command && owner.variant === variant ? claim.binding : null + const graph = getRuntimeHotkeyConflictGraph(bindings) + const ownsEveryPhysicalBinding = getPhysicalHotkeyBindings(claim.binding).every((binding) => { + const owner = graph[binding]?.[0] + return owner?.command === command && owner.variant === variant + }) + return ownsEveryPhysicalBinding ? claim.binding : null } export function findHotkeyConflicts( @@ -830,10 +872,11 @@ export function findHotkeyConflicts( } if (!currentKey) { + const graph = getRuntimeHotkeyConflictGraph(bindings) return [ ...new Set( - (getRuntimeHotkeyConflictGraph(bindings)[normalizedBinding] ?? []).map( - (claim) => claim.command, + getPhysicalHotkeyBindings(normalizedBinding).flatMap((physicalBinding) => + (graph[physicalBinding] ?? []).map((claim) => claim.command), ), ), ] @@ -843,8 +886,10 @@ export function findHotkeyConflicts( const graph = getRuntimeHotkeyConflictGraph(candidateBindings) const conflicts = new Set() for (const claim of getCommandRuntimeHotkeyClaims(currentKey, normalizedBinding)) { - for (const candidate of graph[claim.binding] ?? []) { - if (candidate.command !== currentKey) conflicts.add(candidate.command) + for (const physicalBinding of getPhysicalHotkeyBindings(claim.binding)) { + for (const candidate of graph[physicalBinding] ?? []) { + if (candidate.command !== currentKey) conflicts.add(candidate.command) + } } } return (Object.keys(HOTKEYS) as HotkeyKey[]).filter((key) => conflicts.has(key)) diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index 7af4ab4e8..0db8de565 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { createElement } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { fireEvent, render, waitFor } from '@testing-library/react' import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS } from '@/config/hotkeys' @@ -10,7 +10,7 @@ import { useSettingsStore } from '@/features/editor/deps/settings' import { useHostTimelineShortcuts } from '@/features/editor/deps/timeline-hooks' import { usePlaybackStore } from '@/shared/state/playback' import { createHostShortcutSettings, type EditorHost, type HostShortcutSettings } from './contract' -import { mountHostShortcutSettings } from './shortcut-settings' +import { HOST_SHORTCUT_RETRY_DELAYS_MS, mountHostShortcutSettings } from './shortcut-settings' function HostShortcutHarness() { useHostTimelineShortcuts() @@ -69,6 +69,33 @@ function createDeferred() { return { promise, resolve, reject } } +function createRetryScheduler() { + let nextTimerId = 0 + const timers = new Map void; delayMs: number }>() + return { + scheduler: { + setTimeout: (callback: () => void, delayMs: number) => { + const timerId = ++nextTimerId + timers.set(timerId, { callback, delayMs }) + return timerId + }, + clearTimeout: (timer: unknown) => timers.delete(timer as number), + }, + pendingCount: () => timers.size, + pendingDelays: () => [...timers.values()].map((timer) => timer.delayMs), + runNext: async () => { + const entry = timers.entries().next().value as + | [number, { callback: () => void; delayMs: number }] + | undefined + if (!entry) throw new Error('No retry timer is pending') + timers.delete(entry[0]) + entry[1].callback() + await Promise.resolve() + await Promise.resolve() + }, + } +} + describe('host shortcut settings round trip', () => { beforeEach(() => { useSettingsStore.getState().resetHotkeys() @@ -79,6 +106,10 @@ describe('host shortcut settings round trip', () => { }) }) + afterEach(() => { + vi.useRealTimers() + }) + it('hydrates host bindings, persists UI changes, and accepts agent updates', async () => { useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) const harness = createShortcutHost( @@ -275,6 +306,120 @@ describe('host shortcut settings round trip', () => { unmount() }) + it('retries the newest desired settings after their host write rejects', async () => { + vi.useFakeTimers() + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + host.setSettings.mockRejectedValueOnce(new Error('transient failure')) + const unmount = await mountHostShortcutSettings(host.host) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await Promise.resolve() + await Promise.resolve() + expect(host.setSettings).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(HOST_SHORTCUT_RETRY_DELAYS_MS[0]) + + expect(host.setSettings).toHaveBeenCalledTimes(2) + expect(host.setSettings).toHaveBeenLastCalledWith( + createHostShortcutSettings({ SHUTTLE_PAUSE: 'x' }), + ) + unmount() + }) + + it('backs repeated failures with one capped timer and no tight loop', async () => { + const retry = createRetryScheduler() + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + host.setSettings.mockRejectedValue(new Error('persistent failure')) + const unmount = await mountHostShortcutSettings(host.host, undefined, retry.scheduler) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await Promise.resolve() + await Promise.resolve() + expect(host.setSettings).toHaveBeenCalledTimes(1) + expect(retry.pendingCount()).toBe(1) + expect(retry.pendingDelays()).toEqual([HOST_SHORTCUT_RETRY_DELAYS_MS[0]]) + + await retry.runNext() + expect(host.setSettings).toHaveBeenCalledTimes(2) + expect(retry.pendingCount()).toBe(1) + expect(retry.pendingDelays()).toEqual([HOST_SHORTCUT_RETRY_DELAYS_MS[1]]) + + for (let retryIndex = 2; retryIndex < HOST_SHORTCUT_RETRY_DELAYS_MS.length; retryIndex += 1) { + await retry.runNext() + expect(retry.pendingCount()).toBe(1) + expect(retry.pendingDelays()).toEqual([HOST_SHORTCUT_RETRY_DELAYS_MS[retryIndex]]) + } + await retry.runNext() + expect(retry.pendingCount()).toBe(1) + expect(retry.pendingDelays()).toEqual([HOST_SHORTCUT_RETRY_DELAYS_MS.at(-1)!]) + expect(host.setSettings).toHaveBeenCalledTimes(HOST_SHORTCUT_RETRY_DELAYS_MS.length + 1) + unmount() + expect(retry.pendingCount()).toBe(0) + }) + + it('persists only the newest desired settings after a change during backoff', async () => { + const retry = createRetryScheduler() + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + host.setSettings.mockRejectedValueOnce(new Error('transient failure')) + const unmount = await mountHostShortcutSettings(host.host, undefined, retry.scheduler) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await Promise.resolve() + await Promise.resolve() + expect(retry.pendingCount()).toBe(1) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'f10') + await Promise.resolve() + await Promise.resolve() + + expect(host.setSettings).toHaveBeenCalledTimes(2) + expect(host.setSettings).toHaveBeenLastCalledWith( + createHostShortcutSettings({ SHUTTLE_PAUSE: 'f10' }), + ) + expect(retry.pendingCount()).toBe(0) + unmount() + }) + + it('cancels a pending retry when equal inbound settings acknowledge the desired value', async () => { + const retry = createRetryScheduler() + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + host.setSettings.mockRejectedValueOnce(new Error('transient failure')) + const unmount = await mountHostShortcutSettings(host.host, undefined, retry.scheduler) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await Promise.resolve() + await Promise.resolve() + expect(retry.pendingCount()).toBe(1) + + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'x' })) + expect(retry.pendingCount()).toBe(0) + expect(host.setSettings).toHaveBeenCalledTimes(1) + unmount() + }) + + it('cancels a disposed host retry and fences it from the replacement host', async () => { + const retry = createRetryScheduler() + const hostA = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'a' })) + hostA.setSettings.mockRejectedValueOnce(new Error('transient failure')) + const unmountA = await mountHostShortcutSettings(hostA.host, undefined, retry.scheduler) + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await Promise.resolve() + await Promise.resolve() + expect(retry.pendingCount()).toBe(1) + + const hostB = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'b' })) + const unmountB = await mountHostShortcutSettings(hostB.host, undefined, retry.scheduler) + expect(retry.pendingCount()).toBe(0) + expect(hostA.setSettings).toHaveBeenCalledTimes(1) + expect(hostB.setSettings).not.toHaveBeenCalled() + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'f10') + await Promise.resolve() + expect(hostB.setSettings).toHaveBeenCalledTimes(1) + unmountA() + unmountB() + }) + it('fences in-flight host A work when host B replaces it', async () => { const hostA = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'a' })) const firstWrite = createDeferred() @@ -295,7 +440,7 @@ describe('host shortcut settings round trip', () => { expect(hostA.setSettings).toHaveBeenCalledTimes(1) expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'b' }) - useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'y') + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'f10') await waitFor(() => expect(hostB.setSettings).toHaveBeenCalledTimes(1)) unmountA() @@ -373,4 +518,24 @@ describe('host shortcut settings round trip', () => { expect(harness.setSettings).not.toHaveBeenCalled() unmount() }) + + it('retains the last valid settings and reports meta versus mod host conflicts', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) + const harness = createShortcutHost( + createHostShortcutSettings({ + MARK_IN: 'meta+j', + JOIN_ITEMS: 'mod+shift+j', + }), + ) + + const unmount = await mountHostShortcutSettings(harness.host) + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ PLAY_PAUSE: 'shift+space' }) + expect(harness.notify).toHaveBeenCalledWith({ + kind: 'conflict', + message: expect.stringMatching(/MARK_IN.*JOIN_ITEMS.*last valid/i), + }) + expect(harness.setSettings).not.toHaveBeenCalled() + unmount() + }) }) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts index d34ba86a3..703a6fed6 100644 --- a/src/features/editor/host/shortcut-settings.ts +++ b/src/features/editor/host/shortcut-settings.ts @@ -12,6 +12,18 @@ import { type HostShortcutSettings, } from './contract' +export const HOST_SHORTCUT_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const + +export interface HostShortcutRetryScheduler { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(timer: unknown): void +} + +const DEFAULT_HOST_SHORTCUT_RETRY_SCHEDULER: HostShortcutRetryScheduler = { + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timer) => clearTimeout(timer as ReturnType), +} + function normalizeHostShortcutSettings(settings: HostShortcutSettings): { settings: HostShortcutSettings warnings: HotkeyConflictWarning[] @@ -47,6 +59,7 @@ let currentOwnership: ShortcutOwnership | null = null export async function mountHostShortcutSettings( host: EditorHost, signal?: AbortSignal, + retryScheduler: HostShortcutRetryScheduler = DEFAULT_HOST_SHORTCUT_RETRY_SCHEDULER, ): Promise<() => void> { const previousOwnership = currentOwnership const standaloneOverrides = copyOverrides( @@ -62,12 +75,15 @@ export async function mountHostShortcutSettings( let disposed = false let unsubscribeHost: (() => void) | undefined let unsubscribeStore: (() => void) | undefined + let retryTimer: unknown const isCurrent = () => !disposed && currentOwnership?.epoch === ownership.epoch const dispose = () => { if (disposed) return disposed = true + if (retryTimer !== undefined) retryScheduler.clearTimeout(retryTimer) + retryTimer = undefined unsubscribeStore?.() unsubscribeHost?.() signal?.removeEventListener('abort', dispose) @@ -115,6 +131,13 @@ export async function mountHostShortcutSettings( let inFlightSettings: HostShortcutSettings | null = null let reconcileAfterFlight = false let reconcileScheduled = false + let retryAttempt = 0 + + const cancelRetry = (resetAttempt = false) => { + if (retryTimer !== undefined) retryScheduler.clearTimeout(retryTimer) + retryTimer = undefined + if (resetAttempt) retryAttempt = 0 + } const canStartReconcile = () => { if (!isCurrent()) return false @@ -123,13 +146,28 @@ export async function mountHostShortcutSettings( return !settingsEqual(desiredSettings, settledSettings) } + const desiredDiffersFrom = (settings: HostShortcutSettings) => + desiredSettings !== null && !settingsEqual(desiredSettings, settings) + + const hasUnsettledDesiredSettings = () => + desiredSettings !== null && + (settledSettings === null || !settingsEqual(desiredSettings, settledSettings)) + const finishReconcile = (settingsToWrite: HostShortcutSettings, succeeded: boolean) => { if (!isCurrent()) return - if (succeeded) settledSettings = settingsToWrite - const desiredChanged = - desiredSettings !== null && !settingsEqual(desiredSettings, settingsToWrite) + if (succeeded) { + settledSettings = settingsToWrite + retryAttempt = 0 + } + const desiredChanged = desiredDiffersFrom(settingsToWrite) inFlightSettings = null - if (desiredChanged || reconcileAfterFlight) scheduleReconcile() + if (desiredChanged || reconcileAfterFlight) { + scheduleReconcile() + return + } + if (!succeeded && hasUnsettledDesiredSettings()) { + scheduleRetry() + } } const persistDesiredSettings = async () => { @@ -155,6 +193,17 @@ export async function mountHostShortcutSettings( void Promise.resolve().then(persistDesiredSettings) } + function scheduleRetry() { + if (retryTimer !== undefined || inFlightSettings || !desiredSettings || !isCurrent()) return + const retryIndex = Math.min(retryAttempt, HOST_SHORTCUT_RETRY_DELAYS_MS.length - 1) + const delay = HOST_SHORTCUT_RETRY_DELAYS_MS[retryIndex]! + retryAttempt += 1 + retryTimer = retryScheduler.setTimeout(() => { + retryTimer = undefined + scheduleReconcile() + }, delay) + } + const applyHostSettings = (settings: HostShortcutSettings) => { if (!isCurrent()) return const normalized = normalizeHostShortcutSettings(settings) @@ -174,12 +223,13 @@ export async function mountHostShortcutSettings( applyingHostSettings = false } desiredSettings = normalized.settings + // A subscription is persisted host authority. It acknowledges an equal + // dirty value and supersedes a differing value unless an older write can + // still finish afterward, in which case that authority is reconciled once. + settledSettings = normalized.settings + cancelRetry(true) if (inFlightSettings) { reconcileAfterFlight = !settingsEqual(inFlightSettings, normalized.settings) - } else { - // A subscription is the host's persisted authority unless an older write - // can still complete after it and overwrite that state. - settledSettings = normalized.settings } } @@ -219,6 +269,7 @@ export async function mountHostShortcutSettings( const settings = createHostShortcutSettings(copyOverrides(state.hotkeyOverrides)) desiredSettings = settings + cancelRetry(true) scheduleReconcile() }) diff --git a/src/features/settings/stores/settings-store.test.ts b/src/features/settings/stores/settings-store.test.ts index 4406d663e..52cb6fd4c 100644 --- a/src/features/settings/stores/settings-store.test.ts +++ b/src/features/settings/stores/settings-store.test.ts @@ -104,7 +104,7 @@ describe('settings-store', () => { } as never) expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', DELETE_SELECTED: '', }) }) @@ -124,5 +124,15 @@ describe('settings-store', () => { expect(useSettingsStore.getState()).toBe(previousState) }) + + it('retains the last valid UI settings when an alias collision is attempted', () => { + useSettingsStore.getState().setHotkeyBinding('MARK_IN', 'meta+j') + const previousState = useSettingsStore.getState() + + useSettingsStore.getState().setHotkeyBinding('JOIN_ITEMS', 'mod+shift+j') + + expect(useSettingsStore.getState()).toBe(previousState) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ MARK_IN: 'meta+j' }) + }) }) }) diff --git a/src/features/settings/stores/settings-store.ts b/src/features/settings/stores/settings-store.ts index a4f9d4ea0..a3816f625 100644 --- a/src/features/settings/stores/settings-store.ts +++ b/src/features/settings/stores/settings-store.ts @@ -231,6 +231,9 @@ export const useSettingsStore = create()( ...state.hotkeyOverrides, [key]: normalizedBinding, }) + if (resolution.warnings.length > 0) { + return state + } const nextOverrides = resolution.overrides if (areHotkeyOverridesEqual(state.hotkeyOverrides, nextOverrides)) { diff --git a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx index 68221275f..a0823b3d4 100644 --- a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx @@ -1,23 +1,42 @@ import { fireEvent, render } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useHotkeys } from 'react-hotkeys-hook' -import { HOTKEY_OPTIONS } from '@/config/hotkeys' +import { + HOTKEY_OPTIONS, + getRuntimeHotkeyBinding, + resolveHotkeys, + type HotkeyBindingMap, +} from '@/config/hotkeys' import { useResolvedHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' import { useInOutShortcuts } from './use-in-out-shortcuts' import { usePlaybackShortcuts } from './use-playback-shortcuts' +const runtimeHotkeysOverride = vi.hoisted(() => ({ + current: null as HotkeyBindingMap | null, +})) + +vi.mock('@/features/timeline/deps/settings', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useResolvedHotkeys: () => runtimeHotkeysOverride.current ?? actual.useResolvedHotkeys(), + } +}) + function RuntimeConflictHarness({ onJoin }: { onJoin: () => void }) { const hotkeys = useResolvedHotkeys() + const joinBinding = getRuntimeHotkeyBinding(hotkeys, 'JOIN_ITEMS') usePlaybackShortcuts({}) useInOutShortcuts() - useHotkeys(hotkeys.JOIN_ITEMS, onJoin, HOTKEY_OPTIONS, [onJoin, hotkeys.JOIN_ITEMS]) + useHotkeys(joinBinding ?? [], onJoin, HOTKEY_OPTIONS, [onJoin, joinBinding]) return null } describe('runtime shortcut ownership', () => { beforeEach(() => { + runtimeHotkeysOverride.current = null useSettingsStore.getState().resetHotkeys() usePlaybackStore.setState({ currentFrame: 48, @@ -68,4 +87,20 @@ describe('runtime shortcut ownership', () => { fireEvent.keyDown(document, { key: 'J', code: 'KeyJ', shiftKey: true }) expect(onJoin).toHaveBeenCalledTimes(1) }) + + it('executes one deterministic handler for a legacy meta versus mod collision', () => { + const legacyHotkeys = { + ...resolveHotkeys(), + MARK_IN: 'meta+j', + JOIN_ITEMS: 'mod+shift+j', + } + runtimeHotkeysOverride.current = legacyHotkeys + const onJoin = vi.fn() + render() + + fireEvent.keyDown(document, { key: 'J', code: 'KeyJ', metaKey: true, shiftKey: true }) + + expect(onJoin).toHaveBeenCalledTimes(1) + expect(useTimelineStore.getState().inPoint).toBeNull() + }) }) diff --git a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts index 276c038c8..1ada257ae 100644 --- a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts @@ -8,7 +8,7 @@ import { usePlaybackStore } from '@/shared/state/playback' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' -import { HOTKEY_OPTIONS } from '@/config/hotkeys' +import { HOTKEY_OPTIONS, getRuntimeHotkeyBinding } from '@/config/hotkeys' import { canJoinMultipleItems } from '@/features/timeline/utils/clip-utils' import { canLinkSelection, hasLinkedItems } from '@/features/timeline/utils/linked-items' import { @@ -26,6 +26,7 @@ import { useDeleteShortcuts } from './use-delete-shortcuts' export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { const hotkeys = useResolvedHotkeys() + const joinItemsBinding = getRuntimeHotkeyBinding(hotkeys, 'JOIN_ITEMS') const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const editKeyframePanelOpen = useSelectionStore((s) => s.editKeyframePanelOpen) const clearSelection = useSelectionStore((s) => s.clearSelection) @@ -206,7 +207,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Shift+J - Join selected clips useHotkeys( - hotkeys.JOIN_ITEMS, + joinItemsBinding ?? [], (event) => { if (selectedItemIds.length < 2) return @@ -222,7 +223,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { } }, HOTKEY_OPTIONS, - [selectedItemIds, items, joinItems], + [joinItemsBinding, selectedItemIds, items, joinItems], ) useHotkeys( From d1a75a4613f6796356e123c4981802e3185d14c8 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 04:14:07 -0700 Subject: [PATCH 47/64] fix global runtime hotkey ownership (cherry picked from commit 93d57a2d9805a3cb50bb7d1cf3d9ba01729d6f36) --- src/config/hotkeys.test.ts | 46 ++++++ src/config/hotkeys.ts | 63 +++++++- ...ntime-hotkey-registration-coverage.test.ts | 50 ++++++ src/features/editor/deps/settings-contract.ts | 5 +- .../editor/hooks/use-editor-hotkeys.ts | 4 +- .../components/source-monitor.test.tsx | 31 ++++ .../preview/components/source-monitor.tsx | 21 ++- .../preview/deps/settings-contract.ts | 5 +- .../settings/hooks/use-resolved-hotkeys.ts | 9 +- .../components/keyframe-graph-panel.tsx | 4 +- .../timeline/deps/settings-contract.ts | 5 +- .../shortcuts/runtime-conflicts.test.tsx | 147 ++++++++++++++++++ .../shortcuts/use-clipboard-shortcuts.ts | 4 +- .../hooks/shortcuts/use-delete-shortcuts.ts | 4 +- .../hooks/shortcuts/use-editing-shortcuts.ts | 11 +- .../hooks/shortcuts/use-in-out-shortcuts.ts | 9 +- .../hooks/shortcuts/use-marker-shortcuts.ts | 4 +- .../hooks/shortcuts/use-playback-shortcuts.ts | 4 +- .../shortcuts/use-source-monitor-shortcuts.ts | 4 +- .../hooks/shortcuts/use-tool-shortcuts.ts | 4 +- .../hooks/shortcuts/use-ui-shortcuts.ts | 4 +- 21 files changed, 393 insertions(+), 45 deletions(-) create mode 100644 src/config/runtime-hotkey-registration-coverage.test.ts diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index 1ccf86bfb..0b2b7fe3c 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -6,6 +6,7 @@ import { HOTKEY_EXPORT_SCHEMA, HOTKEY_EXPORT_VERSION, createHotkeyExportDocument, + doesHotkeyEventMatchBinding, findHotkeyConflicts, formatHotkeyBinding, getBrowserHostileHotkey, @@ -16,6 +17,7 @@ import { parseHotkeyImportDocument, resolveHotkeyConfiguration, resolveHotkeys, + resolveRuntimeHotkeys, sanitizeHotkeyOverrides, } from './hotkeys' @@ -149,6 +151,19 @@ describe('getHotkeyBindingFromEventData', () => { }) }) +describe('doesHotkeyEventMatchBinding', () => { + const f10 = { key: 'F10', code: 'F10' } + + it('distinguishes explicit meta and ctrl while keeping mod portable', () => { + expect(doesHotkeyEventMatchBinding({ ...f10, metaKey: true }, 'meta+f10')).toBe(true) + expect(doesHotkeyEventMatchBinding({ ...f10, metaKey: true }, 'ctrl+f10')).toBe(false) + expect(doesHotkeyEventMatchBinding({ ...f10, ctrlKey: true }, 'ctrl+f10')).toBe(true) + expect(doesHotkeyEventMatchBinding({ ...f10, ctrlKey: true }, 'meta+f10')).toBe(false) + expect(doesHotkeyEventMatchBinding({ ...f10, metaKey: true }, 'mod+f10')).toBe(true) + expect(doesHotkeyEventMatchBinding({ ...f10, ctrlKey: true }, 'mod+f10')).toBe(true) + }) +}) + describe('findHotkeyConflicts', () => { it('returns other bindings using the same normalized shortcut', () => { const bindings = resolveHotkeys({ @@ -300,6 +315,37 @@ describe('resolveHotkeyConfiguration', () => { expect(getRuntimeHotkeyBinding(bindings, 'MARK_IN', 'preview')).toBeNull() }) + it('uses declaration order even when a legacy binding map has a different key order', () => { + const bindings = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + } + const reordered = Object.fromEntries(Object.entries(bindings).toReversed()) as typeof bindings + + expect(resolveRuntimeHotkeys(bindings)).toMatchObject({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: '', + }) + expect(resolveRuntimeHotkeys(reordered)).toMatchObject({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: '', + }) + }) + + it('keeps distinct explicit meta and ctrl runtime bindings reachable', () => { + const bindings = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'ctrl+f10', + } + + expect(resolveRuntimeHotkeys(bindings)).toMatchObject({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'ctrl+f10', + }) + }) + it('keeps ordinary remaps whose direct and derived runtime chords are unique', () => { const result = resolveHotkeyConfiguration({ MARK_IN: 'q', diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index 67a2f56ea..1d8b2d327 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -105,6 +105,8 @@ export type HotkeyBindingMap = Record export type HotkeyOverrideMap = Partial> type HotkeyPlatform = 'mac' | 'windows' +const HOTKEY_COMMAND_ORDER = Object.keys(HOTKEYS) as HotkeyKey[] + export const HOTKEY_EXPORT_SCHEMA = 'freecut-hotkeys' export const HOTKEY_EXPORT_VERSION = 2 @@ -787,6 +789,28 @@ export function getHotkeyBindingFromEventData(eventData: HotkeyEventData): strin return normalizeHotkeyBinding(tokens.join('+')) } +/** Exact runtime matching for local handlers, including explicit meta/ctrl remaps. */ +export function doesHotkeyEventMatchBinding(eventData: HotkeyEventData, binding: string): boolean { + const tokens = splitHotkeyBinding(binding) + const eventKey = eventData.code ?? eventData.key ?? '' + const functionKey = /^F(?:[1-9]|1[0-2])$/i.test(eventKey) ? eventKey.toLowerCase() : null + const primaryToken = getHotkeyPrimaryTokenFromEventData(eventData) ?? functionKey + if (!primaryToken || !tokens.includes(primaryToken)) return false + + const usesMod = tokens.includes('mod') + const expectsCtrl = tokens.includes('ctrl') + const expectsMeta = tokens.includes('meta') + const controlModifierMatches = usesMod + ? Boolean(eventData.ctrlKey || eventData.metaKey) + : Boolean(eventData.ctrlKey) === expectsCtrl && Boolean(eventData.metaKey) === expectsMeta + + return ( + controlModifierMatches && + Boolean(eventData.altKey) === tokens.includes('alt') && + Boolean(eventData.shiftKey) === tokens.includes('shift') + ) +} + function addShiftModifier(binding: string): string { const tokens = splitHotkeyBinding(binding) if (tokens.includes('shift')) return normalizeHotkeyBinding(binding) @@ -822,15 +846,18 @@ function getPhysicalHotkeyBindings(binding: string): string[] { /** * Canonical graph of every physical chord registered at runtime, including - * modifier-derived variants. Claim insertion order is the deterministic owner - * order when defensive runtime claiming sees an unresolved collision. + * modifier-derived variants. Ownership follows HOTKEYS declaration order, + * with each command's primary claim before its derived preview claim. This + * order is independent of persisted/host object insertion order so defensive + * runtime claiming stays stable even for invalid external state. */ function getRuntimeHotkeyConflictGraph( bindings: HotkeyBindingMap, ): Record { const conflicts: Record = {} - for (const [key, binding] of Object.entries(bindings) as [HotkeyKey, string][]) { + for (const key of HOTKEY_COMMAND_ORDER) { + const binding = bindings[key] for (const claim of getCommandRuntimeHotkeyClaims(key, binding)) { for (const physicalBinding of getPhysicalHotkeyBindings(claim.binding)) { const bindingClaims = conflicts[physicalBinding] ?? [] @@ -843,17 +870,17 @@ function getRuntimeHotkeyConflictGraph( return conflicts } -export function getRuntimeHotkeyBinding( +function getOwnedRuntimeHotkeyBinding( + graph: Record, bindings: HotkeyBindingMap, command: HotkeyKey, - variant: RuntimeHotkeyVariant = 'primary', + variant: RuntimeHotkeyVariant, ): string | null { const claim = getCommandRuntimeHotkeyClaims(command, bindings[command]).find( (candidate) => candidate.variant === variant, ) if (!claim) return null - const graph = getRuntimeHotkeyConflictGraph(bindings) const ownsEveryPhysicalBinding = getPhysicalHotkeyBindings(claim.binding).every((binding) => { const owner = graph[binding]?.[0] return owner?.command === command && owner.variant === variant @@ -861,6 +888,30 @@ export function getRuntimeHotkeyBinding( return ownsEveryPhysicalBinding ? claim.binding : null } +/** + * Returns the runtime-only primary registration map. A command that loses any + * canonical physical alias is disabled with an empty binding; raw resolved and + * persisted settings are never mutated. + */ +export function resolveRuntimeHotkeys(bindings: HotkeyBindingMap): HotkeyBindingMap { + const graph = getRuntimeHotkeyConflictGraph(bindings) + return Object.fromEntries( + HOTKEY_COMMAND_ORDER.map((command) => [ + command, + getOwnedRuntimeHotkeyBinding(graph, bindings, command, 'primary') ?? '', + ]), + ) as HotkeyBindingMap +} + +export function getRuntimeHotkeyBinding( + bindings: HotkeyBindingMap, + command: HotkeyKey, + variant: RuntimeHotkeyVariant = 'primary', +): string | null { + const graph = getRuntimeHotkeyConflictGraph(bindings) + return getOwnedRuntimeHotkeyBinding(graph, bindings, command, variant) +} + export function findHotkeyConflicts( bindings: HotkeyBindingMap, binding: string, diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts new file mode 100644 index 000000000..c7a1e165f --- /dev/null +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node + +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vite-plus/test' + +const SRC_ROOT = join(process.cwd(), 'src') + +function productionSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return productionSourceFiles(path) + if (!/\.tsx?$/.test(entry.name) || /\.test\./.test(entry.name)) return [] + return [path] + }) +} + +describe('runtime hotkey registration coverage', () => { + it('routes every direct command-map useHotkeys registration through the runtime map', () => { + const directRegistrationFiles = productionSourceFiles(SRC_ROOT).filter((path) => { + const source = readFileSync(path, 'utf8') + return /useHotkeys\(\s*hotkeys\.[A-Z0-9_]+/.test(source) + }) + + expect(directRegistrationFiles.length).toBeGreaterThan(0) + for (const path of directRegistrationFiles) { + expect(readFileSync(path, 'utf8'), relative(process.cwd(), path)).toContain( + 'useRuntimeHotkeys', + ) + } + }) + + it('feeds derived keyframe registrations and local source-monitor matching from the runtime map', () => { + const keyframePanel = readFileSync( + join(SRC_ROOT, 'features/timeline/components/keyframe-graph-panel.tsx'), + 'utf8', + ) + const sourceMonitor = readFileSync( + join(SRC_ROOT, 'features/preview/components/source-monitor.tsx'), + 'utf8', + ) + + expect(keyframePanel).toContain('useRuntimeHotkeys') + expect(keyframePanel).toMatch(/shortcuts=\{\{[\s\S]*hotkeys\.EDIT_KEYFRAME_ADD/) + expect(sourceMonitor).toContain('useRuntimeHotkeys') + expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.MARK_IN\)/) + expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.MARK_OUT\)/) + expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.CLEAR_IN_OUT\)/) + }) +}) diff --git a/src/features/editor/deps/settings-contract.ts b/src/features/editor/deps/settings-contract.ts index 49a43a6bb..8ab606a1d 100644 --- a/src/features/editor/deps/settings-contract.ts +++ b/src/features/editor/deps/settings-contract.ts @@ -12,5 +12,8 @@ export { export type { CaptioningIntervalUnit } from '@/features/settings/stores/settings-store' export { LocalInferenceUnloadControl } from '@/features/settings/components/local-inference-unload-control' export { LocalModelCacheControl } from '@/features/settings/components/local-model-cache-control' -export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' +export { + useResolvedHotkeys, + useRuntimeHotkeys, +} from '@/features/settings/hooks/use-resolved-hotkeys' export { HotkeyEditor } from '@/features/settings/components/hotkey-editor' diff --git a/src/features/editor/hooks/use-editor-hotkeys.ts b/src/features/editor/hooks/use-editor-hotkeys.ts index aeb6b5ad7..2ae48439d 100644 --- a/src/features/editor/hooks/use-editor-hotkeys.ts +++ b/src/features/editor/hooks/use-editor-hotkeys.ts @@ -1,6 +1,6 @@ import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS } from '@/config/hotkeys' -import { useResolvedHotkeys } from '@/features/editor/deps/settings' +import { useRuntimeHotkeys } from '@/features/editor/deps/settings' import { useEditorStore } from '@/shared/state/editor' import { useSceneBrowserStore } from '@/features/editor/deps/scene-browser' @@ -24,7 +24,7 @@ interface EditorHotkeyCallbacks { * Uses react-hotkeys-hook with granular Zustand selectors */ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const enableLocalUi = callbacks.enableLocalUi ?? true // Save: Cmd/Ctrl+S diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx index f4d0477ff..40c5d841f 100644 --- a/src/features/preview/components/source-monitor.test.tsx +++ b/src/features/preview/components/source-monitor.test.tsx @@ -90,6 +90,10 @@ const resolvedHotkeysState = vi.hoisted(() => ({ }, })) +const runtimeHotkeysState = vi.hoisted(() => ({ + hotkeys: { ...resolvedHotkeysState.hotkeys }, +})) + vi.mock('@/features/preview/deps/player-context', () => ({ PlayerEmitterProvider: ({ children }: { children: ReactNode }) => <>{children}, ClockBridgeProvider: ({ children }: { children: ReactNode }) => <>{children}, @@ -190,6 +194,7 @@ vi.mock('@/features/preview/deps/settings', () => { return { useSettingsStore, useResolvedHotkeys: () => resolvedHotkeysState.hotkeys, + useRuntimeHotkeys: () => runtimeHotkeysState.hotkeys, } }) @@ -270,6 +275,7 @@ describe('SourceMonitor current media ownership', () => { INSERT_EDIT: 'comma', OVERWRITE_EDIT: 'period', } + runtimeHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys } }) it('updates visible shortcut labels after remap and reset', async () => { @@ -281,6 +287,7 @@ describe('SourceMonitor current media ownership', () => { ...resolvedHotkeysState.hotkeys, MARK_IN: 'shift+f', } + runtimeHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys } rendered.rerender() expect(rendered.getByLabelText('Mark In (Shift + F)')).toBeInTheDocument() @@ -292,11 +299,35 @@ describe('SourceMonitor current media ownership', () => { expect(rendered.getByLabelText('Mark In (I)')).toBeInTheDocument() }) + it('keeps the raw local label while a losing runtime binding is disabled', async () => { + resolvedHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + MARK_IN: 'meta+f10', + } + runtimeHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + MARK_IN: '', + } + sourcePlayerStoreState.currentSourceFrame = 42 + const rendered = render() + await waitFor(() => expect(rendered.getByLabelText(/Mark In \(.+f10\)/i)).toBeInTheDocument()) + + fireEvent.keyDown(rendered.container.firstElementChild!, { + key: 'F10', + code: 'F10', + metaKey: true, + }) + + expect(sourcePlayerStoreState.setInPoint).not.toHaveBeenCalled() + expect(resolvedHotkeysState.hotkeys.MARK_IN).toBe('meta+f10') + }) + it('uses the same reactive binding for local source-monitor actions', async () => { resolvedHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys, MARK_IN: 'shift+f', } + runtimeHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys } sourcePlayerStoreState.currentSourceFrame = 42 const rendered = render() await waitFor(() => expect(rendered.getByLabelText('Mark In (Shift + F)')).toBeInTheDocument()) diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index 4a43128c0..5bc129d4d 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -53,7 +53,11 @@ import { } from '../utils/source-io' import { useMediaLibraryStore, getMediaType } from '@/features/preview/deps/media-library' import { useItemsStore } from '@/features/preview/deps/timeline-store' -import { useResolvedHotkeys, useSettingsStore } from '@/features/preview/deps/settings' +import { + useResolvedHotkeys, + useRuntimeHotkeys, + useSettingsStore, +} from '@/features/preview/deps/settings' import { useEditorStore } from '@/shared/state/editor' import { useSourcePlayerStore } from '@/shared/state/source-player' import { getNextShuttleRate } from '@/shared/state/playback/shuttle' @@ -71,7 +75,7 @@ import { formatTimecodeCompact } from '@/shared/utils/time-utils' import { getPreviewPixelSnapSize } from '../utils/preview-pixel-snap' import type { TimelineTrack } from '@/types/timeline' import { useBlobUrlEpoch } from '@/infrastructure/browser/blob-url-manager' -import { formatHotkeyBinding, getHotkeyBindingFromEventData } from '@/config/hotkeys' +import { doesHotkeyEventMatchBinding, formatHotkeyBinding } from '@/config/hotkeys' interface SourceMonitorProps { mediaId: string @@ -209,6 +213,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ const media = useMediaLibraryStore((s) => s.mediaById[mediaId]) const blobUrlEpoch = useBlobUrlEpoch(mediaId) const hotkeys = useResolvedHotkeys() + const runtimeHotkeys = useRuntimeHotkeys() // Sync current media ID into source player store for I/O points useEffect(() => { @@ -287,6 +292,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ seekFrame={seekFrame} onClose={onClose} hotkeys={hotkeys} + runtimeHotkeys={runtimeHotkeys} /> @@ -311,6 +317,7 @@ interface SourceMonitorInnerProps { seekFrame: number | null onClose?: () => void hotkeys: ReturnType + runtimeHotkeys: ReturnType } function SourceMonitorInner({ @@ -328,6 +335,7 @@ function SourceMonitorInner({ seekFrame, onClose, hotkeys, + runtimeHotkeys, }: SourceMonitorInnerProps) { const containerRef = useRef(null) const contentHostRef = useRef(null) @@ -458,24 +466,23 @@ function SourceMonitorInner({ (e: React.KeyboardEvent) => { if (!interactive) return if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return - const binding = getHotkeyBindingFromEventData(e) const { currentSourceFrame, setInPoint, setOutPoint, clearInOutPoints } = useSourcePlayerStore.getState() - if (binding === hotkeys.MARK_IN) { + if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.MARK_IN)) { e.preventDefault() e.stopPropagation() setInPoint(currentSourceFrame) - } else if (binding === hotkeys.MARK_OUT) { + } else if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.MARK_OUT)) { e.preventDefault() e.stopPropagation() setOutPoint(getExclusiveSourceOutPoint(currentSourceFrame, durationInFrames)) - } else if (binding === hotkeys.CLEAR_IN_OUT) { + } else if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.CLEAR_IN_OUT)) { e.preventDefault() e.stopPropagation() clearInOutPoints() } }, - [durationInFrames, hotkeys, interactive], + [durationInFrames, interactive, runtimeHotkeys], ) const handleMouseEnter = useCallback(() => { diff --git a/src/features/preview/deps/settings-contract.ts b/src/features/preview/deps/settings-contract.ts index 7f300ac99..f99f7c7d9 100644 --- a/src/features/preview/deps/settings-contract.ts +++ b/src/features/preview/deps/settings-contract.ts @@ -4,4 +4,7 @@ */ export { useSettingsStore } from '@/features/settings/stores/settings-store' -export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' +export { + useResolvedHotkeys, + useRuntimeHotkeys, +} from '@/features/settings/hooks/use-resolved-hotkeys' diff --git a/src/features/settings/hooks/use-resolved-hotkeys.ts b/src/features/settings/hooks/use-resolved-hotkeys.ts index aef1cdacc..9d91a1555 100644 --- a/src/features/settings/hooks/use-resolved-hotkeys.ts +++ b/src/features/settings/hooks/use-resolved-hotkeys.ts @@ -1,7 +1,14 @@ import { useShallow } from 'zustand/react/shallow' -import { resolveHotkeys } from '@/config/hotkeys' +import { resolveHotkeys, resolveRuntimeHotkeys } from '@/config/hotkeys' import { useSettingsStore } from '../stores/settings-store' export function useResolvedHotkeys() { return useSettingsStore(useShallow((state) => resolveHotkeys(state.hotkeyOverrides))) } + +/** Runtime registrations only; display and persistence must use useResolvedHotkeys. */ +export function useRuntimeHotkeys() { + return useSettingsStore( + useShallow((state) => resolveRuntimeHotkeys(resolveHotkeys(state.hotkeyOverrides))), + ) +} diff --git a/src/features/timeline/components/keyframe-graph-panel.tsx b/src/features/timeline/components/keyframe-graph-panel.tsx index 6b2ecb6d7..bb055db31 100644 --- a/src/features/timeline/components/keyframe-graph-panel.tsx +++ b/src/features/timeline/components/keyframe-graph-panel.tsx @@ -96,7 +96,7 @@ import { updateTextMotionLive, } from '../stores/actions/text-motion-actions' import { HOTKEY_OPTIONS } from '@/config/hotkeys' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { getDirectPropertyLinks, isTransformAnimatableProperty } from '@/types/keyframe' import { buildEffectPropertyResetPlan } from '@/features/timeline/utils/effect-property-reset' import { VectorSpeedGraph } from './vector-speed-graph' @@ -1228,7 +1228,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ })), [t], ) - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() // Ref to measure container width const containerRef = useRef(null) const panelRef = useRef(null) diff --git a/src/features/timeline/deps/settings-contract.ts b/src/features/timeline/deps/settings-contract.ts index 6c7f53151..e01af00db 100644 --- a/src/features/timeline/deps/settings-contract.ts +++ b/src/features/timeline/deps/settings-contract.ts @@ -4,4 +4,7 @@ */ export { useSettingsStore } from '@/features/settings/stores/settings-store' -export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' +export { + useResolvedHotkeys, + useRuntimeHotkeys, +} from '@/features/settings/hooks/use-resolved-hotkeys' diff --git a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx index a0823b3d4..81d0c4955 100644 --- a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx @@ -3,13 +3,18 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS, + HOTKEYS, getRuntimeHotkeyBinding, resolveHotkeys, type HotkeyBindingMap, + type HotkeyKey, } from '@/config/hotkeys' import { useResolvedHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' import { usePlaybackStore } from '@/shared/state/playback' +import { useSelectionStore } from '@/shared/state/selection' import { useTimelineStore } from '../../stores/timeline-store' +import type { TimelineTrack, VideoItem } from '@/types/timeline' +import { useEditingShortcuts } from './use-editing-shortcuts' import { useInOutShortcuts } from './use-in-out-shortcuts' import { usePlaybackShortcuts } from './use-playback-shortcuts' @@ -17,11 +22,28 @@ const runtimeHotkeysOverride = vi.hoisted(() => ({ current: null as HotkeyBindingMap | null, })) +function runtimePrimaryBindings(bindings: HotkeyBindingMap): HotkeyBindingMap { + return Object.fromEntries( + (Object.keys(HOTKEYS) as HotkeyKey[]).map((command) => [ + command, + getRuntimeHotkeyBinding(bindings, command) ?? '', + ]), + ) as HotkeyBindingMap +} + +const originalPlaybackActions = { + togglePlayPause: usePlaybackStore.getState().togglePlayPause, + shuttleForward: usePlaybackStore.getState().shuttleForward, + shuttleReverse: usePlaybackStore.getState().shuttleReverse, +} + vi.mock('@/features/timeline/deps/settings', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useResolvedHotkeys: () => runtimeHotkeysOverride.current ?? actual.useResolvedHotkeys(), + useRuntimeHotkeys: () => + runtimePrimaryBindings(runtimeHotkeysOverride.current ?? actual.useResolvedHotkeys()), } }) @@ -34,6 +56,36 @@ function RuntimeConflictHarness({ onJoin }: { onJoin: () => void }) { return null } +function FullRuntimeConflictHarness() { + usePlaybackShortcuts({}) + useEditingShortcuts({}) + useInOutShortcuts() + return null +} + +const TRACK: TimelineTrack = { + id: 'track-1', + name: 'V1', + kind: 'video', + order: 0, + height: 80, + locked: false, + visible: true, + muted: false, + solo: false, + items: [], +} + +const ITEM: VideoItem = { + id: 'clip-1', + type: 'video', + trackId: TRACK.id, + from: 0, + durationInFrames: 100, + label: 'Clip 1', + src: 'clip.mp4', +} + describe('runtime shortcut ownership', () => { beforeEach(() => { runtimeHotkeysOverride.current = null @@ -45,8 +97,10 @@ describe('runtime shortcut ownership', () => { isPlaying: false, playbackRate: 1, transportMode: 'normal', + ...originalPlaybackActions, }) useTimelineStore.setState({ inPoint: null, outPoint: null }) + useSelectionStore.setState({ selectedItemIds: [] }) }) it('executes only JOIN_ITEMS after rejecting the exact derived-chord swap', () => { @@ -103,4 +157,97 @@ describe('runtime shortcut ownership', () => { expect(onJoin).toHaveBeenCalledTimes(1) expect(useTimelineStore.getState().inPoint).toBeNull() }) + + it('gives PLAY_PAUSE sole ownership of a legacy meta versus mod transport collision', () => { + runtimeHotkeysOverride.current = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + } + const togglePlayPause = vi.fn(originalPlaybackActions.togglePlayPause) + const shuttleReverse = vi.fn(originalPlaybackActions.shuttleReverse) + usePlaybackStore.setState({ togglePlayPause, shuttleReverse }) + render() + + fireEvent.keyDown(document, { key: 'F10', code: 'F10', metaKey: true }) + + expect(togglePlayPause).toHaveBeenCalledTimes(1) + expect(shuttleReverse).not.toHaveBeenCalled() + }) + + it('gives playback sole ownership across playback and split shortcut hooks', () => { + runtimeHotkeysOverride.current = { + ...resolveHotkeys(), + SHUTTLE_FORWARD: 'mod+f9', + SPLIT_AT_PLAYHEAD_ALT: 'meta+f9', + } + usePlaybackStore.setState({ currentFrame: 50 }) + useTimelineStore.setState({ tracks: [TRACK], items: [ITEM] }) + const shuttleForward = vi.fn(originalPlaybackActions.shuttleForward) + usePlaybackStore.setState({ shuttleForward }) + render() + + fireEvent.keyDown(document, { key: 'F9', code: 'F9', metaKey: true }) + + expect(shuttleForward).toHaveBeenCalledTimes(1) + expect(useTimelineStore.getState().items).toEqual([ITEM]) + }) + + it('keeps physically distinct explicit meta and ctrl bindings reachable', () => { + runtimeHotkeysOverride.current = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f8', + SHUTTLE_REVERSE: 'ctrl+f8', + } + const togglePlayPause = vi.fn(originalPlaybackActions.togglePlayPause) + const shuttleReverse = vi.fn(originalPlaybackActions.shuttleReverse) + usePlaybackStore.setState({ togglePlayPause, shuttleReverse }) + render() + + fireEvent.keyDown(document, { key: 'F8', code: 'F8', metaKey: true }) + fireEvent.keyDown(document, { key: 'F8', code: 'F8', ctrlKey: true }) + + expect(togglePlayPause).toHaveBeenCalledTimes(1) + expect(shuttleReverse).toHaveBeenCalledTimes(1) + }) + + it('does not let a bubble registration duplicate a capture-owned event', () => { + runtimeHotkeysOverride.current = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f7', + CLEAR_IN_OUT: 'mod+f7', + } + useTimelineStore.setState({ inPoint: 10, outPoint: 20 }) + const togglePlayPause = vi.fn(originalPlaybackActions.togglePlayPause) + usePlaybackStore.setState({ togglePlayPause }) + render() + + fireEvent.keyDown(document, { key: 'F7', code: 'F7', metaKey: true }) + + expect(togglePlayPause).toHaveBeenCalledTimes(1) + expect(useTimelineStore.getState()).toMatchObject({ inPoint: 10, outPoint: 20 }) + }) + + it('filters only runtime ownership without rewriting raw bindings or labels', () => { + const persistedOverrides = { + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + } as const + const displayHotkeys = { ...resolveHotkeys(), ...persistedOverrides } + runtimeHotkeysOverride.current = displayHotkeys + const togglePlayPause = vi.fn(originalPlaybackActions.togglePlayPause) + const shuttleReverse = vi.fn(originalPlaybackActions.shuttleReverse) + usePlaybackStore.setState({ togglePlayPause, shuttleReverse }) + render() + + fireEvent.keyDown(document, { key: 'F10', code: 'F10', metaKey: true }) + + expect(togglePlayPause).toHaveBeenCalledTimes(1) + expect(shuttleReverse).not.toHaveBeenCalled() + expect(persistedOverrides).toEqual({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + }) + expect(displayHotkeys).toMatchObject(persistedOverrides) + }) }) diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts index 4c3742bfd..cc5783e97 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts @@ -15,7 +15,7 @@ import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { Transition } from '@/types/transition' import type { TimelineItem, TimelineTrack } from '@/types/timeline' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { isCompositionWrapperItem, wouldCreateCompositionCycle, @@ -306,7 +306,7 @@ function revealPastedItems(itemIds: readonly string[]): void { } export function useClipboardShortcuts() { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const selectedTransitionId = useSelectionStore((s) => s.selectedTransitionId) const selectedKeyframes = useKeyframeSelectionStore((s) => s.selectedKeyframes) diff --git a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts index fa4d0ddfa..b67f5ad95 100644 --- a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts @@ -14,11 +14,11 @@ import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' export function useDeleteShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const selectedMarkerId = useSelectionStore((s) => s.selectedMarkerId) const selectedTransitionId = useSelectionStore((s) => s.selectedTransitionId) diff --git a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts index 1ada257ae..4ea024200 100644 --- a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts @@ -8,7 +8,7 @@ import { usePlaybackStore } from '@/shared/state/playback' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' -import { HOTKEY_OPTIONS, getRuntimeHotkeyBinding } from '@/config/hotkeys' +import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { canJoinMultipleItems } from '@/features/timeline/utils/clip-utils' import { canLinkSelection, hasLinkedItems } from '@/features/timeline/utils/linked-items' import { @@ -20,13 +20,12 @@ import { import type { TransformProperties } from '@/types/transform' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' import { useClearKeyframesDialogStore } from '@/shared/state/clear-keyframes-dialog' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' import { useDeleteShortcuts } from './use-delete-shortcuts' export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useResolvedHotkeys() - const joinItemsBinding = getRuntimeHotkeyBinding(hotkeys, 'JOIN_ITEMS') + const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const editKeyframePanelOpen = useSelectionStore((s) => s.editKeyframePanelOpen) const clearSelection = useSelectionStore((s) => s.clearSelection) @@ -207,7 +206,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Shift+J - Join selected clips useHotkeys( - joinItemsBinding ?? [], + hotkeys.JOIN_ITEMS, (event) => { if (selectedItemIds.length < 2) return @@ -223,7 +222,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { } }, HOTKEY_OPTIONS, - [joinItemsBinding, selectedItemIds, items, joinItems], + [selectedItemIds, items, joinItems], ) useHotkeys( diff --git a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts index 172b92eb6..4d57e68d5 100644 --- a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts @@ -6,12 +6,13 @@ import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS, getRuntimeHotkeyBinding } from '@/config/hotkeys' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useResolvedHotkeys, useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useInOutShortcuts() { - const hotkeys = useResolvedHotkeys() - const markInAtPreview = getRuntimeHotkeyBinding(hotkeys, 'MARK_IN', 'preview') - const markOutAtPreview = getRuntimeHotkeyBinding(hotkeys, 'MARK_OUT', 'preview') + const resolvedHotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() + const markInAtPreview = getRuntimeHotkeyBinding(resolvedHotkeys, 'MARK_IN', 'preview') + const markOutAtPreview = getRuntimeHotkeyBinding(resolvedHotkeys, 'MARK_OUT', 'preview') useHotkeys( hotkeys.MARK_IN, diff --git a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts index cd6881792..fd1339501 100644 --- a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts @@ -8,10 +8,10 @@ import { useMarkersStore } from '../../stores/markers-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { addMarker, removeMarker } from '../../stores/actions/marker-actions' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useMarkerShortcuts() { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const setCurrentFrame = usePlaybackStore((s) => s.setCurrentFrame) const clearSelection = useSelectionStore((s) => s.clearSelection) diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts index b9ac22cb0..15ebb6db5 100644 --- a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts @@ -14,7 +14,7 @@ import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' import { useSourcePlayerStore } from '@/shared/state/source-player' import { getFilteredItemSnapEdges } from '../../utils/timeline-snap-utils' import { getVisibleTrackIds } from '../../utils/group-utils' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' /** Compute snap points on-demand from current store state (avoids reactive subscriptions). */ function getSnapPoints(): number[] { @@ -41,7 +41,7 @@ function getFinalTimelineFrame(): number { } export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const togglePlayPause = usePlaybackStore((s) => s.togglePlayPause) const shuttleForward = usePlaybackStore((s) => s.shuttleForward) const shuttleReverse = usePlaybackStore((s) => s.shuttleReverse) diff --git a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts index 0cabacc2c..3564ceae1 100644 --- a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts @@ -13,10 +13,10 @@ import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { useEditorStore } from '@/shared/state/editor' import { performInsertEdit, performOverwriteEdit } from '../../stores/actions/source-edit-actions' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useSourceMonitorShortcuts() { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() // Insert Edit: , (comma) — works globally when source monitor is open useHotkeys( diff --git a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts index 27170fa2e..f49883515 100644 --- a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts @@ -8,11 +8,11 @@ import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { SLIP_SLIDE_TOOLS_ENABLED } from '../../constants' export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const activeTool = useSelectionStore((s) => s.activeTool) const setActiveTool = useSelectionStore((s) => s.setActiveTool) diff --git a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts index cffd9cbc7..3d4f9f14b 100644 --- a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts @@ -8,7 +8,7 @@ import { useZoomStore, getZoomTo100Handler } from '../../stores/zoom-store' import { usePlaybackStore } from '@/shared/state/playback' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useResolvedHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' export interface UIShortcutOptions { /** @@ -23,7 +23,7 @@ export function useUIShortcuts( options: UIShortcutOptions = {}, ) { const { enableHistory = true } = options - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const toggleSnap = useTimelineStore((s) => s.toggleSnap) const zoomIn = useZoomStore((s) => s.zoomIn) const zoomOut = useZoomStore((s) => s.zoomOut) From f67249d7523ce5e4634cdff8af7185aedf1d6ddb Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 04:53:26 -0700 Subject: [PATCH 48/64] Fix atomic runtime hotkey ownership (cherry picked from commit 8388082972bb0c1f4f8b75cc2c9f84d407543f0e) --- package-lock.json | 1 + package.json | 1 + scripts/runtime-hotkey-import-boundary.d.mts | 17 ++++ scripts/runtime-hotkey-import-boundary.mjs | 95 +++++++++++++++++++ src/config/hotkeys.test.ts | 35 +++++++ src/config/hotkeys.ts | 29 +++++- ...ntime-hotkey-registration-coverage.test.ts | 59 +++++++----- src/features/editor/deps/settings-contract.ts | 5 +- .../editor/hooks/use-editor-hotkeys.ts | 16 ++-- .../components/dopesheet-editor/index.tsx | 91 +++++++----------- .../dopesheet-editor/shortcuts.test.tsx | 35 ------- .../components/source-monitor.test.tsx | 5 + .../preview/components/source-monitor.tsx | 17 ++-- .../preview/deps/settings-contract.ts | 5 +- .../settings/hooks/use-resolved-hotkeys.ts | 9 +- .../components/keyframe-graph-panel.tsx | 23 ++--- .../timeline/deps/settings-contract.ts | 5 +- .../shortcuts/runtime-conflicts.test.tsx | 54 ++++++++--- .../shortcuts/use-clipboard-shortcuts.ts | 10 +- .../hooks/shortcuts/use-delete-shortcuts.ts | 8 +- .../hooks/shortcuts/use-editing-shortcuts.ts | 38 ++++---- .../hooks/shortcuts/use-in-out-shortcuts.ts | 34 +++---- .../hooks/shortcuts/use-marker-shortcuts.ts | 12 +-- .../hooks/shortcuts/use-playback-shortcuts.ts | 24 +++-- .../shortcuts/use-source-monitor-shortcuts.ts | 9 +- .../hooks/shortcuts/use-tool-shortcuts.ts | 18 ++-- .../hooks/shortcuts/use-ui-shortcuts.ts | 23 +++-- src/hooks/use-hotkey-registration.ts | 68 +++++++++++++ src/hooks/use-runtime-hotkey-binding.ts | 46 +++++++++ 29 files changed, 510 insertions(+), 282 deletions(-) create mode 100644 scripts/runtime-hotkey-import-boundary.d.mts create mode 100644 scripts/runtime-hotkey-import-boundary.mjs create mode 100644 src/hooks/use-hotkey-registration.ts create mode 100644 src/hooks/use-runtime-hotkey-binding.ts diff --git a/package-lock.json b/package-lock.json index f32878f6d..d928ec33e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "zustand": "5.0.12" }, "devDependencies": { + "@babel/parser": "7.29.7", "@tailwindcss/vite": "4.2.2", "@tanstack/router-cli": "1.166.33", "@testing-library/dom": "10.4.1", diff --git a/package.json b/package.json index 7c4f05810..70707d98f 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "zustand": "5.0.12" }, "devDependencies": { + "@babel/parser": "7.29.7", "@tailwindcss/vite": "4.2.2", "@tanstack/router-cli": "1.166.33", "@testing-library/dom": "10.4.1", diff --git a/scripts/runtime-hotkey-import-boundary.d.mts b/scripts/runtime-hotkey-import-boundary.d.mts new file mode 100644 index 000000000..d7006a2c9 --- /dev/null +++ b/scripts/runtime-hotkey-import-boundary.d.mts @@ -0,0 +1,17 @@ +export interface RuntimeHotkeyBoundarySource { + path: string + source: string +} + +export interface RuntimeHotkeyImportViolation { + path: string + line: number + column: number +} + +export declare const RUNTIME_HOTKEY_ADAPTER_PATH: 'src/hooks/use-hotkey-registration.ts' + +export declare function findReactHotkeysHookImportViolations( + sources: RuntimeHotkeyBoundarySource[], + allowedPath?: string, +): RuntimeHotkeyImportViolation[] diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs new file mode 100644 index 000000000..a5e5f8583 --- /dev/null +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -0,0 +1,95 @@ +import { parse } from '@babel/parser' + +const REACT_HOTKEYS_HOOK_MODULE = 'react-hotkeys-hook' +export const RUNTIME_HOTKEY_ADAPTER_PATH = 'src/hooks/use-hotkey-registration.ts' + +function isReactHotkeysSource(source) { + return source?.type === 'StringLiteral' && source.value === REACT_HOTKEYS_HOOK_MODULE +} + +function isStaticReactHotkeysImport(node) { + const hasStaticSource = + node.type === 'ImportDeclaration' || + node.type === 'ExportNamedDeclaration' || + node.type === 'ExportAllDeclaration' + return hasStaticSource && isReactHotkeysSource(node.source) +} + +function isTypeScriptReactHotkeysImport(node) { + if (node.type !== 'TSImportEqualsDeclaration') return false + const reference = node.moduleReference + return ( + reference.type === 'TSExternalModuleReference' && isReactHotkeysSource(reference.expression) + ) +} + +function isReactHotkeysCallImport(node) { + if (node.type !== 'CallExpression') return false + const { callee, arguments: args } = node + const isRequire = callee.type === 'Identifier' && callee.name === 'require' + return ( + (isRequire || callee.type === 'Import') && args.length === 1 && isReactHotkeysSource(args[0]) + ) +} + +function isReactHotkeysImportExpression(node) { + return node.type === 'ImportExpression' && isReactHotkeysSource(node.source) +} + +const IMPORT_NODE_CHECKS = [ + isStaticReactHotkeysImport, + isTypeScriptReactHotkeysImport, + isReactHotkeysCallImport, + isReactHotkeysImportExpression, +] +const AST_METADATA_KEYS = new Set(['loc', 'start', 'end']) + +function walkAst(root, onNode) { + const pending = [root] + while (pending.length > 0) { + const node = pending.pop() + if (!node || typeof node !== 'object') continue + if (Array.isArray(node)) { + pending.push(...node) + continue + } + + onNode(node) + for (const [key, child] of Object.entries(node)) { + if (!AST_METADATA_KEYS.has(key)) pending.push(child) + } + } +} + +export function findReactHotkeysHookImportViolations( + sources, + allowedPath = RUNTIME_HOTKEY_ADAPTER_PATH, +) { + const violations = [] + + for (const { path, source } of sources) { + const ast = parse(source, { + sourceType: 'unambiguous', + plugins: ['typescript', 'jsx', 'dynamicImport'], + }) + + function record(node) { + if (path !== allowedPath) { + violations.push({ + path, + line: node.loc?.start.line ?? 1, + column: (node.loc?.start.column ?? 0) + 1, + }) + } + } + + walkAst(ast, (node) => { + if (IMPORT_NODE_CHECKS.some((check) => check(node))) record(node) + }) + } + + return violations.sort( + (left, right) => + left.path.localeCompare(right.path) || left.line - right.line || left.column - right.column, + ) +} diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index 0b2b7fe3c..63a67638b 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -333,6 +333,41 @@ describe('resolveHotkeyConfiguration', () => { }) }) + it('does not let a dead portable claimant reserve an uncollided platform alias', () => { + const bindings = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + SHUTTLE_PAUSE: 'ctrl+f10', + } + const reordered = Object.fromEntries(Object.entries(bindings).toReversed()) as typeof bindings + + expect(resolveRuntimeHotkeys(bindings)).toMatchObject({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: '', + SHUTTLE_PAUSE: 'ctrl+f10', + }) + expect(resolveRuntimeHotkeys(reordered)).toMatchObject({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: '', + SHUTTLE_PAUSE: 'ctrl+f10', + }) + }) + + it('does not let a dead derived claimant reserve an uncollided platform alias', () => { + const bindings = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+shift+f10', + MARK_IN: 'mod+f10', + INSERT_EDIT: 'ctrl+shift+f10', + } + + expect(getRuntimeHotkeyBinding(bindings, 'PLAY_PAUSE')).toBe('meta+shift+f10') + expect(getRuntimeHotkeyBinding(bindings, 'MARK_IN')).toBe('mod+f10') + expect(getRuntimeHotkeyBinding(bindings, 'MARK_IN', 'preview')).toBeNull() + expect(getRuntimeHotkeyBinding(bindings, 'INSERT_EDIT')).toBe('ctrl+shift+f10') + }) + it('keeps distinct explicit meta and ctrl runtime bindings reachable', () => { const bindings = { ...resolveHotkeys(), diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index 1d8b2d327..976db12ef 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -870,6 +870,31 @@ function getRuntimeHotkeyConflictGraph( return conflicts } +/** + * Runtime ownership graph containing only live candidates. Each primary or + * derived candidate acquires every platform alias as one transaction. A + * collision with any earlier live candidate rejects the whole candidate and + * leaves every one of its aliases available to later declarations. + */ +function getOwnedRuntimeHotkeyConflictGraph( + bindings: HotkeyBindingMap, +): Record { + const owned: Record = {} + + for (const command of HOTKEY_COMMAND_ORDER) { + for (const claim of getCommandRuntimeHotkeyClaims(command, bindings[command])) { + const physicalBindings = getPhysicalHotkeyBindings(claim.binding) + if (physicalBindings.some((physicalBinding) => owned[physicalBinding]?.length)) continue + + for (const physicalBinding of physicalBindings) { + owned[physicalBinding] = [{ ...claim, physicalBinding }] + } + } + } + + return owned +} + function getOwnedRuntimeHotkeyBinding( graph: Record, bindings: HotkeyBindingMap, @@ -894,7 +919,7 @@ function getOwnedRuntimeHotkeyBinding( * persisted settings are never mutated. */ export function resolveRuntimeHotkeys(bindings: HotkeyBindingMap): HotkeyBindingMap { - const graph = getRuntimeHotkeyConflictGraph(bindings) + const graph = getOwnedRuntimeHotkeyConflictGraph(bindings) return Object.fromEntries( HOTKEY_COMMAND_ORDER.map((command) => [ command, @@ -908,7 +933,7 @@ export function getRuntimeHotkeyBinding( command: HotkeyKey, variant: RuntimeHotkeyVariant = 'primary', ): string | null { - const graph = getRuntimeHotkeyConflictGraph(bindings) + const graph = getOwnedRuntimeHotkeyConflictGraph(bindings) return getOwnedRuntimeHotkeyBinding(graph, bindings, command, variant) } diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index c7a1e165f..e3c3d5542 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -3,6 +3,10 @@ import { readdirSync, readFileSync } from 'node:fs' import { join, relative } from 'node:path' import { describe, expect, it } from 'vite-plus/test' +import { + RUNTIME_HOTKEY_ADAPTER_PATH, + findReactHotkeysHookImportViolations, +} from '../../scripts/runtime-hotkey-import-boundary.mjs' const SRC_ROOT = join(process.cwd(), 'src') @@ -16,35 +20,38 @@ function productionSourceFiles(directory: string): string[] { } describe('runtime hotkey registration coverage', () => { - it('routes every direct command-map useHotkeys registration through the runtime map', () => { - const directRegistrationFiles = productionSourceFiles(SRC_ROOT).filter((path) => { - const source = readFileSync(path, 'utf8') - return /useHotkeys\(\s*hotkeys\.[A-Z0-9_]+/.test(source) - }) + it('allows react-hotkeys-hook only in the production registration adapter', () => { + const sources = productionSourceFiles(SRC_ROOT).map((path) => ({ + path: relative(process.cwd(), path), + source: readFileSync(path, 'utf8'), + })) - expect(directRegistrationFiles.length).toBeGreaterThan(0) - for (const path of directRegistrationFiles) { - expect(readFileSync(path, 'utf8'), relative(process.cwd(), path)).toContain( - 'useRuntimeHotkeys', - ) - } + expect(findReactHotkeysHookImportViolations(sources)).toEqual([]) }) - it('feeds derived keyframe registrations and local source-monitor matching from the runtime map', () => { - const keyframePanel = readFileSync( - join(SRC_ROOT, 'features/timeline/components/keyframe-graph-panel.tsx'), - 'utf8', - ) - const sourceMonitor = readFileSync( - join(SRC_ROOT, 'features/preview/components/source-monitor.tsx'), - 'utf8', - ) + it.each([ + ['aliased static import', "import { useHotkeys as register } from 'react-hotkeys-hook'"], + ['default import', "import hotkeyHooks from 'react-hotkeys-hook'"], + ['namespace import', "import * as hotkeyHooks from 'react-hotkeys-hook'"], + ['destructured require', "const { useHotkeys } = require('react-hotkeys-hook')"], + ['TypeScript import-equals', "import hotkeyHooks = require('react-hotkeys-hook')"], + ['wrapper re-export', "export { useHotkeys as useWrappedHotkey } from 'react-hotkeys-hook'"], + ['dynamic import', "const hooks = await import('react-hotkeys-hook')"], + ])('rejects a %s bypass', (_label, source) => { + expect( + findReactHotkeysHookImportViolations([{ path: 'src/features/bypass.ts', source }]), + ).toEqual([expect.objectContaining({ path: 'src/features/bypass.ts', line: 1 })]) + }) - expect(keyframePanel).toContain('useRuntimeHotkeys') - expect(keyframePanel).toMatch(/shortcuts=\{\{[\s\S]*hotkeys\.EDIT_KEYFRAME_ADD/) - expect(sourceMonitor).toContain('useRuntimeHotkeys') - expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.MARK_IN\)/) - expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.MARK_OUT\)/) - expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.CLEAR_IN_OUT\)/) + it('allows the exact adapter module and no similarly named wrapper', () => { + const source = "import { useHotkeys } from 'react-hotkeys-hook'" + expect( + findReactHotkeysHookImportViolations([{ path: RUNTIME_HOTKEY_ADAPTER_PATH, source }]), + ).toEqual([]) + expect( + findReactHotkeysHookImportViolations([ + { path: 'src/hooks/use-hotkey-registration-wrapper.ts', source }, + ]), + ).toHaveLength(1) }) }) diff --git a/src/features/editor/deps/settings-contract.ts b/src/features/editor/deps/settings-contract.ts index 8ab606a1d..49a43a6bb 100644 --- a/src/features/editor/deps/settings-contract.ts +++ b/src/features/editor/deps/settings-contract.ts @@ -12,8 +12,5 @@ export { export type { CaptioningIntervalUnit } from '@/features/settings/stores/settings-store' export { LocalInferenceUnloadControl } from '@/features/settings/components/local-inference-unload-control' export { LocalModelCacheControl } from '@/features/settings/components/local-model-cache-control' -export { - useResolvedHotkeys, - useRuntimeHotkeys, -} from '@/features/settings/hooks/use-resolved-hotkeys' +export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' export { HotkeyEditor } from '@/features/settings/components/hotkey-editor' diff --git a/src/features/editor/hooks/use-editor-hotkeys.ts b/src/features/editor/hooks/use-editor-hotkeys.ts index 2ae48439d..6b70a11d8 100644 --- a/src/features/editor/hooks/use-editor-hotkeys.ts +++ b/src/features/editor/hooks/use-editor-hotkeys.ts @@ -1,6 +1,5 @@ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { HOTKEY_OPTIONS } from '@/config/hotkeys' -import { useRuntimeHotkeys } from '@/features/editor/deps/settings' import { useEditorStore } from '@/shared/state/editor' import { useSceneBrowserStore } from '@/features/editor/deps/scene-browser' @@ -24,11 +23,10 @@ interface EditorHotkeyCallbacks { * Uses react-hotkeys-hook with granular Zustand selectors */ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { - const hotkeys = useRuntimeHotkeys() const enableLocalUi = callbacks.enableLocalUi ?? true // Save: Cmd/Ctrl+S - useHotkeys( + useCommandHotkey( hotkeys.SAVE, (event) => { event.preventDefault() @@ -41,7 +39,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { ) // Export: Cmd/Ctrl+Shift+E - useHotkeys( + useCommandHotkey( hotkeys.EXPORT, (event) => { event.preventDefault() @@ -56,7 +54,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // Open Scene Browser: Cmd/Ctrl+Shift+F — capture phase because the // default browser binding is a no-op here but Chrome will still eat it // if our listener is in bubbling phase. - useHotkeys( + useCommandHotkey( hotkeys.OPEN_SCENE_BROWSER, (event) => { if (!enableLocalUi) return @@ -69,7 +67,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // Workspace switching: Alt+1 (Edit), Alt+2 (Color), Alt+3 (Motion). // WORKSPACE_ANIMATE retains its persisted command id for shortcut migration. - useHotkeys( + useCommandHotkey( hotkeys.WORKSPACE_EDIT, (event) => { if (!enableLocalUi) return @@ -80,7 +78,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { [enableLocalUi], ) - useHotkeys( + useCommandHotkey( hotkeys.WORKSPACE_COLOR, (event) => { if (!enableLocalUi) return @@ -91,7 +89,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { [enableLocalUi], ) - useHotkeys( + useCommandHotkey( hotkeys.WORKSPACE_ANIMATE, (event) => { if (!enableLocalUi) return diff --git a/src/features/keyframes/components/dopesheet-editor/index.tsx b/src/features/keyframes/components/dopesheet-editor/index.tsx index 57b347873..6b751e6ae 100644 --- a/src/features/keyframes/components/dopesheet-editor/index.tsx +++ b/src/features/keyframes/components/dopesheet-editor/index.tsx @@ -17,7 +17,11 @@ import { } from 'react' import { flushSync } from 'react-dom' import { useTranslation } from 'react-i18next' -import { useHotkeys } from 'react-hotkeys-hook' +import { + COMMAND_HOTKEYS as hotkeys, + useCommandHotkey, + useLocalHotkey, +} from '@/hooks/use-hotkey-registration' import { ChevronDown, ChevronLeft, @@ -477,14 +481,6 @@ interface DopesheetEditorProps { shortcutsEnabled?: boolean /** Keep the Edit add-keyframe shortcut active while its dock is open. */ addKeyframeShortcutEnabled?: boolean - /** User-configurable bindings for high-frequency keyframe actions. */ - shortcuts?: { - addKeyframe: string - previousKeyframe: string - nextKeyframe: string - toggleAutoKey: string - fitKeyframes: string - } /** Additional class name */ className?: string } @@ -913,7 +909,6 @@ export const DopesheetEditor = memo(function DopesheetEditor({ showPlayhead = true, shortcutsEnabled = false, addKeyframeShortcutEnabled = false, - shortcuts, className, }: DopesheetEditorProps) { perfMarkRender('DopesheetEditor') @@ -1583,13 +1578,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ linkedTimelineViewportWidth !== undefined && linkedTimelineViewportWidth > 0 const timelineCellBorderWidth = - presentation === 'classic' - ? hasLinkedTimelineAxis - ? 0 - : 1 - : presentation === 'lanes' - ? 1 - : 0 + presentation === 'classic' ? (hasLinkedTimelineAxis ? 0 : 1) : presentation === 'lanes' ? 1 : 0 const effectiveTimelineWidth = Math.max( hasLinkedTimelineAxis ? linkedTimelineViewportWidth @@ -1692,12 +1681,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ }, [affectedFrameRange, effectiveTimelineWidth, frameToX]) const sharedGridFrameToX = useCallback( (frame: number) => - getFrameAxisX( - frame, - viewport, - effectiveTimelineWidth + timelineCellBorderWidth, - 0, - ) - timelineCellBorderWidth, + getFrameAxisX(frame, viewport, effectiveTimelineWidth + timelineCellBorderWidth, 0) - + timelineCellBorderWidth, [effectiveTimelineWidth, timelineCellBorderWidth, viewport], ) const getRenderedKeyframeX = useCallback( @@ -1947,8 +1932,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ if (timelineGridDivisions && timelineGridDivisions > 0) { return Array.from( { length: timelineGridDivisions + 1 }, - (_, index) => - viewport.startFrame + (index / timelineGridDivisions) * frameRange, + (_, index) => viewport.startFrame + (index / timelineGridDivisions) * frameRange, ) } const step = getNiceTickStep(frameRange) @@ -2557,8 +2541,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ? propertyRowByProperty.get(selectedProperty) : undefined - useHotkeys( - shortcuts?.addKeyframe ?? '', + useCommandHotkey( + hotkeys.EDIT_KEYFRAME_ADD, (event) => { event.preventDefault() if (activePropertyRow) { @@ -2571,9 +2555,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ { ...HOTKEY_OPTIONS, enabled: - (shortcutsEnabled || addKeyframeShortcutEnabled) && - !disabled && - Boolean(shortcuts?.addKeyframe && activePropertyRow), + (shortcutsEnabled || addKeyframeShortcutEnabled) && !disabled && Boolean(activePropertyRow), }, [ activePropertyRow, @@ -2584,8 +2566,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ], ) - useHotkeys( - shortcuts?.previousKeyframe ?? '', + useCommandHotkey( + hotkeys.KEYFRAME_PREVIOUS, (event) => { event.preventDefault() if (activePropertyRow) { @@ -2594,14 +2576,13 @@ export const DopesheetEditor = memo(function DopesheetEditor({ }, { ...HOTKEY_OPTIONS, - enabled: - shortcutsEnabled && !disabled && Boolean(shortcuts?.previousKeyframe && activePropertyRow), + enabled: shortcutsEnabled && !disabled && Boolean(activePropertyRow), }, [activePropertyRow, disabled, handleRowNavigate, shortcutsEnabled], ) - useHotkeys( - shortcuts?.nextKeyframe ?? '', + useCommandHotkey( + hotkeys.KEYFRAME_NEXT, (event) => { event.preventDefault() if (activePropertyRow) { @@ -2610,14 +2591,13 @@ export const DopesheetEditor = memo(function DopesheetEditor({ }, { ...HOTKEY_OPTIONS, - enabled: - shortcutsEnabled && !disabled && Boolean(shortcuts?.nextKeyframe && activePropertyRow), + enabled: shortcutsEnabled && !disabled && Boolean(activePropertyRow), }, [activePropertyRow, disabled, handleRowNavigate, shortcutsEnabled], ) - useHotkeys( - shortcuts?.toggleAutoKey ?? '', + useCommandHotkey( + hotkeys.KEYFRAME_TOGGLE_AUTO, (event) => { event.preventDefault() if (activePropertyRow) { @@ -2626,29 +2606,26 @@ export const DopesheetEditor = memo(function DopesheetEditor({ }, { ...HOTKEY_OPTIONS, - enabled: - shortcutsEnabled && - !disabled && - Boolean(shortcuts?.toggleAutoKey && activePropertyRow && onPropertyValueCommit), + enabled: shortcutsEnabled && !disabled && Boolean(activePropertyRow && onPropertyValueCommit), }, [activePropertyRow, disabled, handleRowAutoKeyToggle, onPropertyValueCommit, shortcutsEnabled], ) - useHotkeys( - shortcuts?.fitKeyframes ?? '', + useCommandHotkey( + hotkeys.KEYFRAME_FIT, (event) => { event.preventDefault() fitKeyframesInView() }, { ...HOTKEY_OPTIONS, - enabled: shortcutsEnabled && !disabled && Boolean(shortcuts?.fitKeyframes), + enabled: shortcutsEnabled && !disabled, }, [disabled, fitKeyframesInView, shortcutsEnabled], ) - useHotkeys( - 'delete,backspace', + useLocalHotkey( + 'DOPESHEET_DELETE', (event) => { event.preventDefault() if (selectedRefs.length > 0) { @@ -2659,8 +2636,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ [disabled, selectedRefs, onRemoveKeyframes], ) - useHotkeys( - 'left', + useLocalHotkey( + 'DOPESHEET_NUDGE_LEFT', (event) => { event.preventDefault() nudgeSelectedKeyframes(-1) @@ -2669,8 +2646,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ [disabled, selectedRefs.length, nudgeSelectedKeyframes], ) - useHotkeys( - 'right', + useLocalHotkey( + 'DOPESHEET_NUDGE_RIGHT', (event) => { event.preventDefault() nudgeSelectedKeyframes(1) @@ -2679,8 +2656,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ [disabled, selectedRefs.length, nudgeSelectedKeyframes], ) - useHotkeys( - 'shift+left', + useLocalHotkey( + 'DOPESHEET_NUDGE_LEFT_LARGE', (event) => { event.preventDefault() nudgeSelectedKeyframes(-10) @@ -2689,8 +2666,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ [disabled, selectedRefs.length, nudgeSelectedKeyframes], ) - useHotkeys( - 'shift+right', + useLocalHotkey( + 'DOPESHEET_NUDGE_RIGHT_LARGE', (event) => { event.preventDefault() nudgeSelectedKeyframes(10) diff --git a/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx b/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx index 5e8fee251..96528bb66 100644 --- a/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx +++ b/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx @@ -25,13 +25,6 @@ describe('DopesheetEditor shortcuts', () => { height={240} onAddKeyframe={onAddKeyframe} shortcutsEnabled - shortcuts={{ - addKeyframe: 'shift+k', - previousKeyframe: 'alt+bracketleft', - nextKeyframe: 'alt+bracketright', - toggleAutoKey: 'a', - fitKeyframes: 'f', - }} />, ) @@ -57,13 +50,6 @@ describe('DopesheetEditor shortcuts', () => { onAddKeyframe={onAddKeyframe} onRemoveKeyframes={onRemoveKeyframes} shortcutsEnabled - shortcuts={{ - addKeyframe: 'shift+k', - previousKeyframe: 'alt+bracketleft', - nextKeyframe: 'alt+bracketright', - toggleAutoKey: 'a', - fitKeyframes: 'f', - }} />, ) @@ -86,13 +72,6 @@ describe('DopesheetEditor shortcuts', () => { height={240} onAddKeyframe={onAddKeyframe} shortcutsEnabled={false} - shortcuts={{ - addKeyframe: 'shift+k', - previousKeyframe: 'alt+bracketleft', - nextKeyframe: 'alt+bracketright', - toggleAutoKey: 'a', - fitKeyframes: 'f', - }} />, ) @@ -119,13 +98,6 @@ describe('DopesheetEditor shortcuts', () => { onNavigateToKeyframe={onNavigateToKeyframe} shortcutsEnabled={false} addKeyframeShortcutEnabled - shortcuts={{ - addKeyframe: 'shift+k', - previousKeyframe: 'alt+bracketleft', - nextKeyframe: 'alt+bracketright', - toggleAutoKey: 'a', - fitKeyframes: 'f', - }} />, ) @@ -153,13 +125,6 @@ describe('DopesheetEditor shortcuts', () => { height={240} onAddKeyframe={onAddKeyframe} shortcutsEnabled - shortcuts={{ - addKeyframe: 'shift+k', - previousKeyframe: 'alt+bracketleft', - nextKeyframe: 'alt+bracketright', - toggleAutoKey: 'a', - fitKeyframes: 'f', - }} />, ) diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx index 40c5d841f..5f3719139 100644 --- a/src/features/preview/components/source-monitor.test.tsx +++ b/src/features/preview/components/source-monitor.test.tsx @@ -94,6 +94,11 @@ const runtimeHotkeysState = vi.hoisted(() => ({ hotkeys: { ...resolvedHotkeysState.hotkeys }, })) +vi.mock('@/hooks/use-runtime-hotkey-binding', () => ({ + useRuntimeHotkeyBinding: (command: keyof typeof runtimeHotkeysState.hotkeys) => + runtimeHotkeysState.hotkeys[command] ?? '', +})) + vi.mock('@/features/preview/deps/player-context', () => ({ PlayerEmitterProvider: ({ children }: { children: ReactNode }) => <>{children}, ClockBridgeProvider: ({ children }: { children: ReactNode }) => <>{children}, diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index 5bc129d4d..15a446545 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -55,7 +55,6 @@ import { useMediaLibraryStore, getMediaType } from '@/features/preview/deps/medi import { useItemsStore } from '@/features/preview/deps/timeline-store' import { useResolvedHotkeys, - useRuntimeHotkeys, useSettingsStore, } from '@/features/preview/deps/settings' import { useEditorStore } from '@/shared/state/editor' @@ -76,6 +75,7 @@ import { getPreviewPixelSnapSize } from '../utils/preview-pixel-snap' import type { TimelineTrack } from '@/types/timeline' import { useBlobUrlEpoch } from '@/infrastructure/browser/blob-url-manager' import { doesHotkeyEventMatchBinding, formatHotkeyBinding } from '@/config/hotkeys' +import { useCommandHotkeyBinding } from '@/hooks/use-hotkey-registration' interface SourceMonitorProps { mediaId: string @@ -213,7 +213,6 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ const media = useMediaLibraryStore((s) => s.mediaById[mediaId]) const blobUrlEpoch = useBlobUrlEpoch(mediaId) const hotkeys = useResolvedHotkeys() - const runtimeHotkeys = useRuntimeHotkeys() // Sync current media ID into source player store for I/O points useEffect(() => { @@ -292,7 +291,6 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ seekFrame={seekFrame} onClose={onClose} hotkeys={hotkeys} - runtimeHotkeys={runtimeHotkeys} /> @@ -317,7 +315,6 @@ interface SourceMonitorInnerProps { seekFrame: number | null onClose?: () => void hotkeys: ReturnType - runtimeHotkeys: ReturnType } function SourceMonitorInner({ @@ -335,7 +332,6 @@ function SourceMonitorInner({ seekFrame, onClose, hotkeys, - runtimeHotkeys, }: SourceMonitorInnerProps) { const containerRef = useRef(null) const contentHostRef = useRef(null) @@ -460,6 +456,9 @@ function SourceMonitorInner({ }, [interactive, setHoveredPanel, setPlayerMethods]) // Handle I/O shortcuts locally on this element (not global useHotkeys) + const markInHotkey = useCommandHotkeyBinding('MARK_IN') + const markOutHotkey = useCommandHotkeyBinding('MARK_OUT') + const clearInOutHotkey = useCommandHotkeyBinding('CLEAR_IN_OUT') const wrapperRef = useRef(null) const hadFocusRef = useRef(false) const handleKeyDown = useCallback( @@ -468,21 +467,21 @@ function SourceMonitorInner({ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return const { currentSourceFrame, setInPoint, setOutPoint, clearInOutPoints } = useSourcePlayerStore.getState() - if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.MARK_IN)) { + if (doesHotkeyEventMatchBinding(e, markInHotkey)) { e.preventDefault() e.stopPropagation() setInPoint(currentSourceFrame) - } else if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.MARK_OUT)) { + } else if (doesHotkeyEventMatchBinding(e, markOutHotkey)) { e.preventDefault() e.stopPropagation() setOutPoint(getExclusiveSourceOutPoint(currentSourceFrame, durationInFrames)) - } else if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.CLEAR_IN_OUT)) { + } else if (doesHotkeyEventMatchBinding(e, clearInOutHotkey)) { e.preventDefault() e.stopPropagation() clearInOutPoints() } }, - [durationInFrames, interactive, runtimeHotkeys], + [clearInOutHotkey, durationInFrames, interactive, markInHotkey, markOutHotkey], ) const handleMouseEnter = useCallback(() => { diff --git a/src/features/preview/deps/settings-contract.ts b/src/features/preview/deps/settings-contract.ts index f99f7c7d9..7f300ac99 100644 --- a/src/features/preview/deps/settings-contract.ts +++ b/src/features/preview/deps/settings-contract.ts @@ -4,7 +4,4 @@ */ export { useSettingsStore } from '@/features/settings/stores/settings-store' -export { - useResolvedHotkeys, - useRuntimeHotkeys, -} from '@/features/settings/hooks/use-resolved-hotkeys' +export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' diff --git a/src/features/settings/hooks/use-resolved-hotkeys.ts b/src/features/settings/hooks/use-resolved-hotkeys.ts index 9d91a1555..aef1cdacc 100644 --- a/src/features/settings/hooks/use-resolved-hotkeys.ts +++ b/src/features/settings/hooks/use-resolved-hotkeys.ts @@ -1,14 +1,7 @@ import { useShallow } from 'zustand/react/shallow' -import { resolveHotkeys, resolveRuntimeHotkeys } from '@/config/hotkeys' +import { resolveHotkeys } from '@/config/hotkeys' import { useSettingsStore } from '../stores/settings-store' export function useResolvedHotkeys() { return useSettingsStore(useShallow((state) => resolveHotkeys(state.hotkeyOverrides))) } - -/** Runtime registrations only; display and persistence must use useResolvedHotkeys. */ -export function useRuntimeHotkeys() { - return useSettingsStore( - useShallow((state) => resolveRuntimeHotkeys(resolveHotkeys(state.hotkeyOverrides))), - ) -} diff --git a/src/features/timeline/components/keyframe-graph-panel.tsx b/src/features/timeline/components/keyframe-graph-panel.tsx index bb055db31..da7f40eb1 100644 --- a/src/features/timeline/components/keyframe-graph-panel.tsx +++ b/src/features/timeline/components/keyframe-graph-panel.tsx @@ -17,7 +17,7 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import type { TFunction } from 'i18next' -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { Maximize2, Minimize2, X } from 'lucide-react' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' @@ -96,7 +96,6 @@ import { updateTextMotionLive, } from '../stores/actions/text-motion-actions' import { HOTKEY_OPTIONS } from '@/config/hotkeys' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { getDirectPropertyLinks, isTransformAnimatableProperty } from '@/types/keyframe' import { buildEffectPropertyResetPlan } from '@/features/timeline/utils/effect-property-reset' import { VectorSpeedGraph } from './vector-speed-graph' @@ -1228,7 +1227,6 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ })), [t], ) - const hotkeys = useRuntimeHotkeys() // Ref to measure container width const containerRef = useRef(null) const panelRef = useRef(null) @@ -2748,7 +2746,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ // The view-mode toggle is always visible now, so the hotkeys map to it in // every context (including the Animate workspace's split-capable toggle). - useHotkeys( + useCommandHotkey( hotkeys.KEYFRAME_EDITOR_GRAPH, (event) => { event.preventDefault() @@ -2761,7 +2759,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [isFocusWithinEditor, isOpen, isPointerWithinEditor], ) - useHotkeys( + useCommandHotkey( hotkeys.KEYFRAME_EDITOR_DOPESHEET, (event) => { event.preventDefault() @@ -2774,7 +2772,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [isFocusWithinEditor, isOpen, isPointerWithinEditor], ) - useHotkeys( + useCommandHotkey( hotkeys.KEYFRAME_EDITOR_SPLIT, (event) => { event.preventDefault() @@ -2787,7 +2785,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [isFocusWithinEditor, isOpen, isPointerWithinEditor, splitView], ) - useHotkeys( + useCommandHotkey( hotkeys.COPY, (event) => { event.preventDefault() @@ -2800,7 +2798,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [handleCopyKeyframes, isOpen, selectedEditorKeyframes.length], ) - useHotkeys( + useCommandHotkey( hotkeys.CUT, (event) => { event.preventDefault() @@ -2813,7 +2811,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [handleCutKeyframes, isOpen, selectedEditorKeyframes.length], ) - useHotkeys( + useCommandHotkey( hotkeys.PASTE, (event) => { event.preventDefault() @@ -3690,13 +3688,6 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ propertyColumnWidth={propertyColumnWidth} shortcutsEnabled={isPointerWithinEditor || isFocusWithinEditor} addKeyframeShortcutEnabled={surface === 'edit'} - shortcuts={{ - addKeyframe: surface === 'edit' ? hotkeys.EDIT_KEYFRAME_ADD : '', - previousKeyframe: hotkeys.KEYFRAME_PREVIOUS, - nextKeyframe: hotkeys.KEYFRAME_NEXT, - toggleAutoKey: hotkeys.KEYFRAME_TOGGLE_AUTO, - fitKeyframes: hotkeys.KEYFRAME_FIT, - }} /> diff --git a/src/features/timeline/deps/settings-contract.ts b/src/features/timeline/deps/settings-contract.ts index e01af00db..6c7f53151 100644 --- a/src/features/timeline/deps/settings-contract.ts +++ b/src/features/timeline/deps/settings-contract.ts @@ -4,7 +4,4 @@ */ export { useSettingsStore } from '@/features/settings/stores/settings-store' -export { - useResolvedHotkeys, - useRuntimeHotkeys, -} from '@/features/settings/hooks/use-resolved-hotkeys' +export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' diff --git a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx index 81d0c4955..7a3e15c79 100644 --- a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS, - HOTKEYS, getRuntimeHotkeyBinding, resolveHotkeys, type HotkeyBindingMap, @@ -22,15 +21,6 @@ const runtimeHotkeysOverride = vi.hoisted(() => ({ current: null as HotkeyBindingMap | null, })) -function runtimePrimaryBindings(bindings: HotkeyBindingMap): HotkeyBindingMap { - return Object.fromEntries( - (Object.keys(HOTKEYS) as HotkeyKey[]).map((command) => [ - command, - getRuntimeHotkeyBinding(bindings, command) ?? '', - ]), - ) as HotkeyBindingMap -} - const originalPlaybackActions = { togglePlayPause: usePlaybackStore.getState().togglePlayPause, shuttleForward: usePlaybackStore.getState().shuttleForward, @@ -42,8 +32,15 @@ vi.mock('@/features/timeline/deps/settings', async (importOriginal) => { return { ...actual, useResolvedHotkeys: () => runtimeHotkeysOverride.current ?? actual.useResolvedHotkeys(), - useRuntimeHotkeys: () => - runtimePrimaryBindings(runtimeHotkeysOverride.current ?? actual.useResolvedHotkeys()), + } +}) + +vi.mock('@/hooks/use-runtime-hotkey-binding', () => { + const getBindings = () => + runtimeHotkeysOverride.current ?? resolveHotkeys(useSettingsStore.getState().hotkeyOverrides) + return { + useRuntimeHotkeyBinding: (command: HotkeyKey, variant: 'primary' | 'preview' = 'primary') => + getRuntimeHotkeyBinding(getBindings(), command, variant) ?? '', } }) @@ -175,6 +172,39 @@ describe('runtime shortcut ownership', () => { expect(shuttleReverse).not.toHaveBeenCalled() }) + it('executes exactly one action through a dead-claimant platform bridge', () => { + runtimeHotkeysOverride.current = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + SHUTTLE_PAUSE: 'ctrl+f10', + } + const togglePlayPause = vi.fn(originalPlaybackActions.togglePlayPause) + const shuttleReverse = vi.fn(originalPlaybackActions.shuttleReverse) + const pause = vi.fn() + usePlaybackStore.setState({ togglePlayPause, shuttleReverse, pause }) + render() + + const event = new KeyboardEvent('keydown', { + key: 'F10', + code: 'F10', + ctrlKey: true, + bubbles: true, + cancelable: true, + }) + const preventDefault = vi.spyOn(event, 'preventDefault') + const stopPropagation = vi.spyOn(event, 'stopPropagation') + document.dispatchEvent(event) + + expect(pause).toHaveBeenCalledTimes(1) + expect(shuttleReverse).not.toHaveBeenCalled() + expect(togglePlayPause).not.toHaveBeenCalled() + // react-hotkeys-hook applies preventDefault from HOTKEY_OPTIONS before the + // preserved winner callback applies it; the dead claimant adds no calls. + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(stopPropagation).toHaveBeenCalledTimes(1) + }) + it('gives playback sole ownership across playback and split shortcut hooks', () => { runtimeHotkeysOverride.current = { ...resolveHotkeys(), diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts index cc5783e97..d3e81d8a9 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts @@ -2,7 +2,7 @@ * Clipboard shortcuts: Ctrl+C (copy), Ctrl+X (cut), Ctrl+V (paste). */ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { toast } from 'sonner' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' @@ -15,7 +15,6 @@ import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { Transition } from '@/types/transition' import type { TimelineItem, TimelineTrack } from '@/types/timeline' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { isCompositionWrapperItem, wouldCreateCompositionCycle, @@ -306,7 +305,6 @@ function revealPastedItems(itemIds: readonly string[]): void { } export function useClipboardShortcuts() { - const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const selectedTransitionId = useSelectionStore((s) => s.selectedTransitionId) const selectedKeyframes = useKeyframeSelectionStore((s) => s.selectedKeyframes) @@ -330,7 +328,7 @@ export function useClipboardShortcuts() { } // Clipboard: Ctrl+C - Copy selected transition properties or timeline items - useHotkeys( + useCommandHotkey( hotkeys.COPY, (event) => { // Transcript editor copies the selected words instead of the clip. @@ -377,7 +375,7 @@ export function useClipboardShortcuts() { ) // Clipboard: Ctrl+X - Cut selected items immediately - useHotkeys( + useCommandHotkey( hotkeys.CUT, (event) => { // Transcript editor cuts the selected words instead of the clip. @@ -404,7 +402,7 @@ export function useClipboardShortcuts() { ) // Clipboard: Ctrl+V - Paste transition properties or timeline items - useHotkeys( + useCommandHotkey( hotkeys.PASTE, (event) => { if (selectedTransitionId && transitionClipboard) { diff --git a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts index b67f5ad95..dbdba78b8 100644 --- a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts @@ -8,17 +8,15 @@ */ import { useCallback } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' export function useDeleteShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const selectedMarkerId = useSelectionStore((s) => s.selectedMarkerId) const selectedTransitionId = useSelectionStore((s) => s.selectedTransitionId) @@ -82,8 +80,8 @@ export function useDeleteShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Delete - Delete selected items, marker, or transition - useHotkeys(hotkeys.DELETE_SELECTED, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) + useCommandHotkey(hotkeys.DELETE_SELECTED, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) // Editing: Backspace - Delete selected items, marker, or transition (alternative) - useHotkeys(hotkeys.DELETE_SELECTED_ALT, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) + useCommandHotkey(hotkeys.DELETE_SELECTED_ALT, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) } diff --git a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts index 4ea024200..8b22ccbb5 100644 --- a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts @@ -3,7 +3,7 @@ */ import { useCallback } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' @@ -20,12 +20,10 @@ import { import type { TransformProperties } from '@/types/transform' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' import { useClearKeyframesDialogStore } from '@/shared/state/clear-keyframes-dialog' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' import { useDeleteShortcuts } from './use-delete-shortcuts' export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const editKeyframePanelOpen = useSelectionStore((s) => s.editKeyframePanelOpen) const clearSelection = useSelectionStore((s) => s.clearSelection) @@ -79,7 +77,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Ctrl+Delete - Ripple delete selected items (delete + close gap) - useHotkeys( + useCommandHotkey( hotkeys.RIPPLE_DELETE, (event) => { if (deleteOwnedByPanel) { @@ -101,7 +99,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Ctrl+Backspace - Ripple delete selected items (alternative) - useHotkeys( + useCommandHotkey( hotkeys.RIPPLE_DELETE_ALT, (event) => { if (deleteOwnedByPanel) { @@ -123,7 +121,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Shift+Arrow keys - nudge selected visual items by 1px - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_LEFT, (event) => { event.preventDefault() @@ -133,7 +131,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_RIGHT, (event) => { event.preventDefault() @@ -143,7 +141,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_UP, (event) => { event.preventDefault() @@ -153,7 +151,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_DOWN, (event) => { event.preventDefault() @@ -164,7 +162,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Cmd/Ctrl+Shift+Arrow keys - nudge selected visual items by 10px - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_LEFT_LARGE, (event) => { event.preventDefault() @@ -174,7 +172,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_RIGHT_LARGE, (event) => { event.preventDefault() @@ -184,7 +182,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_UP_LARGE, (event) => { event.preventDefault() @@ -194,7 +192,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_DOWN_LARGE, (event) => { event.preventDefault() @@ -205,7 +203,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Shift+J - Join selected clips - useHotkeys( + useCommandHotkey( hotkeys.JOIN_ITEMS, (event) => { if (selectedItemIds.length < 2) return @@ -225,7 +223,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [selectedItemIds, items, joinItems], ) - useHotkeys( + useCommandHotkey( hotkeys.LINK_AUDIO_VIDEO, (event) => { if (selectedItemIds.length < 2) return @@ -238,7 +236,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [selectedItemIds, items], ) - useHotkeys( + useCommandHotkey( hotkeys.UNLINK_AUDIO_VIDEO, (event) => { if (selectedItemIds.length === 0) return @@ -251,7 +249,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [selectedItemIds, items], ) - useHotkeys( + useCommandHotkey( hotkeys.TOGGLE_LINKED_SELECTION, (event) => { event.preventDefault() @@ -269,7 +267,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { }, []) // Editing: Alt+C - Split all items at gray playhead (or main playhead) - useHotkeys( + useCommandHotkey( hotkeys.SPLIT_AT_PLAYHEAD_ALT, splitAtPlayhead, { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } }, @@ -277,7 +275,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Shift+F - Insert freeze frame at playhead - useHotkeys( + useCommandHotkey( hotkeys.FREEZE_FRAME, (event) => { if (selectedItemIds.length !== 1) return @@ -300,7 +298,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Keyframes: Shift+A - Clear all keyframes for selected items (with confirmation) - useHotkeys( + useCommandHotkey( hotkeys.CLEAR_KEYFRAMES, (event) => { if (selectedItemIds.length === 0) return diff --git a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts index 4d57e68d5..bdba661a3 100644 --- a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts @@ -2,19 +2,17 @@ * Timeline in/out shortcuts: I, O, Shift+I/O, Alt+X. */ -import { useHotkeys } from 'react-hotkeys-hook' -import { HOTKEY_OPTIONS, getRuntimeHotkeyBinding } from '@/config/hotkeys' +import { + COMMAND_HOTKEYS as hotkeys, + useCommandHotkey, + useDerivedCommandHotkey, +} from '@/hooks/use-hotkey-registration' +import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' -import { useResolvedHotkeys, useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useInOutShortcuts() { - const resolvedHotkeys = useResolvedHotkeys() - const hotkeys = useRuntimeHotkeys() - const markInAtPreview = getRuntimeHotkeyBinding(resolvedHotkeys, 'MARK_IN', 'preview') - const markOutAtPreview = getRuntimeHotkeyBinding(resolvedHotkeys, 'MARK_OUT', 'preview') - - useHotkeys( + useCommandHotkey( hotkeys.MARK_IN, (event) => { event.preventDefault() @@ -25,18 +23,19 @@ export function useInOutShortcuts() { [], ) - useHotkeys( - markInAtPreview ?? [], + useDerivedCommandHotkey( + 'MARK_IN', + 'preview', (event) => { event.preventDefault() const { previewFrame, currentFrame } = usePlaybackStore.getState() useTimelineStore.getState().setInPoint(previewFrame ?? currentFrame) }, HOTKEY_OPTIONS, - [markInAtPreview], + [], ) - useHotkeys( + useCommandHotkey( hotkeys.MARK_OUT, (event) => { event.preventDefault() @@ -47,18 +46,19 @@ export function useInOutShortcuts() { [], ) - useHotkeys( - markOutAtPreview ?? [], + useDerivedCommandHotkey( + 'MARK_OUT', + 'preview', (event) => { event.preventDefault() const { previewFrame, currentFrame } = usePlaybackStore.getState() useTimelineStore.getState().setOutPoint(previewFrame ?? currentFrame) }, HOTKEY_OPTIONS, - [markOutAtPreview], + [], ) - useHotkeys( + useCommandHotkey( hotkeys.CLEAR_IN_OUT, (event) => { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts index fd1339501..f2adab901 100644 --- a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts @@ -2,21 +2,19 @@ * Marker shortcuts: M (add), Shift+M (remove), [ ] (navigate). */ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useMarkersStore } from '../../stores/markers-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { addMarker, removeMarker } from '../../stores/actions/marker-actions' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useMarkerShortcuts() { - const hotkeys = useRuntimeHotkeys() const setCurrentFrame = usePlaybackStore((s) => s.setCurrentFrame) const clearSelection = useSelectionStore((s) => s.clearSelection) // Markers: M - Add marker at playhead - useHotkeys( + useCommandHotkey( hotkeys.ADD_MARKER, (event) => { event.preventDefault() @@ -28,7 +26,7 @@ export function useMarkerShortcuts() { ) // Markers: Shift+M - Remove selected marker - useHotkeys( + useCommandHotkey( hotkeys.REMOVE_MARKER, (event) => { event.preventDefault() @@ -43,7 +41,7 @@ export function useMarkerShortcuts() { ) // Markers: [ - Jump to previous marker - useHotkeys( + useCommandHotkey( hotkeys.PREVIOUS_MARKER, (event) => { event.preventDefault() @@ -67,7 +65,7 @@ export function useMarkerShortcuts() { ) // Markers: ] - Jump to next marker - useHotkeys( + useCommandHotkey( hotkeys.NEXT_MARKER, (event) => { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts index 15ebb6db5..bbdf3b12b 100644 --- a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts @@ -3,7 +3,7 @@ */ import { useCallback } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { usePreviewBridgeStore } from '@/shared/state/preview-bridge' import { useItemsStore } from '../../stores/items-store' @@ -14,7 +14,6 @@ import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' import { useSourcePlayerStore } from '@/shared/state/source-player' import { getFilteredItemSnapEdges } from '../../utils/timeline-snap-utils' import { getVisibleTrackIds } from '../../utils/group-utils' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' /** Compute snap points on-demand from current store state (avoids reactive subscriptions). */ function getSnapPoints(): number[] { @@ -41,7 +40,6 @@ function getFinalTimelineFrame(): number { } export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useRuntimeHotkeys() const togglePlayPause = usePlaybackStore((s) => s.togglePlayPause) const shuttleForward = usePlaybackStore((s) => s.shuttleForward) const shuttleReverse = usePlaybackStore((s) => s.shuttleReverse) @@ -60,7 +58,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Playback: Space - Play/Pause - useHotkeys( + useCommandHotkey( hotkeys.PLAY_PAUSE, (event) => { event.preventDefault() @@ -82,7 +80,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Shuttle forward advances through 1x, 2x, and 4x. Ignore browser key // repeat so one physical press produces one transport transition. - useHotkeys( + useCommandHotkey( hotkeys.SHUTTLE_FORWARD, (event) => { if (event.repeat) return @@ -104,7 +102,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Shuttle reverse mirrors forward playback. Browser media stays on a paused visual // seek path for negative rates; the Clock still advances at display cadence. - useHotkeys( + useCommandHotkey( hotkeys.SHUTTLE_REVERSE, (event) => { if (event.repeat) return @@ -126,7 +124,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Pause always owns its binding, including while already paused, so transport // routing cannot fall through to another command. - useHotkeys( + useCommandHotkey( hotkeys.SHUTTLE_PAUSE, (event) => { if (event.repeat) return @@ -148,7 +146,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Arrow Left - Previous frame - useHotkeys( + useCommandHotkey( hotkeys.PREVIOUS_FRAME, (event) => { event.preventDefault() @@ -165,7 +163,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Arrow Right - Next frame - useHotkeys( + useCommandHotkey( hotkeys.NEXT_FRAME, (event) => { event.preventDefault() @@ -182,7 +180,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Home - Go to start - useHotkeys( + useCommandHotkey( hotkeys.GO_TO_START, (event) => { event.preventDefault() @@ -198,7 +196,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: End - Go to end of timeline (last frame of last item) - useHotkeys( + useCommandHotkey( hotkeys.GO_TO_END, (event) => { event.preventDefault() @@ -214,7 +212,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Down - Jump to next snap point (clip edge or marker) - useHotkeys( + useCommandHotkey( hotkeys.NEXT_SNAP_POINT, (event) => { event.preventDefault() @@ -229,7 +227,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Up - Jump to previous snap point (clip edge or marker) - useHotkeys( + useCommandHotkey( hotkeys.PREVIOUS_SNAP_POINT, (event) => { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts index 3564ceae1..97af68c39 100644 --- a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts @@ -9,17 +9,14 @@ * source monitor is hovered/focused. */ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { useEditorStore } from '@/shared/state/editor' import { performInsertEdit, performOverwriteEdit } from '../../stores/actions/source-edit-actions' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useSourceMonitorShortcuts() { - const hotkeys = useRuntimeHotkeys() - // Insert Edit: , (comma) — works globally when source monitor is open - useHotkeys( + useCommandHotkey( hotkeys.INSERT_EDIT, (event) => { event.preventDefault() @@ -32,7 +29,7 @@ export function useSourceMonitorShortcuts() { ) // Overwrite Edit: . (period) — works globally when source monitor is open - useHotkeys( + useCommandHotkey( hotkeys.OVERWRITE_EDIT, (event) => { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts index f49883515..30e833580 100644 --- a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts @@ -2,22 +2,20 @@ * Tool shortcuts: V (Select), T (Trim Edit), C (Razor), Shift+C (Split at playhead), R (Rate Stretch). */ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { SLIP_SLIDE_TOOLS_ENABLED } from '../../constants' export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useRuntimeHotkeys() const activeTool = useSelectionStore((s) => s.activeTool) const setActiveTool = useSelectionStore((s) => s.setActiveTool) // Tool: V - Selection Tool - useHotkeys( + useCommandHotkey( hotkeys.SELECTION_TOOL, (event) => { event.preventDefault() @@ -28,7 +26,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: T - Toggle Trim Edit Tool - useHotkeys( + useCommandHotkey( hotkeys.TRIM_EDIT_TOOL, (event) => { event.preventDefault() @@ -39,7 +37,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: C - Toggle Razor/Cut Mode - useHotkeys( + useCommandHotkey( hotkeys.RAZOR_TOOL, (event) => { event.preventDefault() @@ -50,7 +48,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: Shift+C - Split hovered item at gray playhead (or main playhead) - useHotkeys( + useCommandHotkey( hotkeys.SPLIT_AT_PLAYHEAD, (event) => { event.preventDefault() @@ -74,7 +72,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: R - Toggle Rate Stretch Tool - useHotkeys( + useCommandHotkey( hotkeys.RATE_STRETCH_TOOL, (event) => { event.preventDefault() @@ -85,7 +83,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: Y - Toggle Slip Tool - useHotkeys( + useCommandHotkey( hotkeys.SLIP_TOOL, (event) => { event.preventDefault() @@ -96,7 +94,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: U - Toggle Slide Tool - useHotkeys( + useCommandHotkey( hotkeys.SLIDE_TOOL, (event) => { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts index 3d4f9f14b..379d5cfa9 100644 --- a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts @@ -2,13 +2,13 @@ * UI shortcuts: S (snap toggle), Cmd/Ctrl+=/- (zoom), \\ (zoom to fit), Shift+\\ or Cmd/Ctrl+0 (zoom to 100%), Undo/Redo. */ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { useTimelineStore } from '../../stores/timeline-store' import { useZoomStore, getZoomTo100Handler } from '../../stores/zoom-store' import { usePlaybackStore } from '@/shared/state/playback' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useRuntimeHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' +import { useSettingsStore } from '@/features/timeline/deps/settings' export interface UIShortcutOptions { /** @@ -23,13 +23,12 @@ export function useUIShortcuts( options: UIShortcutOptions = {}, ) { const { enableHistory = true } = options - const hotkeys = useRuntimeHotkeys() const toggleSnap = useTimelineStore((s) => s.toggleSnap) const zoomIn = useZoomStore((s) => s.zoomIn) const zoomOut = useZoomStore((s) => s.zoomOut) // History: Cmd/Ctrl+Z - Undo - useHotkeys( + useCommandHotkey( hotkeys.UNDO, (event) => { event.preventDefault() @@ -47,7 +46,7 @@ export function useUIShortcuts( ) // History: Cmd/Ctrl+Shift+Z - Redo - useHotkeys( + useCommandHotkey( hotkeys.REDO, (event) => { event.preventDefault() @@ -65,7 +64,7 @@ export function useUIShortcuts( ) // UI: S - Toggle Snap - useHotkeys( + useCommandHotkey( hotkeys.TOGGLE_SNAP, (event) => { event.preventDefault() @@ -76,7 +75,7 @@ export function useUIShortcuts( ) // UI: Shift+S - Toggle Canvas (gizmo) Snap — independent from timeline snap. - useHotkeys( + useCommandHotkey( hotkeys.TOGGLE_CANVAS_SNAP, (event) => { event.preventDefault() @@ -90,7 +89,7 @@ export function useUIShortcuts( const zoomHotkeyOptions = { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } } // Zoom: Cmd/Ctrl+Equals - Zoom in - useHotkeys( + useCommandHotkey( hotkeys.ZOOM_IN, (event) => { event.preventDefault() @@ -101,7 +100,7 @@ export function useUIShortcuts( ) // Zoom: Cmd/Ctrl+Minus - Zoom out - useHotkeys( + useCommandHotkey( hotkeys.ZOOM_OUT, (event) => { event.preventDefault() @@ -112,7 +111,7 @@ export function useUIShortcuts( ) // Zoom: Backslash - Zoom to Fit - useHotkeys( + useCommandHotkey( hotkeys.ZOOM_TO_FIT, (event) => { event.preventDefault() @@ -144,7 +143,7 @@ export function useUIShortcuts( ) // Zoom: Shift+Backslash - Zoom to 100% centered on cursor (or playhead if cursor not on timeline) - useHotkeys( + useCommandHotkey( hotkeys.ZOOM_TO_100, (event) => { event.preventDefault() @@ -161,7 +160,7 @@ export function useUIShortcuts( ) // Zoom: Cmd/Ctrl+0 - Reset timeline zoom to 100% - useHotkeys( + useCommandHotkey( hotkeys.ZOOM_TO_100_ALT, (event) => { event.preventDefault() diff --git a/src/hooks/use-hotkey-registration.ts b/src/hooks/use-hotkey-registration.ts new file mode 100644 index 000000000..1c83bfe54 --- /dev/null +++ b/src/hooks/use-hotkey-registration.ts @@ -0,0 +1,68 @@ +import type { DependencyList } from 'react' +import { useHotkeys, type HotkeyCallback, type Options } from 'react-hotkeys-hook' +import { HOTKEYS, HOTKEY_OPTIONS, type HotkeyKey } from '@/config/hotkeys' +import { useRuntimeHotkeyBinding } from './use-runtime-hotkey-binding' + +type HotkeyOptionsOrDependencies = Options | DependencyList +export type DerivedHotkeyCommand = 'MARK_IN' | 'MARK_OUT' + +/** Command identifiers only; values never contain display or persisted bindings. */ +export const COMMAND_HOTKEYS = Object.freeze( + Object.fromEntries((Object.keys(HOTKEYS) as HotkeyKey[]).map((command) => [command, command])), +) as Readonly> + +const LOCAL_HOTKEY_BINDINGS = { + DOPESHEET_DELETE: 'delete,backspace', + DOPESHEET_NUDGE_LEFT: 'left', + DOPESHEET_NUDGE_RIGHT: 'right', + DOPESHEET_NUDGE_LEFT_LARGE: 'shift+left', + DOPESHEET_NUDGE_RIGHT_LARGE: 'shift+right', +} as const + +export type LocalHotkeyKey = keyof typeof LOCAL_HOTKEY_BINDINGS + +/** Runtime-only command binding. Display and persistence must use resolved maps instead. */ +export function useCommandHotkeyBinding(command: HotkeyKey): string { + return useRuntimeHotkeyBinding(command) +} + +function useDerivedCommandHotkeyBinding(command: DerivedHotkeyCommand): string { + return useRuntimeHotkeyBinding(command, 'preview') +} + +/** The sole production registration path for primary command hotkeys. */ +export function useCommandHotkey( + command: HotkeyKey, + callback: HotkeyCallback, + options: HotkeyOptionsOrDependencies = HOTKEY_OPTIONS, + dependencies?: HotkeyOptionsOrDependencies, +) { + const binding = useCommandHotkeyBinding(command) + return useHotkeys(binding, callback, options, dependencies) +} + +/** Typed registration path for centrally owned modifier-derived command variants. */ +export function useDerivedCommandHotkey( + command: DerivedHotkeyCommand, + _variant: 'preview', + callback: HotkeyCallback, + options: HotkeyOptionsOrDependencies = HOTKEY_OPTIONS, + dependencies?: HotkeyOptionsOrDependencies, +) { + const binding = useDerivedCommandHotkeyBinding(command) + return useHotkeys(binding, callback, options, dependencies) +} + +/** + * Low-level, non-command bindings local to the dopesheet. Callers choose a + * closed local key, so command maps and HotkeyKey-derived strings cannot enter + * this API. + */ +export function useLocalHotkey( + localKey: LocalHotkeyKey, + callback: HotkeyCallback, + options?: HotkeyOptionsOrDependencies, + dependencies?: HotkeyOptionsOrDependencies, +) { + return useHotkeys(LOCAL_HOTKEY_BINDINGS[localKey], callback, options, dependencies) +} diff --git a/src/hooks/use-runtime-hotkey-binding.ts b/src/hooks/use-runtime-hotkey-binding.ts new file mode 100644 index 000000000..a2de0fa2d --- /dev/null +++ b/src/hooks/use-runtime-hotkey-binding.ts @@ -0,0 +1,46 @@ +import { + getRuntimeHotkeyBinding, + resolveHotkeys, + resolveRuntimeHotkeys, + type HotkeyBindingMap, + type HotkeyKey, + type HotkeyOverrideMap, +} from '@/config/hotkeys' +import { useSettingsStore } from '@/features/settings/stores/settings-store' + +interface RuntimeHotkeySnapshot { + primary: HotkeyBindingMap + preview: Record<'MARK_IN' | 'MARK_OUT', string> +} + +let cachedOverrides: HotkeyOverrideMap | null = null +let cachedRuntimeSnapshot: RuntimeHotkeySnapshot | null = null + +function getRuntimeHotkeySnapshot(overrides: HotkeyOverrideMap): RuntimeHotkeySnapshot { + if (cachedOverrides === overrides && cachedRuntimeSnapshot) return cachedRuntimeSnapshot + + const resolved = resolveHotkeys(overrides) + cachedOverrides = overrides + cachedRuntimeSnapshot = { + primary: resolveRuntimeHotkeys(resolved), + preview: { + MARK_IN: getRuntimeHotkeyBinding(resolved, 'MARK_IN', 'preview') ?? '', + MARK_OUT: getRuntimeHotkeyBinding(resolved, 'MARK_OUT', 'preview') ?? '', + }, + } + return cachedRuntimeSnapshot +} + +/** Runtime-only selector; display and persistence must use resolved maps instead. */ +export function useRuntimeHotkeyBinding( + command: HotkeyKey, + variant: 'primary' | 'preview' = 'primary', +): string { + return useSettingsStore((state) => { + const snapshot = getRuntimeHotkeySnapshot(state.hotkeyOverrides) + if (variant === 'preview' && (command === 'MARK_IN' || command === 'MARK_OUT')) { + return snapshot.preview[command] + } + return snapshot.primary[command] + }) +} From f5a632f156d9df2cb0f3c568a1d7b22a50d0d7d9 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 05:19:47 -0700 Subject: [PATCH 49/64] fix hotkey registration gates (cherry picked from commit dc40a835a07cadf540abf5fcfb9ed577338f7f08) --- package-lock.json | 1 - package.json | 1 - scripts/runtime-hotkey-import-boundary.d.mts | 2 + scripts/runtime-hotkey-import-boundary.mjs | 121 +++++++++++------- ...ntime-hotkey-registration-coverage.test.ts | 59 ++++++--- .../timeline-item/item-context-menu.test.tsx | 5 + src/hooks/use-hotkey-registration.ts | 14 +- 7 files changed, 134 insertions(+), 69 deletions(-) diff --git a/package-lock.json b/package-lock.json index d928ec33e..f32878f6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,6 @@ "zustand": "5.0.12" }, "devDependencies": { - "@babel/parser": "7.29.7", "@tailwindcss/vite": "4.2.2", "@tanstack/router-cli": "1.166.33", "@testing-library/dom": "10.4.1", diff --git a/package.json b/package.json index 70707d98f..7c4f05810 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,6 @@ "zustand": "5.0.12" }, "devDependencies": { - "@babel/parser": "7.29.7", "@tailwindcss/vite": "4.2.2", "@tanstack/router-cli": "1.166.33", "@testing-library/dom": "10.4.1", diff --git a/scripts/runtime-hotkey-import-boundary.d.mts b/scripts/runtime-hotkey-import-boundary.d.mts index d7006a2c9..8fb226982 100644 --- a/scripts/runtime-hotkey-import-boundary.d.mts +++ b/scripts/runtime-hotkey-import-boundary.d.mts @@ -7,6 +7,8 @@ export interface RuntimeHotkeyImportViolation { path: string line: number column: number + allowedPath: string + message: string } export declare const RUNTIME_HOTKEY_ADAPTER_PATH: 'src/hooks/use-hotkey-registration.ts' diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index a5e5f8583..5693e7fae 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -1,64 +1,55 @@ -import { parse } from '@babel/parser' +import { API } from 'typescript/unstable/sync' +import { createVirtualFileSystem } from 'typescript/unstable/fs' +import { + SyntaxKind, + isCallExpression, + isExportDeclaration, + isExternalModuleReference, + isIdentifier, + isImportDeclaration, + isImportEqualsDeclaration, + isStringLiteral, +} from 'typescript/unstable/ast' const REACT_HOTKEYS_HOOK_MODULE = 'react-hotkeys-hook' export const RUNTIME_HOTKEY_ADAPTER_PATH = 'src/hooks/use-hotkey-registration.ts' function isReactHotkeysSource(source) { - return source?.type === 'StringLiteral' && source.value === REACT_HOTKEYS_HOOK_MODULE + return isStringLiteral(source) && source.text === REACT_HOTKEYS_HOOK_MODULE } function isStaticReactHotkeysImport(node) { - const hasStaticSource = - node.type === 'ImportDeclaration' || - node.type === 'ExportNamedDeclaration' || - node.type === 'ExportAllDeclaration' - return hasStaticSource && isReactHotkeysSource(node.source) + return ( + (isImportDeclaration(node) || isExportDeclaration(node)) && + isReactHotkeysSource(node.moduleSpecifier) + ) } function isTypeScriptReactHotkeysImport(node) { - if (node.type !== 'TSImportEqualsDeclaration') return false + if (!isImportEqualsDeclaration(node)) return false const reference = node.moduleReference - return ( - reference.type === 'TSExternalModuleReference' && isReactHotkeysSource(reference.expression) - ) + return isExternalModuleReference(reference) && isReactHotkeysSource(reference.expression) } function isReactHotkeysCallImport(node) { - if (node.type !== 'CallExpression') return false - const { callee, arguments: args } = node - const isRequire = callee.type === 'Identifier' && callee.name === 'require' + if (!isCallExpression(node)) return false + const { expression, arguments: args } = node + const isRequire = isIdentifier(expression) && expression.text === 'require' + const isDynamicImport = expression.kind === SyntaxKind.ImportKeyword return ( - (isRequire || callee.type === 'Import') && args.length === 1 && isReactHotkeysSource(args[0]) + (isRequire || isDynamicImport) && args.length === 1 && isReactHotkeysSource(args[0]) ) } -function isReactHotkeysImportExpression(node) { - return node.type === 'ImportExpression' && isReactHotkeysSource(node.source) -} - const IMPORT_NODE_CHECKS = [ isStaticReactHotkeysImport, isTypeScriptReactHotkeysImport, isReactHotkeysCallImport, - isReactHotkeysImportExpression, ] -const AST_METADATA_KEYS = new Set(['loc', 'start', 'end']) - -function walkAst(root, onNode) { - const pending = [root] - while (pending.length > 0) { - const node = pending.pop() - if (!node || typeof node !== 'object') continue - if (Array.isArray(node)) { - pending.push(...node) - continue - } - onNode(node) - for (const [key, child] of Object.entries(node)) { - if (!AST_METADATA_KEYS.has(key)) pending.push(child) - } - } +function walkAst(node, onNode) { + onNode(node) + node.forEachChild((child) => walkAst(child, onNode)) } export function findReactHotkeysHookImportViolations( @@ -66,26 +57,58 @@ export function findReactHotkeysHookImportViolations( allowedPath = RUNTIME_HOTKEY_ADAPTER_PATH, ) { const violations = [] + // Every import form we reject must contain the literal module specifier. This + // prefilter keeps the compiler AST focused on the one or two relevant files. + const candidates = sources.filter(({ source }) => source.includes(REACT_HOTKEYS_HOOK_MODULE)) + if (candidates.length === 0) return violations + + const virtualRoot = '/runtime-hotkey-import-boundary' + const virtualSources = new Map() + const virtualFiles = Object.fromEntries( + candidates.map((candidate, index) => { + const extension = candidate.path.endsWith('.tsx') ? 'tsx' : 'ts' + const virtualPath = `${virtualRoot}/source-${index}.${extension}` + virtualSources.set(virtualPath, candidate) + return [virtualPath, candidate.source] + }), + ) + virtualFiles[`${virtualRoot}/tsconfig.json`] = JSON.stringify({ + compilerOptions: { jsx: 'preserve', noLib: true }, + files: [...virtualSources.keys()], + }) - for (const { path, source } of sources) { - const ast = parse(source, { - sourceType: 'unambiguous', - plugins: ['typescript', 'jsx', 'dynamicImport'], - }) + const compiler = new API({ cwd: virtualRoot, fs: createVirtualFileSystem(virtualFiles) }) + let snapshot - function record(node) { - if (path !== allowedPath) { + try { + snapshot = compiler.updateSnapshot({ openProjects: [`${virtualRoot}/tsconfig.json`] }) + const project = snapshot.getProjects()[0] + + for (const [virtualPath, { path }] of virtualSources) { + const sourceFile = project?.program.getSourceFile(virtualPath) + if (!sourceFile) throw new Error(`TypeScript could not parse in-memory source: ${path}`) + + function record(node) { + if (path === allowedPath) return + const location = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) + const line = location.line + 1 + const column = location.character + 1 violations.push({ path, - line: node.loc?.start.line ?? 1, - column: (node.loc?.start.column ?? 0) + 1, + line, + column, + allowedPath, + message: `${path}:${line}:${column} imports ${REACT_HOTKEYS_HOOK_MODULE}; use ${allowedPath}`, }) } - } - walkAst(ast, (node) => { - if (IMPORT_NODE_CHECKS.some((check) => check(node))) record(node) - }) + walkAst(sourceFile, (node) => { + if (IMPORT_NODE_CHECKS.some((check) => check(node))) record(node) + }) + } + } finally { + snapshot?.dispose() + compiler.close() } return violations.sort( diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index e3c3d5542..5201acbc4 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -19,6 +19,14 @@ function productionSourceFiles(directory: string): string[] { }) } +function formatViolations( + violations: ReturnType, +): string { + return violations.length === 0 + ? 'No runtime hotkey import violations' + : violations.map(({ message }) => message).join('\n') +} + describe('runtime hotkey registration coverage', () => { it('allows react-hotkeys-hook only in the production registration adapter', () => { const sources = productionSourceFiles(SRC_ROOT).map((path) => ({ @@ -26,21 +34,34 @@ describe('runtime hotkey registration coverage', () => { source: readFileSync(path, 'utf8'), })) - expect(findReactHotkeysHookImportViolations(sources)).toEqual([]) + const violations = findReactHotkeysHookImportViolations(sources) + expect(violations, formatViolations(violations)).toEqual([]) }) - it.each([ - ['aliased static import', "import { useHotkeys as register } from 'react-hotkeys-hook'"], - ['default import', "import hotkeyHooks from 'react-hotkeys-hook'"], - ['namespace import', "import * as hotkeyHooks from 'react-hotkeys-hook'"], - ['destructured require', "const { useHotkeys } = require('react-hotkeys-hook')"], - ['TypeScript import-equals', "import hotkeyHooks = require('react-hotkeys-hook')"], - ['wrapper re-export', "export { useHotkeys as useWrappedHotkey } from 'react-hotkeys-hook'"], - ['dynamic import', "const hooks = await import('react-hotkeys-hook')"], - ])('rejects a %s bypass', (_label, source) => { - expect( - findReactHotkeysHookImportViolations([{ path: 'src/features/bypass.ts', source }]), - ).toEqual([expect.objectContaining({ path: 'src/features/bypass.ts', line: 1 })]) + it('rejects in-memory AST fixtures for every supported bypass form', () => { + const fixtures: Array<[string, string]> = [ + ['side-effect-static-import', "import 'react-hotkeys-hook'"], + ['aliased-static-import', "import { useHotkeys as register } from 'react-hotkeys-hook'"], + ['default-import', "import hotkeyHooks from 'react-hotkeys-hook'"], + ['namespace-import', "import * as hotkeyHooks from 'react-hotkeys-hook'"], + ['destructured-require', "const { useHotkeys } = require('react-hotkeys-hook')"], + ['typescript-import-equals', "import hotkeyHooks = require('react-hotkeys-hook')"], + ['wrapper-re-export', "export { useHotkeys as useWrappedHotkey } from 'react-hotkeys-hook'"], + ['export-all', "export * from 'react-hotkeys-hook'"], + ['dynamic-import', "const hooks = await import('react-hotkeys-hook')"], + ] + const sources = fixtures.map(([name, source]) => ({ + path: `src/features/${name}.ts`, + source, + })) + + expect(findReactHotkeysHookImportViolations(sources)).toEqual( + sources + .toSorted((left, right) => left.path.localeCompare(right.path)) + .map(({ path }) => + expect.objectContaining({ path, line: 1, allowedPath: RUNTIME_HOTKEY_ADAPTER_PATH }), + ), + ) }) it('allows the exact adapter module and no similarly named wrapper', () => { @@ -48,10 +69,18 @@ describe('runtime hotkey registration coverage', () => { expect( findReactHotkeysHookImportViolations([{ path: RUNTIME_HOTKEY_ADAPTER_PATH, source }]), ).toEqual([]) + const wrapperPath = 'src/hooks/use-hotkey-registration-wrapper.ts' expect( findReactHotkeysHookImportViolations([ - { path: 'src/hooks/use-hotkey-registration-wrapper.ts', source }, + { path: RUNTIME_HOTKEY_ADAPTER_PATH, source }, + { path: wrapperPath, source }, ]), - ).toHaveLength(1) + ).toEqual([ + expect.objectContaining({ + path: wrapperPath, + allowedPath: RUNTIME_HOTKEY_ADAPTER_PATH, + message: expect.stringContaining(RUNTIME_HOTKEY_ADAPTER_PATH), + }), + ]) }) }) diff --git a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx index 3b39ed0d8..2f20a277b 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx @@ -1,6 +1,7 @@ import type { ComponentProps, ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { fireEvent, render, screen } from '@testing-library/react' +import { COMMAND_HOTKEYS } from '@/hooks/use-hotkey-registration' import { useSelectionStore } from '@/shared/state/selection' import { ItemContextMenu } from './item-context-menu' @@ -116,6 +117,10 @@ describe('ItemContextMenu scene detection', () => { }) }) + it('keeps command identifiers usable with the existing partial hotkey config mock', () => { + expect(COMMAND_HOTKEYS.DELETE_SELECTED).toBe('DELETE_SELECTED') + }) + it('keeps the menu non-modal so dialog handoffs cannot strand pointer blocking', () => { renderContextMenu() diff --git a/src/hooks/use-hotkey-registration.ts b/src/hooks/use-hotkey-registration.ts index 1c83bfe54..3d6dc2f4c 100644 --- a/src/hooks/use-hotkey-registration.ts +++ b/src/hooks/use-hotkey-registration.ts @@ -7,9 +7,17 @@ type HotkeyOptionsOrDependencies = Options | DependencyList export type DerivedHotkeyCommand = 'MARK_IN' | 'MARK_OUT' /** Command identifiers only; values never contain display or persisted bindings. */ -export const COMMAND_HOTKEYS = Object.freeze( - Object.fromEntries((Object.keys(HOTKEYS) as HotkeyKey[]).map((command) => [command, command])), -) as Readonly> +export const COMMAND_HOTKEYS = new Proxy({} as Record, { + get: (_target, command) => (typeof command === 'string' ? (command as HotkeyKey) : undefined), + ownKeys: () => Object.keys(HOTKEYS), + getOwnPropertyDescriptor: (_target, command) => + typeof command === 'string' && Object.hasOwn(HOTKEYS, command) + ? { configurable: true, enumerable: true, value: command, writable: false } + : undefined, + set: () => false, + defineProperty: () => false, + deleteProperty: () => false, +}) as Readonly> const LOCAL_HOTKEY_BINDINGS = { DOPESHEET_DELETE: 'delete,backspace', From 88158b85745643e584c6af1c58740ca6e39d5f6b Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 05:57:03 -0700 Subject: [PATCH 50/64] fix hotkey proxy and import boundary (cherry picked from commit 026c62d4960bd8a944bafb121fbeca213d8c5b5a) --- scripts/runtime-hotkey-import-boundary.mjs | 144 ++++++++++++++++-- ...ntime-hotkey-registration-coverage.test.ts | 81 ++++++---- .../editor/hooks/use-editor-hotkeys.ts | 14 +- .../components/dopesheet-editor/index.tsx | 16 +- .../components/keyframe-graph-panel.tsx | 14 +- .../timeline-item/item-context-menu.test.tsx | 5 - .../shortcuts/use-clipboard-shortcuts.ts | 8 +- .../hooks/shortcuts/use-delete-shortcuts.ts | 6 +- .../hooks/shortcuts/use-editing-shortcuts.ts | 36 ++--- .../hooks/shortcuts/use-in-out-shortcuts.ts | 12 +- .../hooks/shortcuts/use-marker-shortcuts.ts | 10 +- .../hooks/shortcuts/use-playback-shortcuts.ts | 22 +-- .../shortcuts/use-source-monitor-shortcuts.ts | 6 +- .../hooks/shortcuts/use-tool-shortcuts.ts | 16 +- .../hooks/shortcuts/use-ui-shortcuts.ts | 20 +-- src/hooks/use-hotkey-registration.test.ts | 20 +++ src/hooks/use-hotkey-registration.ts | 15 +- 17 files changed, 291 insertions(+), 154 deletions(-) create mode 100644 src/hooks/use-hotkey-registration.test.ts diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index 5693e7fae..a043ae967 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -1,43 +1,114 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' import { API } from 'typescript/unstable/sync' import { createVirtualFileSystem } from 'typescript/unstable/fs' import { SyntaxKind, + NodeFlags, + isAsExpression, + isBinaryExpression, isCallExpression, isExportDeclaration, isExternalModuleReference, isIdentifier, isImportDeclaration, isImportEqualsDeclaration, + isNoSubstitutionTemplateLiteral, + isParenthesizedExpression, + isSatisfiesExpression, isStringLiteral, + isTemplateExpression, + isTypeAssertion, + isVariableStatement, } from 'typescript/unstable/ast' const REACT_HOTKEYS_HOOK_MODULE = 'react-hotkeys-hook' export const RUNTIME_HOTKEY_ADAPTER_PATH = 'src/hooks/use-hotkey-registration.ts' -function isReactHotkeysSource(source) { - return isStringLiteral(source) && source.text === REACT_HOTKEYS_HOOK_MODULE +const CONSTANT_STRING_WRAPPER_CHECKS = [ + isParenthesizedExpression, + isAsExpression, + isSatisfiesExpression, + isTypeAssertion, +] + +function evaluateTemplateString(expression, constantBindings, resolving) { + let value = expression.head.text + for (const span of expression.templateSpans) { + const interpolation = evaluateConstantString(span.expression, constantBindings, resolving) + if (interpolation === undefined) return undefined + value += interpolation + span.literal.text + } + return value +} + +function evaluateConcatenatedString(expression, constantBindings, resolving) { + if (expression.operatorToken.kind !== SyntaxKind.PlusToken) return undefined + const left = evaluateConstantString(expression.left, constantBindings, resolving) + const right = evaluateConstantString(expression.right, constantBindings, resolving) + return left === undefined || right === undefined ? undefined : left + right +} + +function evaluateConstantBinding(expression, constantBindings, resolving) { + if (!constantBindings.has(expression.text) || resolving.has(expression.text)) return undefined + const nextResolving = new Set(resolving).add(expression.text) + return evaluateConstantString( + constantBindings.get(expression.text), + constantBindings, + nextResolving, + ) +} + +function evaluateConstantString(expression, constantBindings, resolving = new Set()) { + if (!expression) return undefined + if (isStringLiteral(expression) || isNoSubstitutionTemplateLiteral(expression)) { + return expression.text + } + if (CONSTANT_STRING_WRAPPER_CHECKS.some((check) => check(expression))) { + return evaluateConstantString(expression.expression, constantBindings, resolving) + } + if (isTemplateExpression(expression)) { + return evaluateTemplateString(expression, constantBindings, resolving) + } + if (isBinaryExpression(expression)) { + return evaluateConcatenatedString(expression, constantBindings, resolving) + } + if (isIdentifier(expression)) { + return evaluateConstantBinding(expression, constantBindings, resolving) + } + return undefined +} + +function isReactHotkeysSource(source, constantBindings) { + return evaluateConstantString(source, constantBindings) === REACT_HOTKEYS_HOOK_MODULE } -function isStaticReactHotkeysImport(node) { +function isStaticReactHotkeysImport(node, constantBindings) { return ( (isImportDeclaration(node) || isExportDeclaration(node)) && - isReactHotkeysSource(node.moduleSpecifier) + isReactHotkeysSource(node.moduleSpecifier, constantBindings) ) } -function isTypeScriptReactHotkeysImport(node) { +function isTypeScriptReactHotkeysImport(node, constantBindings) { if (!isImportEqualsDeclaration(node)) return false const reference = node.moduleReference - return isExternalModuleReference(reference) && isReactHotkeysSource(reference.expression) + return ( + isExternalModuleReference(reference) && + isReactHotkeysSource(reference.expression, constantBindings) + ) } -function isReactHotkeysCallImport(node) { +function isReactHotkeysCallImport(node, constantBindings) { if (!isCallExpression(node)) return false const { expression, arguments: args } = node const isRequire = isIdentifier(expression) && expression.text === 'require' const isDynamicImport = expression.kind === SyntaxKind.ImportKeyword return ( - (isRequire || isDynamicImport) && args.length === 1 && isReactHotkeysSource(args[0]) + (isRequire || isDynamicImport) && + args.length === 1 && + isReactHotkeysSource(args[0], constantBindings) ) } @@ -52,20 +123,32 @@ function walkAst(node, onNode) { node.forEachChild((child) => walkAst(child, onNode)) } +function topLevelConstantBindings(sourceFile) { + const bindings = new Map() + for (const statement of sourceFile.statements) { + if (!isVariableStatement(statement)) continue + const declarationList = statement.declarationList + if (!(declarationList.flags & NodeFlags.Const)) continue + for (const declaration of declarationList.declarations) { + if (isIdentifier(declaration.name) && declaration.initializer) { + bindings.set(declaration.name.text, declaration.initializer) + } + } + } + return bindings +} + export function findReactHotkeysHookImportViolations( sources, allowedPath = RUNTIME_HOTKEY_ADAPTER_PATH, ) { const violations = [] - // Every import form we reject must contain the literal module specifier. This - // prefilter keeps the compiler AST focused on the one or two relevant files. - const candidates = sources.filter(({ source }) => source.includes(REACT_HOTKEYS_HOOK_MODULE)) - if (candidates.length === 0) return violations + if (sources.length === 0) return violations const virtualRoot = '/runtime-hotkey-import-boundary' const virtualSources = new Map() const virtualFiles = Object.fromEntries( - candidates.map((candidate, index) => { + sources.map((candidate, index) => { const extension = candidate.path.endsWith('.tsx') ? 'tsx' : 'ts' const virtualPath = `${virtualRoot}/source-${index}.${extension}` virtualSources.set(virtualPath, candidate) @@ -87,6 +170,7 @@ export function findReactHotkeysHookImportViolations( for (const [virtualPath, { path }] of virtualSources) { const sourceFile = project?.program.getSourceFile(virtualPath) if (!sourceFile) throw new Error(`TypeScript could not parse in-memory source: ${path}`) + const constantBindings = topLevelConstantBindings(sourceFile) function record(node) { if (path === allowedPath) return @@ -103,7 +187,7 @@ export function findReactHotkeysHookImportViolations( } walkAst(sourceFile, (node) => { - if (IMPORT_NODE_CHECKS.some((check) => check(node))) record(node) + if (IMPORT_NODE_CHECKS.some((check) => check(node, constantBindings))) record(node) }) } } finally { @@ -116,3 +200,35 @@ export function findReactHotkeysHookImportViolations( left.path.localeCompare(right.path) || left.line - right.line || left.column - right.column, ) } + +function productionSourceFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return productionSourceFiles(path) + if (!/\.tsx?$/.test(entry.name) || /\.test\./.test(entry.name)) return [] + return [path] + }) +} + +function runCli() { + const root = process.cwd() + const files = productionSourceFiles(join(root, 'src')) + const sources = files.map((path) => ({ + path: relative(root, path).split(sep).join('/'), + source: readFileSync(path, 'utf8'), + })) + const violations = findReactHotkeysHookImportViolations(sources) + + if (violations.length > 0) { + console.error(violations.map(({ message }) => message).join('\n')) + process.exitCode = 1 + return + } + + console.log( + `Runtime hotkey import boundary passed (${files.length} source files; allowed adapter: ${RUNTIME_HOTKEY_ADAPTER_PATH})`, + ) +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined +if (invokedPath === fileURLToPath(import.meta.url)) runCli() diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index 5201acbc4..7ba875754 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -1,45 +1,32 @@ // @vitest-environment node -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative } from 'node:path' +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' import { describe, expect, it } from 'vite-plus/test' import { RUNTIME_HOTKEY_ADAPTER_PATH, findReactHotkeysHookImportViolations, } from '../../scripts/runtime-hotkey-import-boundary.mjs' -const SRC_ROOT = join(process.cwd(), 'src') - -function productionSourceFiles(directory: string): string[] { - return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const path = join(directory, entry.name) - if (entry.isDirectory()) return productionSourceFiles(path) - if (!/\.tsx?$/.test(entry.name) || /\.test\./.test(entry.name)) return [] - return [path] - }) -} - -function formatViolations( - violations: ReturnType, -): string { - return violations.length === 0 - ? 'No runtime hotkey import violations' - : violations.map(({ message }) => message).join('\n') -} +const BOUNDARY_SCRIPT = join(process.cwd(), 'scripts/runtime-hotkey-import-boundary.mjs') describe('runtime hotkey registration coverage', () => { - it('allows react-hotkeys-hook only in the production registration adapter', () => { - const sources = productionSourceFiles(SRC_ROOT).map((path) => ({ - path: relative(process.cwd(), path), - source: readFileSync(path, 'utf8'), - })) + it('checks the full source tree in a standalone Node process', () => { + const startedAt = performance.now() + const result = spawnSync(process.execPath, [BOUNDARY_SCRIPT], { + cwd: process.cwd(), + encoding: 'utf8', + }) + const elapsedMs = performance.now() - startedAt - const violations = findReactHotkeysHookImportViolations(sources) - expect(violations, formatViolations(violations)).toEqual([]) + expect(result.status, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toContain(`allowed adapter: ${RUNTIME_HOTKEY_ADAPTER_PATH}`) + expect(elapsedMs).toBeLessThan(5_000) }) it('rejects in-memory AST fixtures for every supported bypass form', () => { const fixtures: Array<[string, string]> = [ + ['escaped-static-import', "import 'react-hotkeys-\\u0068ook'"], ['side-effect-static-import', "import 'react-hotkeys-hook'"], ['aliased-static-import', "import { useHotkeys as register } from 'react-hotkeys-hook'"], ['default-import', "import hotkeyHooks from 'react-hotkeys-hook'"], @@ -49,6 +36,18 @@ describe('runtime hotkey registration coverage', () => { ['wrapper-re-export', "export { useHotkeys as useWrappedHotkey } from 'react-hotkeys-hook'"], ['export-all', "export * from 'react-hotkeys-hook'"], ['dynamic-import', "const hooks = await import('react-hotkeys-hook')"], + ['template-dynamic-import', 'const hooks = await import(`react-hotkeys-hook`)'], + ['interpolated-constant-template', "const hooks = await import(`react-${'hotkeys-'}hook`)"], + ['concatenated-dynamic-import', "const hooks = await import('react-hotkeys-' + 'hook')"], + ['nested-parentheses', "const hooks = await import(((('react-hotkeys-') + ('hook'))))"], + [ + 'typescript-expression-wrappers', + "const hooks = await import((('react-hotkeys-' as string) + ('hook' satisfies string)))", + ], + [ + 'verified-rolldown-const-identifier', + "const moduleName = 'react-hotkeys-hook'; const hooks = await import(moduleName)", + ], ] const sources = fixtures.map(([name, source]) => ({ path: `src/features/${name}.ts`, @@ -64,6 +63,34 @@ describe('runtime hotkey registration coverage', () => { ) }) + it('does not trap text or non-constant module expressions', () => { + const source = ` + // import('react-hotkeys-hook') + const documentation = "require('react-hotkeys-hook')" + const moduleName = getModuleName() + const hooks = await import(moduleName) + ` + + expect( + findReactHotkeysHookImportViolations([{ path: 'src/features/documentation.ts', source }]), + ).toEqual([]) + }) + + it('reports the exact source location and allowed adapter', () => { + const path = 'src/features/multiline-import.ts' + const source = "// setup\nconst hooks = await import('react-hotkeys-hook')" + + expect(findReactHotkeysHookImportViolations([{ path, source }])).toEqual([ + { + path, + line: 2, + column: 21, + allowedPath: RUNTIME_HOTKEY_ADAPTER_PATH, + message: `${path}:2:21 imports react-hotkeys-hook; use ${RUNTIME_HOTKEY_ADAPTER_PATH}`, + }, + ]) + }) + it('allows the exact adapter module and no similarly named wrapper', () => { const source = "import { useHotkeys } from 'react-hotkeys-hook'" expect( diff --git a/src/features/editor/hooks/use-editor-hotkeys.ts b/src/features/editor/hooks/use-editor-hotkeys.ts index 6b70a11d8..e84395c61 100644 --- a/src/features/editor/hooks/use-editor-hotkeys.ts +++ b/src/features/editor/hooks/use-editor-hotkeys.ts @@ -1,4 +1,4 @@ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { useEditorStore } from '@/shared/state/editor' @@ -27,7 +27,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // Save: Cmd/Ctrl+S useCommandHotkey( - hotkeys.SAVE, + 'SAVE', (event) => { event.preventDefault() if (callbacks.onSave) { @@ -40,7 +40,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // Export: Cmd/Ctrl+Shift+E useCommandHotkey( - hotkeys.EXPORT, + 'EXPORT', (event) => { event.preventDefault() if (callbacks.onExport) { @@ -55,7 +55,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // default browser binding is a no-op here but Chrome will still eat it // if our listener is in bubbling phase. useCommandHotkey( - hotkeys.OPEN_SCENE_BROWSER, + 'OPEN_SCENE_BROWSER', (event) => { if (!enableLocalUi) return event.preventDefault() @@ -68,7 +68,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // Workspace switching: Alt+1 (Edit), Alt+2 (Color), Alt+3 (Motion). // WORKSPACE_ANIMATE retains its persisted command id for shortcut migration. useCommandHotkey( - hotkeys.WORKSPACE_EDIT, + 'WORKSPACE_EDIT', (event) => { if (!enableLocalUi) return event.preventDefault() @@ -79,7 +79,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { ) useCommandHotkey( - hotkeys.WORKSPACE_COLOR, + 'WORKSPACE_COLOR', (event) => { if (!enableLocalUi) return event.preventDefault() @@ -90,7 +90,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { ) useCommandHotkey( - hotkeys.WORKSPACE_ANIMATE, + 'WORKSPACE_ANIMATE', (event) => { if (!enableLocalUi) return event.preventDefault() diff --git a/src/features/keyframes/components/dopesheet-editor/index.tsx b/src/features/keyframes/components/dopesheet-editor/index.tsx index 6b751e6ae..e91e5e375 100644 --- a/src/features/keyframes/components/dopesheet-editor/index.tsx +++ b/src/features/keyframes/components/dopesheet-editor/index.tsx @@ -17,11 +17,7 @@ import { } from 'react' import { flushSync } from 'react-dom' import { useTranslation } from 'react-i18next' -import { - COMMAND_HOTKEYS as hotkeys, - useCommandHotkey, - useLocalHotkey, -} from '@/hooks/use-hotkey-registration' +import { useCommandHotkey, useLocalHotkey } from '@/hooks/use-hotkey-registration' import { ChevronDown, ChevronLeft, @@ -2542,7 +2538,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ : undefined useCommandHotkey( - hotkeys.EDIT_KEYFRAME_ADD, + 'EDIT_KEYFRAME_ADD', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2567,7 +2563,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ) useCommandHotkey( - hotkeys.KEYFRAME_PREVIOUS, + 'KEYFRAME_PREVIOUS', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2582,7 +2578,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ) useCommandHotkey( - hotkeys.KEYFRAME_NEXT, + 'KEYFRAME_NEXT', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2597,7 +2593,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ) useCommandHotkey( - hotkeys.KEYFRAME_TOGGLE_AUTO, + 'KEYFRAME_TOGGLE_AUTO', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2612,7 +2608,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ) useCommandHotkey( - hotkeys.KEYFRAME_FIT, + 'KEYFRAME_FIT', (event) => { event.preventDefault() fitKeyframesInView() diff --git a/src/features/timeline/components/keyframe-graph-panel.tsx b/src/features/timeline/components/keyframe-graph-panel.tsx index da7f40eb1..6f33e7584 100644 --- a/src/features/timeline/components/keyframe-graph-panel.tsx +++ b/src/features/timeline/components/keyframe-graph-panel.tsx @@ -17,7 +17,7 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import type { TFunction } from 'i18next' -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { Maximize2, Minimize2, X } from 'lucide-react' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' @@ -2747,7 +2747,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ // The view-mode toggle is always visible now, so the hotkeys map to it in // every context (including the Animate workspace's split-capable toggle). useCommandHotkey( - hotkeys.KEYFRAME_EDITOR_GRAPH, + 'KEYFRAME_EDITOR_GRAPH', (event) => { event.preventDefault() setEditorMode('graph') @@ -2760,7 +2760,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ ) useCommandHotkey( - hotkeys.KEYFRAME_EDITOR_DOPESHEET, + 'KEYFRAME_EDITOR_DOPESHEET', (event) => { event.preventDefault() setEditorMode('dopesheet') @@ -2773,7 +2773,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ ) useCommandHotkey( - hotkeys.KEYFRAME_EDITOR_SPLIT, + 'KEYFRAME_EDITOR_SPLIT', (event) => { event.preventDefault() setEditorMode('split') @@ -2786,7 +2786,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ ) useCommandHotkey( - hotkeys.COPY, + 'COPY', (event) => { event.preventDefault() handleCopyKeyframes() @@ -2799,7 +2799,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ ) useCommandHotkey( - hotkeys.CUT, + 'CUT', (event) => { event.preventDefault() handleCutKeyframes() @@ -2812,7 +2812,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ ) useCommandHotkey( - hotkeys.PASTE, + 'PASTE', (event) => { event.preventDefault() handlePasteKeyframes() diff --git a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx index 2f20a277b..3b39ed0d8 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx @@ -1,7 +1,6 @@ import type { ComponentProps, ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { fireEvent, render, screen } from '@testing-library/react' -import { COMMAND_HOTKEYS } from '@/hooks/use-hotkey-registration' import { useSelectionStore } from '@/shared/state/selection' import { ItemContextMenu } from './item-context-menu' @@ -117,10 +116,6 @@ describe('ItemContextMenu scene detection', () => { }) }) - it('keeps command identifiers usable with the existing partial hotkey config mock', () => { - expect(COMMAND_HOTKEYS.DELETE_SELECTED).toBe('DELETE_SELECTED') - }) - it('keeps the menu non-modal so dialog handoffs cannot strand pointer blocking', () => { renderContextMenu() diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts index d3e81d8a9..a6af20cf6 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts @@ -2,7 +2,7 @@ * Clipboard shortcuts: Ctrl+C (copy), Ctrl+X (cut), Ctrl+V (paste). */ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { toast } from 'sonner' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' @@ -329,7 +329,7 @@ export function useClipboardShortcuts() { // Clipboard: Ctrl+C - Copy selected transition properties or timeline items useCommandHotkey( - hotkeys.COPY, + 'COPY', (event) => { // Transcript editor copies the selected words instead of the clip. if (handleTranscriptClipboardCopy(false)) { @@ -376,7 +376,7 @@ export function useClipboardShortcuts() { // Clipboard: Ctrl+X - Cut selected items immediately useCommandHotkey( - hotkeys.CUT, + 'CUT', (event) => { // Transcript editor cuts the selected words instead of the clip. if (handleTranscriptClipboardCopy(true)) { @@ -403,7 +403,7 @@ export function useClipboardShortcuts() { // Clipboard: Ctrl+V - Paste transition properties or timeline items useCommandHotkey( - hotkeys.PASTE, + 'PASTE', (event) => { if (selectedTransitionId && transitionClipboard) { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts index dbdba78b8..9ef20b096 100644 --- a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts @@ -8,7 +8,7 @@ */ import { useCallback } from 'react' -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' @@ -80,8 +80,8 @@ export function useDeleteShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Delete - Delete selected items, marker, or transition - useCommandHotkey(hotkeys.DELETE_SELECTED, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) + useCommandHotkey('DELETE_SELECTED', deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) // Editing: Backspace - Delete selected items, marker, or transition (alternative) - useCommandHotkey(hotkeys.DELETE_SELECTED_ALT, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) + useCommandHotkey('DELETE_SELECTED_ALT', deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) } diff --git a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts index 8b22ccbb5..f541c0efa 100644 --- a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts @@ -3,7 +3,7 @@ */ import { useCallback } from 'react' -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' @@ -78,7 +78,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Ctrl+Delete - Ripple delete selected items (delete + close gap) useCommandHotkey( - hotkeys.RIPPLE_DELETE, + 'RIPPLE_DELETE', (event) => { if (deleteOwnedByPanel) { event.preventDefault() @@ -100,7 +100,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Ctrl+Backspace - Ripple delete selected items (alternative) useCommandHotkey( - hotkeys.RIPPLE_DELETE_ALT, + 'RIPPLE_DELETE_ALT', (event) => { if (deleteOwnedByPanel) { event.preventDefault() @@ -122,7 +122,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Shift+Arrow keys - nudge selected visual items by 1px useCommandHotkey( - hotkeys.NUDGE_LEFT, + 'NUDGE_LEFT', (event) => { event.preventDefault() nudgeSelectedVisualItems(-1, 0) @@ -132,7 +132,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_RIGHT, + 'NUDGE_RIGHT', (event) => { event.preventDefault() nudgeSelectedVisualItems(1, 0) @@ -142,7 +142,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_UP, + 'NUDGE_UP', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, -1) @@ -152,7 +152,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_DOWN, + 'NUDGE_DOWN', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, 1) @@ -163,7 +163,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Cmd/Ctrl+Shift+Arrow keys - nudge selected visual items by 10px useCommandHotkey( - hotkeys.NUDGE_LEFT_LARGE, + 'NUDGE_LEFT_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(-10, 0) @@ -173,7 +173,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_RIGHT_LARGE, + 'NUDGE_RIGHT_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(10, 0) @@ -183,7 +183,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_UP_LARGE, + 'NUDGE_UP_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, -10) @@ -193,7 +193,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_DOWN_LARGE, + 'NUDGE_DOWN_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, 10) @@ -204,7 +204,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Shift+J - Join selected clips useCommandHotkey( - hotkeys.JOIN_ITEMS, + 'JOIN_ITEMS', (event) => { if (selectedItemIds.length < 2) return @@ -224,7 +224,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.LINK_AUDIO_VIDEO, + 'LINK_AUDIO_VIDEO', (event) => { if (selectedItemIds.length < 2) return if (!canLinkSelection(items, selectedItemIds)) return @@ -237,7 +237,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.UNLINK_AUDIO_VIDEO, + 'UNLINK_AUDIO_VIDEO', (event) => { if (selectedItemIds.length === 0) return if (!selectedItemIds.some((id) => hasLinkedItems(items, id))) return @@ -250,7 +250,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.TOGGLE_LINKED_SELECTION, + 'TOGGLE_LINKED_SELECTION', (event) => { event.preventDefault() toggleLinkedSelectionEnabled() @@ -268,7 +268,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Alt+C - Split all items at gray playhead (or main playhead) useCommandHotkey( - hotkeys.SPLIT_AT_PLAYHEAD_ALT, + 'SPLIT_AT_PLAYHEAD_ALT', splitAtPlayhead, { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } }, [splitAtPlayhead], @@ -276,7 +276,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Shift+F - Insert freeze frame at playhead useCommandHotkey( - hotkeys.FREEZE_FRAME, + 'FREEZE_FRAME', (event) => { if (selectedItemIds.length !== 1) return const currentFrame = usePlaybackStore.getState().currentFrame @@ -299,7 +299,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Keyframes: Shift+A - Clear all keyframes for selected items (with confirmation) useCommandHotkey( - hotkeys.CLEAR_KEYFRAMES, + 'CLEAR_KEYFRAMES', (event) => { if (selectedItemIds.length === 0) return diff --git a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts index bdba661a3..eaba4bb38 100644 --- a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts @@ -2,18 +2,14 @@ * Timeline in/out shortcuts: I, O, Shift+I/O, Alt+X. */ -import { - COMMAND_HOTKEYS as hotkeys, - useCommandHotkey, - useDerivedCommandHotkey, -} from '@/hooks/use-hotkey-registration' +import { useCommandHotkey, useDerivedCommandHotkey } from '@/hooks/use-hotkey-registration' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' export function useInOutShortcuts() { useCommandHotkey( - hotkeys.MARK_IN, + 'MARK_IN', (event) => { event.preventDefault() const { currentFrame } = usePlaybackStore.getState() @@ -36,7 +32,7 @@ export function useInOutShortcuts() { ) useCommandHotkey( - hotkeys.MARK_OUT, + 'MARK_OUT', (event) => { event.preventDefault() const { currentFrame } = usePlaybackStore.getState() @@ -59,7 +55,7 @@ export function useInOutShortcuts() { ) useCommandHotkey( - hotkeys.CLEAR_IN_OUT, + 'CLEAR_IN_OUT', (event) => { event.preventDefault() useTimelineStore.getState().clearInOutPoints() diff --git a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts index f2adab901..e5073feb0 100644 --- a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts @@ -2,7 +2,7 @@ * Marker shortcuts: M (add), Shift+M (remove), [ ] (navigate). */ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useMarkersStore } from '../../stores/markers-store' import { useSelectionStore } from '@/shared/state/selection' @@ -15,7 +15,7 @@ export function useMarkerShortcuts() { // Markers: M - Add marker at playhead useCommandHotkey( - hotkeys.ADD_MARKER, + 'ADD_MARKER', (event) => { event.preventDefault() const { previewFrame, currentFrame } = usePlaybackStore.getState() @@ -27,7 +27,7 @@ export function useMarkerShortcuts() { // Markers: Shift+M - Remove selected marker useCommandHotkey( - hotkeys.REMOVE_MARKER, + 'REMOVE_MARKER', (event) => { event.preventDefault() const id = useSelectionStore.getState().selectedMarkerId @@ -42,7 +42,7 @@ export function useMarkerShortcuts() { // Markers: [ - Jump to previous marker useCommandHotkey( - hotkeys.PREVIOUS_MARKER, + 'PREVIOUS_MARKER', (event) => { event.preventDefault() const currentMarkers = useMarkersStore.getState().markers @@ -66,7 +66,7 @@ export function useMarkerShortcuts() { // Markers: ] - Jump to next marker useCommandHotkey( - hotkeys.NEXT_MARKER, + 'NEXT_MARKER', (event) => { event.preventDefault() const currentMarkers = useMarkersStore.getState().markers diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts index bbdf3b12b..bfe302e47 100644 --- a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts @@ -3,7 +3,7 @@ */ import { useCallback } from 'react' -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { usePreviewBridgeStore } from '@/shared/state/preview-bridge' import { useItemsStore } from '../../stores/items-store' @@ -59,7 +59,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Playback: Space - Play/Pause useCommandHotkey( - hotkeys.PLAY_PAUSE, + 'PLAY_PAUSE', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -81,7 +81,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Shuttle forward advances through 1x, 2x, and 4x. Ignore browser key // repeat so one physical press produces one transport transition. useCommandHotkey( - hotkeys.SHUTTLE_FORWARD, + 'SHUTTLE_FORWARD', (event) => { if (event.repeat) return event.preventDefault() @@ -103,7 +103,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Shuttle reverse mirrors forward playback. Browser media stays on a paused visual // seek path for negative rates; the Clock still advances at display cadence. useCommandHotkey( - hotkeys.SHUTTLE_REVERSE, + 'SHUTTLE_REVERSE', (event) => { if (event.repeat) return event.preventDefault() @@ -125,7 +125,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Pause always owns its binding, including while already paused, so transport // routing cannot fall through to another command. useCommandHotkey( - hotkeys.SHUTTLE_PAUSE, + 'SHUTTLE_PAUSE', (event) => { if (event.repeat) return event.preventDefault() @@ -147,7 +147,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: Arrow Left - Previous frame useCommandHotkey( - hotkeys.PREVIOUS_FRAME, + 'PREVIOUS_FRAME', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -164,7 +164,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: Arrow Right - Next frame useCommandHotkey( - hotkeys.NEXT_FRAME, + 'NEXT_FRAME', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -181,7 +181,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: Home - Go to start useCommandHotkey( - hotkeys.GO_TO_START, + 'GO_TO_START', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -197,7 +197,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: End - Go to end of timeline (last frame of last item) useCommandHotkey( - hotkeys.GO_TO_END, + 'GO_TO_END', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -213,7 +213,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: Down - Jump to next snap point (clip edge or marker) useCommandHotkey( - hotkeys.NEXT_SNAP_POINT, + 'NEXT_SNAP_POINT', (event) => { event.preventDefault() const currentFrame = usePlaybackStore.getState().currentFrame @@ -228,7 +228,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: Up - Jump to previous snap point (clip edge or marker) useCommandHotkey( - hotkeys.PREVIOUS_SNAP_POINT, + 'PREVIOUS_SNAP_POINT', (event) => { event.preventDefault() const currentFrame = usePlaybackStore.getState().currentFrame diff --git a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts index 97af68c39..0adde7a38 100644 --- a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts @@ -9,7 +9,7 @@ * source monitor is hovered/focused. */ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { useEditorStore } from '@/shared/state/editor' import { performInsertEdit, performOverwriteEdit } from '../../stores/actions/source-edit-actions' @@ -17,7 +17,7 @@ import { performInsertEdit, performOverwriteEdit } from '../../stores/actions/so export function useSourceMonitorShortcuts() { // Insert Edit: , (comma) — works globally when source monitor is open useCommandHotkey( - hotkeys.INSERT_EDIT, + 'INSERT_EDIT', (event) => { event.preventDefault() const sourceMediaId = useEditorStore.getState().sourcePreviewMediaId @@ -30,7 +30,7 @@ export function useSourceMonitorShortcuts() { // Overwrite Edit: . (period) — works globally when source monitor is open useCommandHotkey( - hotkeys.OVERWRITE_EDIT, + 'OVERWRITE_EDIT', (event) => { event.preventDefault() const sourceMediaId = useEditorStore.getState().sourcePreviewMediaId diff --git a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts index 30e833580..4e8ac7d94 100644 --- a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts @@ -2,7 +2,7 @@ * Tool shortcuts: V (Select), T (Trim Edit), C (Razor), Shift+C (Split at playhead), R (Rate Stretch). */ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' @@ -16,7 +16,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: V - Selection Tool useCommandHotkey( - hotkeys.SELECTION_TOOL, + 'SELECTION_TOOL', (event) => { event.preventDefault() setActiveTool('select') @@ -27,7 +27,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: T - Toggle Trim Edit Tool useCommandHotkey( - hotkeys.TRIM_EDIT_TOOL, + 'TRIM_EDIT_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'trim-edit' ? 'select' : 'trim-edit') @@ -38,7 +38,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: C - Toggle Razor/Cut Mode useCommandHotkey( - hotkeys.RAZOR_TOOL, + 'RAZOR_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'razor' ? 'select' : 'razor') @@ -49,7 +49,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: Shift+C - Split hovered item at gray playhead (or main playhead) useCommandHotkey( - hotkeys.SPLIT_AT_PLAYHEAD, + 'SPLIT_AT_PLAYHEAD', (event) => { event.preventDefault() const { previewFrame, previewItemId, currentFrame } = usePlaybackStore.getState() @@ -73,7 +73,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: R - Toggle Rate Stretch Tool useCommandHotkey( - hotkeys.RATE_STRETCH_TOOL, + 'RATE_STRETCH_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'rate-stretch' ? 'select' : 'rate-stretch') @@ -84,7 +84,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: Y - Toggle Slip Tool useCommandHotkey( - hotkeys.SLIP_TOOL, + 'SLIP_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'slip' ? 'select' : 'slip') @@ -95,7 +95,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: U - Toggle Slide Tool useCommandHotkey( - hotkeys.SLIDE_TOOL, + 'SLIDE_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'slide' ? 'select' : 'slide') diff --git a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts index 379d5cfa9..1bd43ab02 100644 --- a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts @@ -2,7 +2,7 @@ * UI shortcuts: S (snap toggle), Cmd/Ctrl+=/- (zoom), \\ (zoom to fit), Shift+\\ or Cmd/Ctrl+0 (zoom to 100%), Undo/Redo. */ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { useTimelineStore } from '../../stores/timeline-store' import { useZoomStore, getZoomTo100Handler } from '../../stores/zoom-store' import { usePlaybackStore } from '@/shared/state/playback' @@ -29,7 +29,7 @@ export function useUIShortcuts( // History: Cmd/Ctrl+Z - Undo useCommandHotkey( - hotkeys.UNDO, + 'UNDO', (event) => { event.preventDefault() useTimelineStore.temporal.getState().undo() @@ -47,7 +47,7 @@ export function useUIShortcuts( // History: Cmd/Ctrl+Shift+Z - Redo useCommandHotkey( - hotkeys.REDO, + 'REDO', (event) => { event.preventDefault() useTimelineStore.temporal.getState().redo() @@ -65,7 +65,7 @@ export function useUIShortcuts( // UI: S - Toggle Snap useCommandHotkey( - hotkeys.TOGGLE_SNAP, + 'TOGGLE_SNAP', (event) => { event.preventDefault() toggleSnap() @@ -76,7 +76,7 @@ export function useUIShortcuts( // UI: Shift+S - Toggle Canvas (gizmo) Snap — independent from timeline snap. useCommandHotkey( - hotkeys.TOGGLE_CANVAS_SNAP, + 'TOGGLE_CANVAS_SNAP', (event) => { event.preventDefault() const s = useSettingsStore.getState() @@ -90,7 +90,7 @@ export function useUIShortcuts( // Zoom: Cmd/Ctrl+Equals - Zoom in useCommandHotkey( - hotkeys.ZOOM_IN, + 'ZOOM_IN', (event) => { event.preventDefault() zoomIn() @@ -101,7 +101,7 @@ export function useUIShortcuts( // Zoom: Cmd/Ctrl+Minus - Zoom out useCommandHotkey( - hotkeys.ZOOM_OUT, + 'ZOOM_OUT', (event) => { event.preventDefault() zoomOut() @@ -112,7 +112,7 @@ export function useUIShortcuts( // Zoom: Backslash - Zoom to Fit useCommandHotkey( - hotkeys.ZOOM_TO_FIT, + 'ZOOM_TO_FIT', (event) => { event.preventDefault() if (callbacks.onZoomToFit) { @@ -144,7 +144,7 @@ export function useUIShortcuts( // Zoom: Shift+Backslash - Zoom to 100% centered on cursor (or playhead if cursor not on timeline) useCommandHotkey( - hotkeys.ZOOM_TO_100, + 'ZOOM_TO_100', (event) => { event.preventDefault() const { currentFrame, previewFrame } = usePlaybackStore.getState() @@ -161,7 +161,7 @@ export function useUIShortcuts( // Zoom: Cmd/Ctrl+0 - Reset timeline zoom to 100% useCommandHotkey( - hotkeys.ZOOM_TO_100_ALT, + 'ZOOM_TO_100_ALT', (event) => { event.preventDefault() const { currentFrame, previewFrame } = usePlaybackStore.getState() diff --git a/src/hooks/use-hotkey-registration.test.ts b/src/hooks/use-hotkey-registration.test.ts new file mode 100644 index 000000000..cff02a545 --- /dev/null +++ b/src/hooks/use-hotkey-registration.test.ts @@ -0,0 +1,20 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vite-plus/test' +import * as registration from './use-hotkey-registration' + +vi.mock('react-hotkeys-hook', () => ({ useHotkeys: vi.fn() })) +vi.mock('@/config/hotkeys', () => ({ HOTKEY_OPTIONS: {} })) +vi.mock('./use-runtime-hotkey-binding', () => ({ useRuntimeHotkeyBinding: vi.fn() })) + +describe('hotkey registration adapter surface', () => { + it('loads with a partial hotkey config mock and exposes no command proxy object API', () => { + expect(registration).not.toHaveProperty('COMMAND_HOTKEYS') + }) + + it('rejects invalid command literals at typecheck', () => { + // @ts-expect-error invalid command literals cannot enter the adapter API + const invalidCommand: Parameters[0] = 'NOT_A_COMMAND' + expect(invalidCommand).toBe('NOT_A_COMMAND') + }) +}) diff --git a/src/hooks/use-hotkey-registration.ts b/src/hooks/use-hotkey-registration.ts index 3d6dc2f4c..c57aeabff 100644 --- a/src/hooks/use-hotkey-registration.ts +++ b/src/hooks/use-hotkey-registration.ts @@ -1,24 +1,11 @@ import type { DependencyList } from 'react' import { useHotkeys, type HotkeyCallback, type Options } from 'react-hotkeys-hook' -import { HOTKEYS, HOTKEY_OPTIONS, type HotkeyKey } from '@/config/hotkeys' +import { HOTKEY_OPTIONS, type HotkeyKey } from '@/config/hotkeys' import { useRuntimeHotkeyBinding } from './use-runtime-hotkey-binding' type HotkeyOptionsOrDependencies = Options | DependencyList export type DerivedHotkeyCommand = 'MARK_IN' | 'MARK_OUT' -/** Command identifiers only; values never contain display or persisted bindings. */ -export const COMMAND_HOTKEYS = new Proxy({} as Record, { - get: (_target, command) => (typeof command === 'string' ? (command as HotkeyKey) : undefined), - ownKeys: () => Object.keys(HOTKEYS), - getOwnPropertyDescriptor: (_target, command) => - typeof command === 'string' && Object.hasOwn(HOTKEYS, command) - ? { configurable: true, enumerable: true, value: command, writable: false } - : undefined, - set: () => false, - defineProperty: () => false, - deleteProperty: () => false, -}) as Readonly> - const LOCAL_HOTKEY_BINDINGS = { DOPESHEET_DELETE: 'delete,backspace', DOPESHEET_NUDGE_LEFT: 'left', From 1b602ca2e569f4a74458d5696bc6611891bc463f Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 06:25:23 -0700 Subject: [PATCH 51/64] fix(qa): resolve hotkey imports lexically (cherry picked from commit 0eda4ba74aaf230119c783ecd710379e518a7fae) --- scripts/runtime-hotkey-import-boundary.mjs | 285 +++++++++++++++--- ...ntime-hotkey-registration-coverage.test.ts | 227 ++++++++++++++ 2 files changed, 465 insertions(+), 47 deletions(-) diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index a043ae967..d7db6f0b3 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -20,7 +20,6 @@ import { isStringLiteral, isTemplateExpression, isTypeAssertion, - isVariableStatement, } from 'typescript/unstable/ast' const REACT_HOTKEYS_HOOK_MODULE = 'react-hotkeys-hook' @@ -33,74 +32,280 @@ const CONSTANT_STRING_WRAPPER_CHECKS = [ isTypeAssertion, ] -function evaluateTemplateString(expression, constantBindings, resolving) { +const FUNCTION_SCOPE_KINDS = new Set([ + SyntaxKind.FunctionDeclaration, + SyntaxKind.FunctionExpression, + SyntaxKind.ArrowFunction, + SyntaxKind.MethodDeclaration, + SyntaxKind.Constructor, + SyntaxKind.GetAccessor, + SyntaxKind.SetAccessor, +]) + +const NAMED_FUNCTION_SCOPE_KINDS = new Set([ + SyntaxKind.FunctionDeclaration, + SyntaxKind.FunctionExpression, +]) + +const CLASS_SCOPE_KINDS = new Set([SyntaxKind.ClassDeclaration, SyntaxKind.ClassExpression]) + +const LOOP_SCOPE_KINDS = new Set([ + SyntaxKind.ForStatement, + SyntaxKind.ForInStatement, + SyntaxKind.ForOfStatement, +]) + +const BLOCK_SCOPE_KINDS = new Set([ + SyntaxKind.Block, + SyntaxKind.ClassStaticBlockDeclaration, + SyntaxKind.ModuleBlock, +]) + +const BLOCK_VAR_SCOPE_KINDS = new Set([ + SyntaxKind.ClassStaticBlockDeclaration, + SyntaxKind.ModuleBlock, +]) + +const BARRIER_DECLARATION_KINDS = new Set([ + SyntaxKind.EnumDeclaration, + SyntaxKind.ModuleDeclaration, +]) + +function createScope(parent, kind, isVarScope = false, isConstantBoundary = false) { + return { parent, kind, isVarScope, isConstantBoundary, bindings: new Map() } +} + +function declareBinding(scope, name, binding) { + if (scope.bindings.has(name)) { + scope.bindings.set(name, { kind: 'barrier' }) + return + } + scope.bindings.set(name, binding) +} + +function bindingNames(name) { + if (isIdentifier(name)) return [name.text] + if (name.kind !== SyntaxKind.ObjectBindingPattern && name.kind !== SyntaxKind.ArrayBindingPattern) { + return [] + } + return name.elements.flatMap((element) => (element.name ? bindingNames(element.name) : [])) +} + +function declareBarrier(scope, name) { + for (const identifier of bindingNames(name)) { + declareBinding(scope, identifier, { kind: 'barrier' }) + } +} + +function nearestVarScope(scope) { + let current = scope + while (current.parent && !current.isVarScope) current = current.parent + return current +} + +function declareVariableList(declarationList, scope) { + const isConst = Boolean(declarationList.flags & NodeFlags.Const) + const isBlockScoped = Boolean(declarationList.flags & NodeFlags.BlockScoped) + const declarationScope = isBlockScoped ? scope : nearestVarScope(scope) + // Rolldown keeps loop-header identifier imports dynamic even when the + // header declares a const literal, so those bindings remain barriers. + const isResolvableConst = isConst && declarationScope.kind !== 'loop' + + for (const declaration of declarationList.declarations) { + if (isResolvableConst && isIdentifier(declaration.name) && declaration.initializer) { + declareBinding(declarationScope, declaration.name.text, { + kind: 'constant', + initializer: declaration.initializer, + scope: declarationScope, + }) + continue + } + declareBarrier(declarationScope, declaration.name) + } +} + +function declareImportBindings(node, scope) { + if (isImportEqualsDeclaration(node)) { + declareBarrier(scope, node.name) + return + } + if (!isImportDeclaration(node) || !node.importClause) return + + const { name, namedBindings } = node.importClause + if (name) declareBarrier(scope, name) + if (!namedBindings) return + if (namedBindings.name) { + declareBarrier(scope, namedBindings.name) + return + } + for (const element of namedBindings.elements) declareBarrier(scope, element.name) +} + +function createFunctionLexicalScope(node, currentScope) { + if (node.kind === SyntaxKind.FunctionDeclaration && node.name) { + declareBarrier(currentScope, node.name) + } + + const functionScope = createScope(currentScope, 'function', true, true) + if (NAMED_FUNCTION_SCOPE_KINDS.has(node.kind) && node.name) { + declareBarrier(functionScope, node.name) + } + for (const parameter of node.parameters ?? []) declareBarrier(functionScope, parameter.name) + return functionScope +} + +function createClassLexicalScope(node, currentScope) { + if (node.kind === SyntaxKind.ClassDeclaration && node.name) { + declareBarrier(currentScope, node.name) + } + + const classScope = createScope(currentScope, 'class', false, true) + if (node.name) declareBarrier(classScope, node.name) + return classScope +} + +function createChildLexicalScope(node, currentScope) { + if (FUNCTION_SCOPE_KINDS.has(node.kind)) { + return createFunctionLexicalScope(node, currentScope) + } + if (CLASS_SCOPE_KINDS.has(node.kind)) { + return createClassLexicalScope(node, currentScope) + } + if (node.kind === SyntaxKind.CatchClause) { + const catchScope = createScope(currentScope, 'catch') + if (node.variableDeclaration) declareBarrier(catchScope, node.variableDeclaration.name) + return catchScope + } + if (LOOP_SCOPE_KINDS.has(node.kind)) return createScope(currentScope, 'loop') + if (node.kind === SyntaxKind.SwitchStatement) return createScope(currentScope, 'block') + if (!BLOCK_SCOPE_KINDS.has(node.kind)) return undefined + + return createScope( + currentScope, + 'block', + BLOCK_VAR_SCOPE_KINDS.has(node.kind), + node.kind === SyntaxKind.ModuleBlock, + ) +} + +function predeclareNodeBindings(node, currentScope) { + if (node.kind === SyntaxKind.VariableDeclarationList) { + declareVariableList(node, currentScope) + return + } + if (isImportDeclaration(node) || isImportEqualsDeclaration(node)) { + declareImportBindings(node, currentScope) + return + } + if (BARRIER_DECLARATION_KINDS.has(node.kind) && node.name) { + declareBarrier(currentScope, node.name) + } +} + +function buildLexicalScopes(sourceFile) { + const sourceScope = createScope(undefined, 'source', true, true) + const nodeScopes = new WeakMap() + + function visit(node, currentScope) { + nodeScopes.set(node, currentScope) + const childScope = createChildLexicalScope(node, currentScope) + if (!childScope) predeclareNodeBindings(node, currentScope) + node.forEachChild((child) => visit(child, childScope ?? currentScope)) + } + + visit(sourceFile, sourceScope) + return { nodeScopes, sourceScope } +} + +function findBinding(scope, name) { + let current = scope + while (current) { + const binding = current.bindings.get(name) + if (binding) return binding + // Rolldown folds through lexical blocks, but not through captured + // function, class, or namespace environments. + if (current.isConstantBoundary) return undefined + current = current.parent + } + return undefined +} + +function evaluateTemplateString(expression, scope, resolving, referenceScope) { let value = expression.head.text for (const span of expression.templateSpans) { - const interpolation = evaluateConstantString(span.expression, constantBindings, resolving) + const interpolation = evaluateConstantString( + span.expression, + scope, + resolving, + referenceScope, + ) if (interpolation === undefined) return undefined value += interpolation + span.literal.text } return value } -function evaluateConcatenatedString(expression, constantBindings, resolving) { +function evaluateConcatenatedString(expression, scope, resolving, referenceScope) { if (expression.operatorToken.kind !== SyntaxKind.PlusToken) return undefined - const left = evaluateConstantString(expression.left, constantBindings, resolving) - const right = evaluateConstantString(expression.right, constantBindings, resolving) + const left = evaluateConstantString(expression.left, scope, resolving, referenceScope) + const right = evaluateConstantString(expression.right, scope, resolving, referenceScope) return left === undefined || right === undefined ? undefined : left + right } -function evaluateConstantBinding(expression, constantBindings, resolving) { - if (!constantBindings.has(expression.text) || resolving.has(expression.text)) return undefined - const nextResolving = new Set(resolving).add(expression.text) - return evaluateConstantString( - constantBindings.get(expression.text), - constantBindings, - nextResolving, - ) +function evaluateConstantBinding(expression, scope, resolving, referenceScope) { + const binding = findBinding(scope, expression.text) + // Initializers use their declaration environment, but Rolldown only folds + // an alias when that referenced binding is still the one visible at use. + if ( + !binding || + binding.kind !== 'constant' || + resolving.has(binding) || + findBinding(referenceScope, expression.text) !== binding + ) { + return undefined + } + const nextResolving = new Set(resolving).add(binding) + return evaluateConstantString(binding.initializer, binding.scope, nextResolving, referenceScope) } -function evaluateConstantString(expression, constantBindings, resolving = new Set()) { +function evaluateConstantString(expression, scope, resolving = new Set(), referenceScope = scope) { if (!expression) return undefined if (isStringLiteral(expression) || isNoSubstitutionTemplateLiteral(expression)) { return expression.text } if (CONSTANT_STRING_WRAPPER_CHECKS.some((check) => check(expression))) { - return evaluateConstantString(expression.expression, constantBindings, resolving) + return evaluateConstantString(expression.expression, scope, resolving, referenceScope) } if (isTemplateExpression(expression)) { - return evaluateTemplateString(expression, constantBindings, resolving) + return evaluateTemplateString(expression, scope, resolving, referenceScope) } if (isBinaryExpression(expression)) { - return evaluateConcatenatedString(expression, constantBindings, resolving) + return evaluateConcatenatedString(expression, scope, resolving, referenceScope) } if (isIdentifier(expression)) { - return evaluateConstantBinding(expression, constantBindings, resolving) + return evaluateConstantBinding(expression, scope, resolving, referenceScope) } return undefined } -function isReactHotkeysSource(source, constantBindings) { - return evaluateConstantString(source, constantBindings) === REACT_HOTKEYS_HOOK_MODULE +function isReactHotkeysSource(source, scope) { + return evaluateConstantString(source, scope) === REACT_HOTKEYS_HOOK_MODULE } -function isStaticReactHotkeysImport(node, constantBindings) { +function isStaticReactHotkeysImport(node, scope) { return ( (isImportDeclaration(node) || isExportDeclaration(node)) && - isReactHotkeysSource(node.moduleSpecifier, constantBindings) + isReactHotkeysSource(node.moduleSpecifier, scope) ) } -function isTypeScriptReactHotkeysImport(node, constantBindings) { +function isTypeScriptReactHotkeysImport(node, scope) { if (!isImportEqualsDeclaration(node)) return false const reference = node.moduleReference - return ( - isExternalModuleReference(reference) && - isReactHotkeysSource(reference.expression, constantBindings) - ) + return isExternalModuleReference(reference) && isReactHotkeysSource(reference.expression, scope) } -function isReactHotkeysCallImport(node, constantBindings) { +function isReactHotkeysCallImport(node, scope) { if (!isCallExpression(node)) return false const { expression, arguments: args } = node const isRequire = isIdentifier(expression) && expression.text === 'require' @@ -108,7 +313,7 @@ function isReactHotkeysCallImport(node, constantBindings) { return ( (isRequire || isDynamicImport) && args.length === 1 && - isReactHotkeysSource(args[0], constantBindings) + isReactHotkeysSource(args[0], scope) ) } @@ -123,21 +328,6 @@ function walkAst(node, onNode) { node.forEachChild((child) => walkAst(child, onNode)) } -function topLevelConstantBindings(sourceFile) { - const bindings = new Map() - for (const statement of sourceFile.statements) { - if (!isVariableStatement(statement)) continue - const declarationList = statement.declarationList - if (!(declarationList.flags & NodeFlags.Const)) continue - for (const declaration of declarationList.declarations) { - if (isIdentifier(declaration.name) && declaration.initializer) { - bindings.set(declaration.name.text, declaration.initializer) - } - } - } - return bindings -} - export function findReactHotkeysHookImportViolations( sources, allowedPath = RUNTIME_HOTKEY_ADAPTER_PATH, @@ -170,7 +360,7 @@ export function findReactHotkeysHookImportViolations( for (const [virtualPath, { path }] of virtualSources) { const sourceFile = project?.program.getSourceFile(virtualPath) if (!sourceFile) throw new Error(`TypeScript could not parse in-memory source: ${path}`) - const constantBindings = topLevelConstantBindings(sourceFile) + const { nodeScopes, sourceScope } = buildLexicalScopes(sourceFile) function record(node) { if (path === allowedPath) return @@ -187,7 +377,8 @@ export function findReactHotkeysHookImportViolations( } walkAst(sourceFile, (node) => { - if (IMPORT_NODE_CHECKS.some((check) => check(node, constantBindings))) record(node) + const scope = nodeScopes.get(node) ?? sourceScope + if (IMPORT_NODE_CHECKS.some((check) => check(node, scope))) record(node) }) } } finally { diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index 7ba875754..d189965c8 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -10,6 +10,133 @@ import { const BOUNDARY_SCRIPT = join(process.cwd(), 'scripts/runtime-hotkey-import-boundary.mjs') +const ROLLDOWN_PARITY_CASES = [ + { + name: 'function-local const', + source: "export function load() { const pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: true, + }, + { + name: 'shadowed parameter', + source: + "const pkg = 'react-hotkeys-hook'; export function load(pkg: string) { return import(pkg) }", + resolves: false, + }, + { + name: 'nested block const', + source: + "export function load() { if (true) { const pkg = 'react-hotkeys-hook'; return import(pkg) } }", + resolves: true, + }, + { + name: 'catch destructuring shadow', + source: + "const pkg = 'react-hotkeys-hook'; export function load() { try { throw { pkg: 'dynamic' } } catch ({ pkg }) { return import(pkg) } }", + resolves: false, + }, + { + name: 'const alias chain', + source: + "export function load() { const prefix = 'react-'; const suffix = 'hotkeys-hook'; const pkg = prefix + suffix; return import(pkg) }", + resolves: true, + }, + { + name: 'nested block alias chain', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; { const alias = pkg; return import(alias) } }", + resolves: true, + }, + { + name: 'shadowed alias initializer reference', + source: + "declare function moduleName(): string; export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { const pkg = moduleName(); return import(alias) } }", + resolves: false, + }, + { + name: 'declaration environment alias', + source: + "declare function moduleName(): string; export function load() { const pkg = moduleName(); const alias = pkg; { const pkg = 'react-hotkeys-hook'; return import(alias) } }", + resolves: false, + }, + { + name: 'outer function boundary const', + source: "const pkg = 'react-hotkeys-hook'; export function load() { return import(pkg) }", + resolves: false, + }, + { + name: 'outer class boundary const', + source: "const pkg = 'react-hotkeys-hook'; export class Loader { static load = import(pkg) }", + resolves: false, + }, + { + name: 'loop-header const', + source: + "export function load() { for (const pkg = 'react-hotkeys-hook'; ;) return import(pkg) }", + resolves: false, + }, + { + name: 'const alias cycle', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; { const pkg = pkg; return import(pkg) } }", + resolves: false, + }, + { + name: 'unknown const initializer', + source: + "declare function moduleName(): string; export function load() { const pkg = 'react-hotkeys-hook'; { const pkg = moduleName(); return import(pkg) } }", + resolves: false, + }, +] as const + +const ROLLDOWN_PARITY_SCRIPT = ` + import { rolldown, VERSION } from 'rolldown' + + let input = '' + for await (const chunk of process.stdin) input += chunk + const sources = JSON.parse(input) + const resolutions = [] + + for (const [index, source] of sources.entries()) { + const entry = \`virtual:runtime-hotkey-boundary-\${index}.ts\` + const bundle = await rolldown({ + input: entry, + external: ['react-hotkeys-hook'], + plugins: [{ + name: 'runtime-hotkey-boundary-memory-fixture', + resolveId(id) { if (id === entry) return id }, + load(id) { if (id === entry) return source }, + }], + }) + + try { + const generated = await bundle.generate({ format: 'es' }) + const chunk = generated.output.find((output) => output.type === 'chunk') + if (!chunk) throw new Error('Rolldown did not generate a JavaScript chunk') + resolutions.push(/import\\(["']react-hotkeys-hook["']\\)/.test(chunk.code)) + } finally { + await bundle.close() + } + } + + process.stdout.write(JSON.stringify({ version: VERSION, resolutions })) +` + +function runRolldownParityFixtures(sources: readonly string[]) { + const result = spawnSync( + process.execPath, + ['--input-type=module', '--eval', ROLLDOWN_PARITY_SCRIPT], + { + cwd: process.cwd(), + encoding: 'utf8', + input: JSON.stringify(sources), + }, + ) + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout || 'Rolldown parity process failed') + } + return JSON.parse(result.stdout) as { version: string; resolutions: boolean[] } +} + describe('runtime hotkey registration coverage', () => { it('checks the full source tree in a standalone Node process', () => { const startedAt = performance.now() @@ -63,6 +190,106 @@ describe('runtime hotkey registration coverage', () => { ) }) + it('detects a function-local lexical const resolved by Rolldown', () => { + const localConst = + "export function load() { const pkg = 'react-hotkeys-hook'; return import(pkg) }" + + expect( + findReactHotkeysHookImportViolations([ + { path: 'src/features/function-local.ts', source: localConst }, + ]), + ).toEqual([expect.objectContaining({ path: 'src/features/function-local.ts' })]) + }) + + it('does not fall through a shadowed lexical parameter Rolldown keeps dynamic', () => { + const shadowedParameter = + "const pkg = 'react-hotkeys-hook'; export function load(pkg: string) { return import(pkg) }" + + expect( + findReactHotkeysHookImportViolations([ + { path: 'src/features/shadowed-parameter.ts', source: shadowedParameter }, + ]), + ).toEqual([]) + }) + + it('matches Rolldown constant folding across lexical scopes and shadow barriers', () => { + const parity = runRolldownParityFixtures(ROLLDOWN_PARITY_CASES.map(({ source }) => source)) + expect(parity.version).toBe('1.1.5') + expect(parity.resolutions).toHaveLength(ROLLDOWN_PARITY_CASES.length) + + for (const [index, fixture] of ROLLDOWN_PARITY_CASES.entries()) { + const checkerResolves = + findReactHotkeysHookImportViolations([ + { path: `src/features/${fixture.name.replaceAll(' ', '-')}.ts`, source: fixture.source }, + ]).length === 1 + const rolldownResolves = parity.resolutions[index] + + expect(rolldownResolves, `${fixture.name}: Rolldown fixture expectation`).toBe( + fixture.resolves, + ) + expect(checkerResolves, `${fixture.name}: checker/Rolldown parity`).toBe(rolldownResolves) + } + }) + + it('predeclares every lexical shadow barrier before resolving identifier imports', () => { + const fixtures: Array<[string, string]> = [ + [ + 'let-after-import', + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); let pkg }", + ], + [ + 'var-after-import', + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); var pkg }", + ], + [ + 'nested-var-after-import', + "const pkg = 'react-hotkeys-hook'; export function load() { { import(pkg) } if (true) { var pkg } }", + ], + [ + 'destructuring-after-import', + "const pkg = 'react-hotkeys-hook'; export function load(value: { pkg: string }) { import(pkg); const { pkg } = value }", + ], + ['import-binding', "import pkg from './runtime-name'; export const load = () => import(pkg)"], + [ + 'import-equals-binding', + "const pkg = 'react-hotkeys-hook'; declare namespace Runtime { const pkg: string } namespace Loader { import pkg = Runtime.pkg; export const load = () => import(pkg) }", + ], + [ + 'class-after-import', + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); class pkg {} }", + ], + [ + 'function-after-import', + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); function pkg() {} }", + ], + [ + 'const-without-initializer', + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); const pkg: string }", + ], + [ + 'loop-destructuring', + "const pkg = 'react-hotkeys-hook'; export function load(values: Array<{ pkg: string }>) { for (const { pkg } of values) import(pkg) }", + ], + ] + + expect( + findReactHotkeysHookImportViolations( + fixtures.map(([name, source]) => ({ path: `src/features/${name}.ts`, source })), + ), + ).toEqual([]) + }) + + it("evaluates a const initializer in its declaration's lexical environment", () => { + const source = + "declare function moduleName(): string; export function load() { const pkg = moduleName(); const alias = pkg; { const pkg = 'react-hotkeys-hook'; return import(alias) } }" + + expect( + findReactHotkeysHookImportViolations([ + { path: 'src/features/declaration-environment.ts', source }, + ]), + ).toEqual([]) + }) + it('does not trap text or non-constant module expressions', () => { const source = ` // import('react-hotkeys-hook') From cdfb4e3dec32eaee98a99aa1e3bcdd89a2fdb3d6 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 07:43:42 -0700 Subject: [PATCH 52/64] fix(qa): match Rolldown lexical import folding (cherry picked from commit 3a698020974e8d38ee047aed92e669c57fca867d) --- scripts/runtime-hotkey-import-boundary.mjs | 174 ++++++++++++++---- ...ntime-hotkey-registration-coverage.test.ts | 154 +++++++++++++++- 2 files changed, 296 insertions(+), 32 deletions(-) diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index d7db6f0b3..66d424464 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -53,6 +53,8 @@ const LOOP_SCOPE_KINDS = new Set([ SyntaxKind.ForStatement, SyntaxKind.ForInStatement, SyntaxKind.ForOfStatement, + SyntaxKind.WhileStatement, + SyntaxKind.DoStatement, ]) const BLOCK_SCOPE_KINDS = new Set([ @@ -117,6 +119,7 @@ function declareVariableList(declarationList, scope) { kind: 'constant', initializer: declaration.initializer, scope: declarationScope, + availableAfter: declaration.end, }) continue } @@ -172,11 +175,10 @@ function createChildLexicalScope(node, currentScope) { return createClassLexicalScope(node, currentScope) } if (node.kind === SyntaxKind.CatchClause) { - const catchScope = createScope(currentScope, 'catch') + const catchScope = createScope(currentScope, 'catch', false, true) if (node.variableDeclaration) declareBarrier(catchScope, node.variableDeclaration.name) return catchScope } - if (LOOP_SCOPE_KINDS.has(node.kind)) return createScope(currentScope, 'loop') if (node.kind === SyntaxKind.SwitchStatement) return createScope(currentScope, 'block') if (!BLOCK_SCOPE_KINDS.has(node.kind)) return undefined @@ -206,8 +208,55 @@ function buildLexicalScopes(sourceFile) { const sourceScope = createScope(undefined, 'source', true, true) const nodeScopes = new WeakMap() + function visitLoopHeader(node, currentScope) { + if (!node) return + nodeScopes.set(node, currentScope) + node.forEachChild((child) => visit(child, currentScope)) + } + + function visitLoop(node, currentScope) { + const loopScope = createScope(currentScope, 'loop', false, true) + + if (node.kind === SyntaxKind.ForStatement) { + // Rolldown folds outer constants in a classic-for initializer, then + // stops carrying them through the condition, update, and body. + if (node.initializer?.kind === SyntaxKind.VariableDeclarationList) { + declareVariableList(node.initializer, loopScope) + visitLoopHeader(node.initializer, currentScope) + } else { + visit(node.initializer, currentScope) + } + visit(node.condition, loopScope) + visit(node.incrementor, loopScope) + visit(node.statement, loopScope) + return + } + + if (node.kind === SyntaxKind.ForInStatement || node.kind === SyntaxKind.ForOfStatement) { + if (node.initializer.kind === SyntaxKind.VariableDeclarationList) { + declareVariableList(node.initializer, loopScope) + visitLoopHeader(node.initializer, currentScope) + } else { + visit(node.initializer, currentScope) + } + // The collection expression behaves like an initializer; the repeated + // body is the constant-resolution boundary. + visit(node.expression, currentScope) + visit(node.statement, loopScope) + return + } + + visit(node.expression, loopScope) + visit(node.statement, loopScope) + } + function visit(node, currentScope) { + if (!node) return nodeScopes.set(node, currentScope) + if (LOOP_SCOPE_KINDS.has(node.kind)) { + visitLoop(node, currentScope) + return + } const childScope = createChildLexicalScope(node, currentScope) if (!childScope) predeclareNodeBindings(node, currentScope) node.forEachChild((child) => visit(child, childScope ?? currentScope)) @@ -222,74 +271,137 @@ function findBinding(scope, name) { while (current) { const binding = current.bindings.get(name) if (binding) return binding - // Rolldown folds through lexical blocks, but not through captured - // function, class, or namespace environments. + // Rolldown folds through ordinary lexical blocks, but not through + // captured, catch, repeated-loop, or namespace environments. if (current.isConstantBoundary) return undefined current = current.parent } return undefined } -function evaluateTemplateString(expression, scope, resolving, referenceScope) { +function isBindingAvailable(binding, referencePosition) { + return binding.availableAfter === undefined || referencePosition >= binding.availableAfter +} + +function evaluateTemplateString(expression, scope, resolving) { let value = expression.head.text + const dependencies = [] for (const span of expression.templateSpans) { - const interpolation = evaluateConstantString( - span.expression, - scope, - resolving, - referenceScope, - ) - if (interpolation === undefined) return undefined - value += interpolation + span.literal.text + const interpolation = evaluateConstantString(span.expression, scope, resolving) + if (!interpolation) return undefined + value += interpolation.value + span.literal.text + dependencies.push(...interpolation.dependencies) } - return value + return { value, dependencies } } -function evaluateConcatenatedString(expression, scope, resolving, referenceScope) { +function evaluateConcatenatedString(expression, scope, resolving) { if (expression.operatorToken.kind !== SyntaxKind.PlusToken) return undefined - const left = evaluateConstantString(expression.left, scope, resolving, referenceScope) - const right = evaluateConstantString(expression.right, scope, resolving, referenceScope) - return left === undefined || right === undefined ? undefined : left + right + const left = evaluateConstantString(expression.left, scope, resolving) + const right = evaluateConstantString(expression.right, scope, resolving) + if (!left || !right) return undefined + return { + value: left.value + right.value, + dependencies: [...left.dependencies, ...right.dependencies], + } +} + +function evaluateConstantBindingValue(binding, resolving) { + // Bindings are stable identities, so aliases keep the declaration-time + // environment even when the same name is shadowed at a later use site. + if (binding.cachedValue !== undefined) return binding.cachedValue + if (resolving.has(binding)) return undefined + + const nextResolving = new Set(resolving).add(binding) + const result = evaluateConstantString(binding.initializer, binding.scope, nextResolving) + binding.cachedValue = result ?? null + return result } -function evaluateConstantBinding(expression, scope, resolving, referenceScope) { +function dependencyMatchesUseSite(dependency, referenceScope, referencePosition, resolving) { + const visibleBinding = findBinding(referenceScope, dependency.name) + if ( + !visibleBinding || + visibleBinding === dependency.binding || + !isBindingAvailable(visibleBinding, referencePosition) + ) { + return true + } + if (visibleBinding.kind !== 'constant') return false + + // A same-value shadow is eliminated by Rolldown and does not prevent the + // captured alias from becoming a literal import. An unknown shadow does. + const visibleValue = evaluateConstantBindingValue(visibleBinding, resolving) + return visibleValue?.value === dependency.value +} + +function evaluateConstantBinding(expression, scope, resolving, referenceScope, referencePosition) { const binding = findBinding(scope, expression.text) - // Initializers use their declaration environment, but Rolldown only folds - // an alias when that referenced binding is still the one visible at use. if ( !binding || binding.kind !== 'constant' || resolving.has(binding) || - findBinding(referenceScope, expression.text) !== binding + !isBindingAvailable(binding, expression.getStart()) ) { return undefined } - const nextResolving = new Set(resolving).add(binding) - return evaluateConstantString(binding.initializer, binding.scope, nextResolving, referenceScope) + + const value = evaluateConstantBindingValue(binding, resolving) + if (!value) return undefined + const dependencies = [ + { name: expression.text, binding, value: value.value }, + ...value.dependencies, + ] + if ( + !dependencies.every((dependency) => + dependencyMatchesUseSite(dependency, referenceScope, referencePosition, resolving), + ) + ) { + return undefined + } + return { value: value.value, dependencies } } -function evaluateConstantString(expression, scope, resolving = new Set(), referenceScope = scope) { +function evaluateConstantString( + expression, + scope, + resolving = new Set(), + referenceScope = scope, + referencePosition = expression?.getStart() ?? 0, +) { if (!expression) return undefined if (isStringLiteral(expression) || isNoSubstitutionTemplateLiteral(expression)) { - return expression.text + return { value: expression.text, dependencies: [] } } if (CONSTANT_STRING_WRAPPER_CHECKS.some((check) => check(expression))) { - return evaluateConstantString(expression.expression, scope, resolving, referenceScope) + return evaluateConstantString( + expression.expression, + scope, + resolving, + referenceScope, + referencePosition, + ) } if (isTemplateExpression(expression)) { - return evaluateTemplateString(expression, scope, resolving, referenceScope) + return evaluateTemplateString(expression, scope, resolving) } if (isBinaryExpression(expression)) { - return evaluateConcatenatedString(expression, scope, resolving, referenceScope) + return evaluateConcatenatedString(expression, scope, resolving) } if (isIdentifier(expression)) { - return evaluateConstantBinding(expression, scope, resolving, referenceScope) + return evaluateConstantBinding( + expression, + scope, + resolving, + referenceScope, + referencePosition, + ) } return undefined } function isReactHotkeysSource(source, scope) { - return evaluateConstantString(source, scope) === REACT_HOTKEYS_HOOK_MODULE + return evaluateConstantString(source, scope)?.value === REACT_HOTKEYS_HOOK_MODULE } function isStaticReactHotkeysImport(node, scope) { diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index d189965c8..657dbdb76 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -46,6 +46,12 @@ const ROLLDOWN_PARITY_CASES = [ "export function load() { const pkg = 'react-hotkeys-hook'; { const alias = pkg; return import(alias) } }", resolves: true, }, + { + name: 'same-value nested shadow alias', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { const pkg = 'react-hotkeys-hook'; return import(alias) } }", + resolves: true, + }, { name: 'shadowed alias initializer reference', source: @@ -74,6 +80,148 @@ const ROLLDOWN_PARITY_CASES = [ "export function load() { for (const pkg = 'react-hotkeys-hook'; ;) return import(pkg) }", resolves: false, }, + { + name: 'enclosing catch const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; try { throw 1 } catch { return import(pkg) } }", + resolves: false, + }, + { + name: 'catch-local const', + source: + "export function load() { try { throw 1 } catch { const pkg = 'react-hotkeys-hook'; return import(pkg) } }", + resolves: true, + }, + { + name: 'catch-local alias chain', + source: + "export function load() { try { throw 1 } catch { const pkg = 'react-hotkeys-hook'; const alias = pkg; return import(alias) } }", + resolves: true, + }, + { + name: 'catch-local alias cycle', + source: + 'export function load() { try { throw 1 } catch { const pkg = pkg; return import(pkg) } }', + resolves: false, + }, + { + name: 'classic for initializer outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (import(pkg); ;) break }", + resolves: true, + }, + { + name: 'classic for condition outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (; import(pkg); ) break }", + resolves: false, + }, + { + name: 'classic for update outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (; ; import(pkg)) break }", + resolves: false, + }, + { + name: 'classic for body outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (;;) { import(pkg); break } }", + resolves: false, + }, + { + name: 'classic for body local const', + source: + "export function load() { for (;;) { const pkg = 'react-hotkeys-hook'; import(pkg); break } }", + resolves: true, + }, + { + name: 'classic for body local alias cycle', + source: 'export function load() { for (;;) { const pkg = pkg; import(pkg); break } }', + resolves: false, + }, + { + name: 'for-in expression outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const key in import(pkg)) void key }", + resolves: true, + }, + { + name: 'for-in body outer const', + source: + "export function load(values: object) { const pkg = 'react-hotkeys-hook'; for (const key in values) import(pkg) }", + resolves: false, + }, + { + name: 'for-in body local const', + source: + "export function load(values: object) { for (const key in values) { const pkg = 'react-hotkeys-hook'; import(pkg) } }", + resolves: true, + }, + { + name: 'for-of expression outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const value of import(pkg)) void value }", + resolves: true, + }, + { + name: 'for-of body outer const', + source: + "export function load(values: unknown[]) { const pkg = 'react-hotkeys-hook'; for (const value of values) import(pkg) }", + resolves: false, + }, + { + name: 'for-of body local const', + source: + "export function load(values: unknown[]) { for (const value of values) { const pkg = 'react-hotkeys-hook'; import(pkg) } }", + resolves: true, + }, + { + name: 'while condition outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; while (import(pkg)) break }", + resolves: false, + }, + { + name: 'while body outer const', + source: + "export function load(active: boolean) { const pkg = 'react-hotkeys-hook'; while (active) { import(pkg); break } }", + resolves: false, + }, + { + name: 'while body local const', + source: + "export function load(active: boolean) { while (active) { const pkg = 'react-hotkeys-hook'; import(pkg); break } }", + resolves: true, + }, + { + name: 'do-while condition outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; do {} while (import(pkg)) }", + resolves: false, + }, + { + name: 'do-while body outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; do { import(pkg) } while (false) }", + resolves: false, + }, + { + name: 'do-while body local const', + source: + "export function load() { do { const pkg = 'react-hotkeys-hook'; import(pkg) } while (false) }", + resolves: true, + }, + { + name: 'direct const temporal dead zone', + source: "export function load() { import(pkg); const pkg = 'react-hotkeys-hook' }", + resolves: false, + }, + { + name: 'alias initializer temporal dead zone', + source: + "export function load() { const alias = pkg; const pkg = 'react-hotkeys-hook'; import(alias) }", + resolves: false, + }, { name: 'const alias cycle', source: @@ -217,6 +365,7 @@ describe('runtime hotkey registration coverage', () => { expect(parity.version).toBe('1.1.5') expect(parity.resolutions).toHaveLength(ROLLDOWN_PARITY_CASES.length) + const mismatches: string[] = [] for (const [index, fixture] of ROLLDOWN_PARITY_CASES.entries()) { const checkerResolves = findReactHotkeysHookImportViolations([ @@ -227,8 +376,11 @@ describe('runtime hotkey registration coverage', () => { expect(rolldownResolves, `${fixture.name}: Rolldown fixture expectation`).toBe( fixture.resolves, ) - expect(checkerResolves, `${fixture.name}: checker/Rolldown parity`).toBe(rolldownResolves) + if (checkerResolves !== rolldownResolves) { + mismatches.push(`${fixture.name}: checker=${checkerResolves}, Rolldown=${rolldownResolves}`) + } } + expect(mismatches).toEqual([]) }) it('predeclares every lexical shadow barrier before resolving identifier imports', () => { From 4ade5aedf2511bd8a71660565e080d65580f7bf6 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 08:31:53 -0700 Subject: [PATCH 53/64] fix(shortcuts): repair runtime hotkey boundary parity (cherry picked from commit 62e6c1d6ecc526209520041fc78430e798e5fb8c) --- scripts/runtime-hotkey-import-boundary.mjs | 787 +++++++++++++++--- ...ntime-hotkey-registration-coverage.test.ts | 382 ++++++++- 2 files changed, 1071 insertions(+), 98 deletions(-) diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index 66d424464..85c92b679 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -6,31 +6,36 @@ import { createVirtualFileSystem } from 'typescript/unstable/fs' import { SyntaxKind, NodeFlags, - isAsExpression, isBinaryExpression, isCallExpression, + isElementAccessExpression, + isEnumDeclaration, isExportDeclaration, isExternalModuleReference, isIdentifier, isImportDeclaration, isImportEqualsDeclaration, isNoSubstitutionTemplateLiteral, - isParenthesizedExpression, - isSatisfiesExpression, + isNumericLiteral, + isPostfixUnaryExpression, + isPrefixUnaryExpression, + isPropertyAccessExpression, isStringLiteral, - isTemplateExpression, - isTypeAssertion, } from 'typescript/unstable/ast' const REACT_HOTKEYS_HOOK_MODULE = 'react-hotkeys-hook' export const RUNTIME_HOTKEY_ADAPTER_PATH = 'src/hooks/use-hotkey-registration.ts' -const CONSTANT_STRING_WRAPPER_CHECKS = [ - isParenthesizedExpression, - isAsExpression, - isSatisfiesExpression, - isTypeAssertion, -] +const MAX_CONSTANT_EVALUATION_DEPTH = 100 + +const BINARY_VALUE_RESOLVERS = new Map([ + [SyntaxKind.PlusToken, (left, right) => left + right], + [SyntaxKind.AmpersandAmpersandToken, (left, right) => (left ? right : left)], + [SyntaxKind.BarBarToken, (left, right) => (left ? left : right)], + [SyntaxKind.QuestionQuestionToken, (left, right) => (left === null ? right : left)], +]) + +const UPDATE_OPERATORS = new Set([SyntaxKind.PlusPlusToken, SyntaxKind.MinusMinusToken]) const FUNCTION_SCOPE_KINDS = new Set([ SyntaxKind.FunctionDeclaration, @@ -74,17 +79,27 @@ const BARRIER_DECLARATION_KINDS = new Set([ ]) function createScope(parent, kind, isVarScope = false, isConstantBoundary = false) { - return { parent, kind, isVarScope, isConstantBoundary, bindings: new Map() } + const region = !parent || isConstantBoundary ? { bindings: new Map() } : parent.region + return { parent, kind, isVarScope, isConstantBoundary, region, bindings: new Map() } } function declareBinding(scope, name, binding) { + const regionBindings = scope.region.bindings.get(name) ?? [] + regionBindings.push(binding) + scope.region.bindings.set(name, regionBindings) if (scope.bindings.has(name)) { - scope.bindings.set(name, { kind: 'barrier' }) + const duplicate = { kind: 'barrier' } + regionBindings.push(duplicate) + scope.bindings.set(name, duplicate) return } scope.bindings.set(name, binding) } +function hasModifier(node, kind) { + return node.modifiers?.some((modifier) => modifier.kind === kind) ?? false +} + function bindingNames(name) { if (isIdentifier(name)) return [name.text] if (name.kind !== SyntaxKind.ObjectBindingPattern && name.kind !== SyntaxKind.ArrayBindingPattern) { @@ -93,9 +108,9 @@ function bindingNames(name) { return name.elements.flatMap((element) => (element.name ? bindingNames(element.name) : [])) } -function declareBarrier(scope, name) { +function declareBarrier(scope, name, binding = { kind: 'barrier' }) { for (const identifier of bindingNames(name)) { - declareBinding(scope, identifier, { kind: 'barrier' }) + declareBinding(scope, identifier, binding) } } @@ -105,25 +120,41 @@ function nearestVarScope(scope) { return current } -function declareVariableList(declarationList, scope) { +function variableBinding(declaration, declarationScope, isConst, isResolvableConst) { + if (isResolvableConst && isIdentifier(declaration.name) && declaration.initializer) { + return { + kind: 'constant', + initializer: declaration.initializer, + scope: declarationScope, + availableAfter: declaration.end, + } + } + if (isIdentifier(declaration.name) && !isConst) { + return { + kind: 'mutable', + initializer: declaration.initializer, + scope: declarationScope, + mutationPositions: [], + availableAfter: declaration.end, + } + } + return { kind: 'unknown-shadow', availableAfter: declaration.end } +} + +function declareVariableList(declarationList, scope, { resolveLoopConstants = false } = {}) { const isConst = Boolean(declarationList.flags & NodeFlags.Const) const isBlockScoped = Boolean(declarationList.flags & NodeFlags.BlockScoped) const declarationScope = isBlockScoped ? scope : nearestVarScope(scope) - // Rolldown keeps loop-header identifier imports dynamic even when the - // header declares a const literal, so those bindings remain barriers. - const isResolvableConst = isConst && declarationScope.kind !== 'loop' + const isResolvableConst = + isConst && (declarationScope.kind !== 'loop' || resolveLoopConstants) for (const declaration of declarationList.declarations) { - if (isResolvableConst && isIdentifier(declaration.name) && declaration.initializer) { - declareBinding(declarationScope, declaration.name.text, { - kind: 'constant', - initializer: declaration.initializer, - scope: declarationScope, - availableAfter: declaration.end, - }) - continue + const binding = variableBinding(declaration, declarationScope, isConst, isResolvableConst) + if (isIdentifier(declaration.name)) { + declareBinding(declarationScope, declaration.name.text, binding) + } else { + declareBarrier(declarationScope, declaration.name, binding) } - declareBarrier(declarationScope, declaration.name) } } @@ -146,7 +177,7 @@ function declareImportBindings(node, scope) { function createFunctionLexicalScope(node, currentScope) { if (node.kind === SyntaxKind.FunctionDeclaration && node.name) { - declareBarrier(currentScope, node.name) + declareBarrier(currentScope, node.name, { kind: 'static-shadow' }) } const functionScope = createScope(currentScope, 'function', true, true) @@ -159,7 +190,7 @@ function createFunctionLexicalScope(node, currentScope) { function createClassLexicalScope(node, currentScope) { if (node.kind === SyntaxKind.ClassDeclaration && node.name) { - declareBarrier(currentScope, node.name) + declareBarrier(currentScope, node.name, { kind: 'static-shadow' }) } const classScope = createScope(currentScope, 'class', false, true) @@ -190,15 +221,36 @@ function createChildLexicalScope(node, currentScope) { ) } +function constEnumMemberDescriptor(member, index) { + const supportedName = + isIdentifier(member.name) || isStringLiteral(member.name) || isNumericLiteral(member.name) + return supportedName ? { member, index, name: member.name.text } : undefined +} + +function declareConstEnum(node, currentScope) { + const memberList = node.members.map(constEnumMemberDescriptor) + const members = new Map( + memberList.filter(Boolean).map((descriptor) => [descriptor.name, descriptor]), + ) + declareBinding(currentScope, node.name.text, { + kind: 'const-enum', + members, + memberList, + scope: currentScope, + cachedValues: new Map(), + }) +} + +function isConstEnumDeclaration(node) { + return isEnumDeclaration(node) && hasModifier(node, SyntaxKind.ConstKeyword) +} + function predeclareNodeBindings(node, currentScope) { - if (node.kind === SyntaxKind.VariableDeclarationList) { - declareVariableList(node, currentScope) - return - } + if (node.kind === SyntaxKind.VariableDeclarationList) return declareVariableList(node, currentScope) if (isImportDeclaration(node) || isImportEqualsDeclaration(node)) { - declareImportBindings(node, currentScope) - return + return declareImportBindings(node, currentScope) } + if (isConstEnumDeclaration(node)) return declareConstEnum(node, currentScope) if (BARRIER_DECLARATION_KINDS.has(node.kind) && node.name) { declareBarrier(currentScope, node.name) } @@ -221,8 +273,14 @@ function buildLexicalScopes(sourceFile) { // Rolldown folds outer constants in a classic-for initializer, then // stops carrying them through the condition, update, and body. if (node.initializer?.kind === SyntaxKind.VariableDeclarationList) { - declareVariableList(node.initializer, loopScope) - visitLoopHeader(node.initializer, currentScope) + const initializerScope = createScope(currentScope, 'loop-initializer') + declareVariableList(node.initializer, initializerScope, { + resolveLoopConstants: true, + }) + for (const declaration of node.initializer.declarations) { + declareBarrier(loopScope, declaration.name) + } + visitLoopHeader(node.initializer, initializerScope) } else { visit(node.initializer, currentScope) } @@ -234,14 +292,22 @@ function buildLexicalScopes(sourceFile) { if (node.kind === SyntaxKind.ForInStatement || node.kind === SyntaxKind.ForOfStatement) { if (node.initializer.kind === SyntaxKind.VariableDeclarationList) { - declareVariableList(node.initializer, loopScope) - visitLoopHeader(node.initializer, currentScope) + const initializerScope = createScope(currentScope, 'loop-initializer') + declareVariableList(node.initializer, initializerScope, { + resolveLoopConstants: true, + }) + for (const declaration of node.initializer.declarations) { + declareBarrier(loopScope, declaration.name) + } + visitLoopHeader(node.initializer, initializerScope) + // A lexical for-in/of binding is in its temporal dead zone while the + // collection expression is evaluated. Different names still see the + // surrounding declaration environment. + visit(node.expression, initializerScope) } else { visit(node.initializer, currentScope) + visit(node.expression, currentScope) } - // The collection expression behaves like an initializer; the repeated - // body is the constant-resolution boundary. - visit(node.expression, currentScope) visit(node.statement, loopScope) return } @@ -263,6 +329,7 @@ function buildLexicalScopes(sourceFile) { } visit(sourceFile, sourceScope) + markMutableBindingWrites(sourceFile, nodeScopes, sourceScope) return { nodeScopes, sourceScope } } @@ -279,46 +346,204 @@ function findBinding(scope, name) { return undefined } +function findLexicalBinding(scope, name) { + let current = scope + while (current) { + const binding = current.bindings.get(name) + if (binding) return binding + current = current.parent + } + return undefined +} + +function assignmentTargetIdentifier(node) { + const assignment = + isBinaryExpression(node) && + node.operatorToken.kind >= SyntaxKind.FirstAssignment && + node.operatorToken.kind <= SyntaxKind.LastAssignment + if (assignment && isIdentifier(node.left)) return node.left + const update = + isPrefixUnaryExpression(node) || isPostfixUnaryExpression(node) + ? UPDATE_OPERATORS.has(node.operator) + : false + return update && isIdentifier(node.operand) ? node.operand : undefined +} + +function markMutableBindingWrites(sourceFile, nodeScopes, sourceScope) { + walkAst(sourceFile, (node) => { + const identifier = assignmentTargetIdentifier(node) + if (!identifier) return + const scope = nodeScopes.get(identifier) ?? nodeScopes.get(node) ?? sourceScope + const binding = findLexicalBinding(scope, identifier.text) + if (binding?.kind === 'mutable') { + binding.mutationPositions.push(node.end) + } + }) +} + function isBindingAvailable(binding, referencePosition) { return binding.availableAfter === undefined || referencePosition >= binding.availableAfter } -function evaluateTemplateString(expression, scope, resolving) { +function evaluateTemplateValue( + expression, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth, +) { let value = expression.head.text const dependencies = [] for (const span of expression.templateSpans) { - const interpolation = evaluateConstantString(span.expression, scope, resolving) + const interpolation = evaluateConstantValue( + span.expression, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth + 1, + ) if (!interpolation) return undefined - value += interpolation.value + span.literal.text + value += String(interpolation.value) + span.literal.text dependencies.push(...interpolation.dependencies) } return { value, dependencies } } -function evaluateConcatenatedString(expression, scope, resolving) { - if (expression.operatorToken.kind !== SyntaxKind.PlusToken) return undefined - const left = evaluateConstantString(expression.left, scope, resolving) - const right = evaluateConstantString(expression.right, scope, resolving) - if (!left || !right) return undefined +function evaluateBinaryValue( + expression, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth, +) { + const left = evaluateConstantValue( + expression.left, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth + 1, + ) + if (!left) return undefined + + const operator = expression.operatorToken.kind + const resolver = BINARY_VALUE_RESOLVERS.get(operator) + if (!resolver) return undefined + const shortCircuitValue = resolver(left.value, undefined) + if (shortCircuitValue !== undefined && operator !== SyntaxKind.PlusToken) return left + + const right = evaluateConstantValue( + expression.right, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth + 1, + ) + if (!right) return undefined return { - value: left.value + right.value, + value: resolver(left.value, right.value), dependencies: [...left.dependencies, ...right.dependencies], } } -function evaluateConstantBindingValue(binding, resolving) { +function evaluateConstantBindingValue(binding, resolving, depth) { // Bindings are stable identities, so aliases keep the declaration-time // environment even when the same name is shadowed at a later use site. if (binding.cachedValue !== undefined) return binding.cachedValue - if (resolving.has(binding)) return undefined + if (resolving.has(binding) || depth > MAX_CONSTANT_EVALUATION_DEPTH) return undefined const nextResolving = new Set(resolving).add(binding) - const result = evaluateConstantString(binding.initializer, binding.scope, nextResolving) + const result = evaluateConstantValue( + binding.initializer, + binding.scope, + nextResolving, + binding.scope, + binding.initializer.getStart(), + undefined, + depth + 1, + ) binding.cachedValue = result ?? null return result } -function dependencyMatchesUseSite(dependency, referenceScope, referencePosition, resolving) { +function mutableShadowBlocks(candidate, referencePosition, resolving, depth) { + if (candidate.mutationPositions.some((position) => position <= referencePosition)) return true + if (!candidate.initializer) return false + return !evaluateConstantValue( + candidate.initializer, + candidate.scope, + resolving, + candidate.scope, + candidate.initializer.getStart(), + undefined, + depth + 1, + ) +} + +function regionCandidateBlocks(candidate, dependency, referencePosition, resolving, depth) { + if (candidate === dependency.binding || !isBindingAvailable(candidate, referencePosition)) { + return false + } + if (candidate.kind === 'constant') { + return !evaluateConstantBindingValue(candidate, resolving, depth + 1) + } + if (candidate.kind === 'mutable') { + return mutableShadowBlocks(candidate, referencePosition, resolving, depth + 1) + } + return candidate.kind === 'barrier' || candidate.kind === 'unknown-shadow' +} + +function regionHasBlockingShadow( + dependency, + referenceScope, + referencePosition, + resolving, + depth, +) { + const candidates = referenceScope.region.bindings.get(dependency.name) ?? [] + return candidates.some((candidate) => + regionCandidateBlocks(candidate, dependency, referencePosition, resolving, depth + 1), + ) +} + +function visibleBindingAllowsCapture(binding, referencePosition, resolving, depth) { + if (binding.kind === 'constant') { + // Rolldown eliminates any proven literal shadow before folding the alias; + // the shadow does not need to have the captured dependency's value. + return Boolean(evaluateConstantBindingValue(binding, resolving, depth + 1)) + } + if (binding.kind === 'const-enum' || binding.kind === 'static-shadow') return true + if (binding.kind !== 'mutable') return false + return !mutableShadowBlocks(binding, referencePosition, resolving, depth + 1) +} + +function dependencyMatchesUseSite( + dependency, + referenceScope, + referencePosition, + resolving, + depth, +) { + if ( + regionHasBlockingShadow( + dependency, + referenceScope, + referencePosition, + resolving, + depth + 1, + ) + ) { + return false + } const visibleBinding = findBinding(referenceScope, dependency.name) if ( !visibleBinding || @@ -327,15 +552,17 @@ function dependencyMatchesUseSite(dependency, referenceScope, referencePosition, ) { return true } - if (visibleBinding.kind !== 'constant') return false - - // A same-value shadow is eliminated by Rolldown and does not prevent the - // captured alias from becoming a literal import. An unknown shadow does. - const visibleValue = evaluateConstantBindingValue(visibleBinding, resolving) - return visibleValue?.value === dependency.value + return visibleBindingAllowsCapture(visibleBinding, referencePosition, resolving, depth + 1) } -function evaluateConstantBinding(expression, scope, resolving, referenceScope, referencePosition) { +function evaluateConstantBinding( + expression, + scope, + resolving, + referenceScope, + referencePosition, + depth, +) { const binding = findBinding(scope, expression.text) if ( !binding || @@ -346,7 +573,7 @@ function evaluateConstantBinding(expression, scope, resolving, referenceScope, r return undefined } - const value = evaluateConstantBindingValue(binding, resolving) + const value = evaluateConstantBindingValue(binding, resolving, depth + 1) if (!value) return undefined const dependencies = [ { name: expression.text, binding, value: value.value }, @@ -354,7 +581,13 @@ function evaluateConstantBinding(expression, scope, resolving, referenceScope, r ] if ( !dependencies.every((dependency) => - dependencyMatchesUseSite(dependency, referenceScope, referencePosition, resolving), + dependencyMatchesUseSite( + dependency, + referenceScope, + referencePosition, + resolving, + depth + 1, + ), ) ) { return undefined @@ -362,57 +595,362 @@ function evaluateConstantBinding(expression, scope, resolving, referenceScope, r return { value: value.value, dependencies } } -function evaluateConstantString( +function constEnumMemberName(expression) { + if (isPropertyAccessExpression(expression) && isIdentifier(expression.expression)) { + return { enumName: expression.expression.text, memberName: expression.name.text } + } + if ( + isElementAccessExpression(expression) && + isIdentifier(expression.expression) && + (isStringLiteral(expression.argumentExpression) || + isNumericLiteral(expression.argumentExpression)) + ) { + return { + enumName: expression.expression.text, + memberName: expression.argumentExpression.text, + } + } + return undefined +} + +function evaluateImplicitConstEnumMember(binding, descriptor, resolving, depth) { + if (descriptor.index === 0) return { value: 0, dependencies: [] } + const previous = binding.memberList[descriptor.index - 1] + if (!previous) return undefined + const previousValue = evaluateConstEnumMember( + binding, + previous.name, + resolving, + depth + 1, + ) + if (typeof previousValue?.value !== 'number') return undefined + return { + value: previousValue.value + 1, + dependencies: previousValue.dependencies, + } +} + +function evaluateExplicitConstEnumMember(binding, descriptor, resolving, depth) { + const initializer = descriptor.member.initializer + return evaluateConstantValue( + initializer, + binding.scope, + resolving, + binding.scope, + initializer.getStart(), + binding, + depth + 1, + ) +} + +function evaluateConstEnumMember(binding, memberName, resolving, depth) { + if (binding.cachedValues.has(memberName)) { + return binding.cachedValues.get(memberName) ?? undefined + } + const descriptor = binding.members.get(memberName) + if ( + !descriptor || + resolving.has(descriptor) || + depth > MAX_CONSTANT_EVALUATION_DEPTH + ) { + return undefined + } + + const nextResolving = new Set(resolving).add(descriptor) + const result = descriptor.member.initializer + ? evaluateExplicitConstEnumMember(binding, descriptor, nextResolving, depth + 1) + : evaluateImplicitConstEnumMember(binding, descriptor, nextResolving, depth + 1) + binding.cachedValues.set(memberName, result ?? null) + return result +} + +function evaluateConstEnumAccess(expression, scope, resolving, depth) { + const access = constEnumMemberName(expression) + if (!access) return undefined + const binding = findBinding(scope, access.enumName) + if (!binding || binding.kind !== 'const-enum') return undefined + const value = evaluateConstEnumMember(binding, access.memberName, resolving, depth + 1) + if (!value) return undefined + return { + value: value.value, + dependencies: [ + { name: access.enumName, binding, value: value.value }, + ...value.dependencies, + ], + } +} + +function evaluateLiteralExpression(expression) { + if (isNumericLiteral(expression)) { + return { value: Number(expression.text), dependencies: [] } + } + return { value: expression.text, dependencies: [] } +} + +function evaluateKeywordExpression(expression) { + const values = new Map([ + [SyntaxKind.TrueKeyword, true], + [SyntaxKind.FalseKeyword, false], + [SyntaxKind.NullKeyword, null], + ]) + return { value: values.get(expression.kind), dependencies: [] } +} + +function evaluateWrappedExpression(expression, context) { + return evaluateConstantValue( + expression.expression, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.enumBinding, + context.depth + 1, + ) +} + +function evaluateTemplateExpressionValue(expression, context) { + return evaluateTemplateValue( + expression, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.enumBinding, + context.depth, + ) +} + +function evaluateConditionalExpressionValue(expression, context) { + const condition = evaluateConstantValue( + expression.condition, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.enumBinding, + context.depth + 1, + ) + if (!condition) return undefined + const branch = condition.value ? expression.whenTrue : expression.whenFalse + const result = evaluateConstantValue( + branch, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.enumBinding, + context.depth + 1, + ) + if (!result) return undefined + return { + value: result.value, + dependencies: [...condition.dependencies, ...result.dependencies], + } +} + +function evaluateBinaryExpressionValue(expression, context) { + return evaluateBinaryValue( + expression, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.enumBinding, + context.depth, + ) +} + +function evaluateIdentifierExpressionValue(expression, context) { + if (context.enumBinding?.members.has(expression.text)) { + return evaluateConstEnumMember( + context.enumBinding, + expression.text, + context.resolving, + context.depth + 1, + ) + } + return evaluateConstantBinding( + expression, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.depth, + ) +} + +function evaluateConstEnumAccessValue(expression, context) { + return evaluateConstEnumAccess( + expression, + context.scope, + context.resolving, + context.depth, + ) +} + +const CONSTANT_VALUE_HANDLERS = new Map([ + [SyntaxKind.StringLiteral, evaluateLiteralExpression], + [SyntaxKind.NoSubstitutionTemplateLiteral, evaluateLiteralExpression], + [SyntaxKind.NumericLiteral, evaluateLiteralExpression], + [SyntaxKind.TrueKeyword, evaluateKeywordExpression], + [SyntaxKind.FalseKeyword, evaluateKeywordExpression], + [SyntaxKind.NullKeyword, evaluateKeywordExpression], + [SyntaxKind.ParenthesizedExpression, evaluateWrappedExpression], + [SyntaxKind.AsExpression, evaluateWrappedExpression], + [SyntaxKind.NonNullExpression, evaluateWrappedExpression], + [SyntaxKind.SatisfiesExpression, evaluateWrappedExpression], + [SyntaxKind.TypeAssertionExpression, evaluateWrappedExpression], + [SyntaxKind.TemplateExpression, evaluateTemplateExpressionValue], + [SyntaxKind.ConditionalExpression, evaluateConditionalExpressionValue], + [SyntaxKind.BinaryExpression, evaluateBinaryExpressionValue], + [SyntaxKind.Identifier, evaluateIdentifierExpressionValue], + [SyntaxKind.PropertyAccessExpression, evaluateConstEnumAccessValue], + [SyntaxKind.ElementAccessExpression, evaluateConstEnumAccessValue], +]) + +function evaluateConstantValue( expression, scope, resolving = new Set(), referenceScope = scope, referencePosition = expression?.getStart() ?? 0, + enumBinding, + depth = 0, ) { - if (!expression) return undefined - if (isStringLiteral(expression) || isNoSubstitutionTemplateLiteral(expression)) { - return { value: expression.text, dependencies: [] } + if (!expression || depth > MAX_CONSTANT_EVALUATION_DEPTH) return undefined + const handler = CONSTANT_VALUE_HANDLERS.get(expression.kind) + if (!handler) return undefined + return handler(expression, { + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth, + }) +} + +function possibleWrappedTarget(expression, context) { + return expressionMayResolveToReactHotkeys( + expression.expression, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.depth + 1, + ) +} + +function possibleConditionalTarget(expression, context) { + return [expression.whenTrue, expression.whenFalse].some((branch) => + expressionMayResolveToReactHotkeys( + branch, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.depth + 1, + ), + ) +} + +function possibleIdentifierTarget(expression, context) { + const { scope, resolving, referenceScope, referencePosition, depth } = context + const binding = findBinding(scope, expression.text) + if ( + !binding || + binding.kind !== 'constant' || + resolving.has(binding) || + !isBindingAvailable(binding, expression.getStart()) + ) { + return false } - if (CONSTANT_STRING_WRAPPER_CHECKS.some((check) => check(expression))) { - return evaluateConstantString( - expression.expression, - scope, - resolving, + const dependency = { name: expression.text, binding } + if ( + !dependencyMatchesUseSite( + dependency, referenceScope, referencePosition, - ) - } - if (isTemplateExpression(expression)) { - return evaluateTemplateString(expression, scope, resolving) - } - if (isBinaryExpression(expression)) { - return evaluateConcatenatedString(expression, scope, resolving) - } - if (isIdentifier(expression)) { - return evaluateConstantBinding( - expression, - scope, resolving, - referenceScope, - referencePosition, + depth + 1, ) + ) { + return false } - return undefined + return expressionMayResolveToReactHotkeys( + binding.initializer, + binding.scope, + new Set(resolving).add(binding), + referenceScope, + referencePosition, + depth + 1, + ) +} + +const POSSIBLE_TARGET_HANDLERS = new Map([ + [SyntaxKind.ParenthesizedExpression, possibleWrappedTarget], + [SyntaxKind.AsExpression, possibleWrappedTarget], + [SyntaxKind.NonNullExpression, possibleWrappedTarget], + [SyntaxKind.SatisfiesExpression, possibleWrappedTarget], + [SyntaxKind.TypeAssertionExpression, possibleWrappedTarget], + [SyntaxKind.ConditionalExpression, possibleConditionalTarget], + [SyntaxKind.Identifier, possibleIdentifierTarget], +]) + +function expressionMayResolveToReactHotkeys( + expression, + scope, + resolving = new Set(), + referenceScope = scope, + referencePosition = expression?.getStart() ?? 0, + depth = 0, +) { + if (!expression || depth > MAX_CONSTANT_EVALUATION_DEPTH) return false + const exact = evaluateConstantValue( + expression, + scope, + resolving, + referenceScope, + referencePosition, + undefined, + depth + 1, + ) + if (exact) return exact.value === REACT_HOTKEYS_HOOK_MODULE + const handler = POSSIBLE_TARGET_HANDLERS.get(expression.kind) + if (!handler) return false + return handler(expression, { scope, resolving, referenceScope, referencePosition, depth }) } function isReactHotkeysSource(source, scope) { - return evaluateConstantString(source, scope)?.value === REACT_HOTKEYS_HOOK_MODULE + return expressionMayResolveToReactHotkeys(source, scope) +} + +function hasOnlyTypeSpecifiers(elements) { + return elements?.length > 0 && elements.every((element) => element.isTypeOnly) +} + +function isRuntimeImportDeclaration(node) { + const clause = node.importClause + if (!clause) return true + if (clause.isTypeOnly) return false + if (clause.name) return true + return !hasOnlyTypeSpecifiers(clause.namedBindings?.elements) +} + +function isRuntimeExportDeclaration(node) { + if (node.isTypeOnly) return false + return !hasOnlyTypeSpecifiers(node.exportClause?.elements) } function isStaticReactHotkeysImport(node, scope) { - return ( - (isImportDeclaration(node) || isExportDeclaration(node)) && - isReactHotkeysSource(node.moduleSpecifier, scope) - ) + const runtimeDeclaration = isImportDeclaration(node) + ? isRuntimeImportDeclaration(node) + : isExportDeclaration(node) && isRuntimeExportDeclaration(node) + return runtimeDeclaration && isReactHotkeysSource(node.moduleSpecifier, scope) } function isTypeScriptReactHotkeysImport(node, scope) { - if (!isImportEqualsDeclaration(node)) return false + if (!isImportEqualsDeclaration(node) || node.isTypeOnly) return false const reference = node.moduleReference return isExternalModuleReference(reference) && isReactHotkeysSource(reference.expression, scope) } @@ -420,7 +958,10 @@ function isTypeScriptReactHotkeysImport(node, scope) { function isReactHotkeysCallImport(node, scope) { if (!isCallExpression(node)) return false const { expression, arguments: args } = node - const isRequire = isIdentifier(expression) && expression.text === 'require' + const isRequire = + isIdentifier(expression) && + expression.text === 'require' && + !findLexicalBinding(scope, expression.text) const isDynamicImport = expression.kind === SyntaxKind.ImportKeyword return ( (isRequire || isDynamicImport) && @@ -468,6 +1009,51 @@ export function findReactHotkeysHookImportViolations( try { snapshot = compiler.updateSnapshot({ openProjects: [`${virtualRoot}/tsconfig.json`] }) const project = snapshot.getProjects()[0] + if (!project) throw new Error('TypeScript could not create the in-memory boundary project') + + const syntaxErrors = project.program + .getSyntacticDiagnostics() + .flatMap((diagnostic) => { + const candidate = virtualSources.get(diagnostic.fileName) + const sourceFile = project.program.getSourceFile(diagnostic.fileName) + if (!candidate || !sourceFile) return [] + const position = Math.min(diagnostic.pos ?? 0, sourceFile.end) + const location = sourceFile.getLineAndCharacterOfPosition(position) + return [ + { + path: candidate.path, + line: location.line + 1, + column: location.character + 1, + code: diagnostic.code, + text: diagnostic.text ?? 'Invalid TypeScript syntax', + }, + ] + }) + .toSorted( + (left, right) => + left.path.localeCompare(right.path) || + left.line - right.line || + left.column - right.column || + left.code - right.code, + ) + .filter( + (diagnostic, index, diagnostics) => + index === 0 || + diagnostic.path !== diagnostics[index - 1].path || + diagnostic.line !== diagnostics[index - 1].line || + diagnostic.column !== diagnostics[index - 1].column || + diagnostic.code !== diagnostics[index - 1].code, + ) + if (syntaxErrors.length > 0) { + throw new SyntaxError( + `Runtime hotkey import boundary could not parse source:\n${syntaxErrors + .map( + ({ path, line, column, code, text }) => + `${path}:${line}:${column} TS${code}: ${text}`, + ) + .join('\n')}`, + ) + } for (const [virtualPath, { path }] of virtualSources) { const sourceFile = project?.program.getSourceFile(virtualPath) @@ -534,4 +1120,11 @@ function runCli() { } const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined -if (invokedPath === fileURLToPath(import.meta.url)) runCli() +if (invokedPath === fileURLToPath(import.meta.url)) { + try { + runCli() + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } +} diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index 657dbdb76..94dfeaebf 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -234,6 +234,364 @@ const ROLLDOWN_PARITY_CASES = [ "declare function moduleName(): string; export function load() { const pkg = 'react-hotkeys-hook'; { const pkg = moduleName(); return import(pkg) } }", resolves: false, }, + { + name: 'captured alias under different literal shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { const pkg = 'other'; return import(alias) } }", + resolves: true, + }, + { + name: 'captured alias under uninitialized let shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { let pkg; return import(alias) } }", + resolves: true, + }, + { + name: 'captured alias under unknown let shadow', + source: + "declare function moduleName(): string; export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { let pkg = moduleName(); return import(alias) } }", + resolves: false, + }, + { + name: 'captured alias under mutated let shadow', + source: + "declare function moduleName(): string; export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { let pkg; pkg = moduleName(); return import(alias) } }", + resolves: false, + }, + { + name: 'captured alias under function shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { function pkg() {} return import(alias) } }", + resolves: true, + }, + { + name: 'captured alias under class shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { class pkg {} return import(alias) } }", + resolves: true, + }, + { + name: 'captured alias with unknown sibling shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { const pkg = moduleName() } { return import(alias) } }", + resolves: false, + }, + { + name: 'captured alias with known sibling shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { const pkg = 'other' } { return import(alias) } }", + resolves: true, + }, + { + name: 'captured alias across closure parameter', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; return function inner(pkg: string) { return import(alias) } }", + resolves: false, + }, + { + name: 'direct let shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; { let pkg; return import(pkg) } }", + resolves: false, + }, + { + name: 'direct var shadow', + source: + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); var pkg: string }", + resolves: false, + }, + { + name: 'direct destructuring shadow', + source: + "export function load(value: { pkg: string }) { const pkg = 'react-hotkeys-hook'; { const { pkg } = value; return import(pkg) } }", + resolves: false, + }, + { + name: 'direct import binding shadow', + source: "import pkg from 'runtime-name'; export function load() { return import(pkg) }", + resolves: false, + }, + { + name: 'direct function shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; { return import(pkg); function pkg() {} } }", + resolves: false, + }, + { + name: 'direct class shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; { return import(pkg); class pkg {} } }", + resolves: false, + }, + { + name: 'finally outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; try {} finally { return import(pkg) } }", + resolves: true, + }, + { + name: 'finally captured alias under let shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; try {} finally { let pkg; return import(alias) } }", + resolves: true, + }, + { + name: 'catch captured alias boundary', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; try { throw 1 } catch { return import(alias) } }", + resolves: false, + }, + { + name: 'classic for sequential declarator', + source: + "export function load() { for (const pkg = 'react-hotkeys-hook', pending = import(pkg); ;) break }", + resolves: true, + }, + { + name: 'classic for later declarator temporal dead zone', + source: + "export function load() { for (const pending = import(pkg), pkg = 'react-hotkeys-hook'; ;) break }", + resolves: false, + }, + { + name: 'classic for current declarator temporal dead zone', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const pkg = import(pkg); ;) break }", + resolves: false, + }, + { + name: 'for-in same-name expression temporal dead zone', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const pkg in import(pkg)) break }", + resolves: false, + }, + { + name: 'for-of same-name expression temporal dead zone', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const pkg of import(pkg)) break }", + resolves: false, + }, + { + name: 'for-in different-name expression', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const key in import(pkg)) break }", + resolves: true, + }, + { + name: 'for-of different-name expression', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const value of import(pkg)) break }", + resolves: true, + }, + { + name: 'conditional true branch', + source: "const pkg = true ? 'react-hotkeys-hook' : 'other'; import(pkg)", + resolves: true, + }, + { + name: 'conditional false branch', + source: "const pkg = false ? 'other' : 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'conditional non-target result', + source: "const pkg = true ? 'other' : 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'conditional unknown condition', + source: + "declare const enabled: boolean; const pkg = enabled ? 'react-hotkeys-hook' : 'other'; import(pkg)", + resolves: true, + }, + { + name: 'conditional wholly dynamic result', + source: + 'declare const enabled: boolean; declare const first: string; declare const second: string; const pkg = enabled ? first : second; import(pkg)', + resolves: false, + }, + { + name: 'logical and truthy boolean', + source: "const pkg = true && 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'logical and truthy number', + source: "const pkg = 1 && 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'logical and falsy boolean', + source: "const pkg = false && 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'logical and falsy number', + source: "const pkg = 0 && 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'logical or falsy boolean', + source: "const pkg = false || 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'logical or falsy number', + source: "const pkg = 0 || 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'logical or truthy boolean', + source: "const pkg = true || 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'logical or truthy string', + source: "const pkg = 'other' || 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'nullish null', + source: "const pkg = null ?? 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'nullish non-null number', + source: "const pkg = 0 ?? 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'unknown logical operand', + source: + "declare const enabled: boolean; const pkg = enabled && 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'unknown concatenated operand', + source: "declare const suffix: string; const pkg = 'react-hotkeys-' + suffix; import(pkg)", + resolves: false, + }, + { + name: 'wrapped logical expression', + source: + "const pkg = (((true && 'react-hotkeys-hook') as string)!) satisfies string; import(pkg)", + resolves: true, + }, + { + name: 'const enum property member', + source: "const enum Modules { Hotkeys = 'react-hotkeys-hook' } import(Modules.Hotkeys)", + resolves: true, + }, + { + name: 'const enum element member', + source: "const enum Modules { Hotkeys = 'react-hotkeys-hook' } import(Modules['Hotkeys'])", + resolves: true, + }, + { + name: 'const enum member alias', + source: + "const enum Modules { Hotkeys = 'react-hotkeys-hook', Alias = Hotkeys } import(Modules.Alias)", + resolves: true, + }, + { + name: 'const enum non-target member', + source: + "const enum Modules { Hotkeys = 'react-hotkeys-hook', Other = 'other' } import(Modules.Other)", + resolves: false, + }, + { + name: 'const enum automatic numeric member', + source: 'const enum Modules { Other } import(Modules.Other)', + resolves: false, + }, + { + name: 'const enum member cycle', + source: 'const enum Modules { First = Second, Second = First } import(Modules.First)', + resolves: false, + }, + { + name: 'global require', + source: "export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'require parameter shadow', + source: + "export function load(require: (id: string) => unknown) { return require('react-hotkeys-hook') }", + resolves: false, + }, + { + name: 'require destructuring parameter shadow', + source: + "export function load({ require }: { require: (id: string) => unknown }) { return require('react-hotkeys-hook') }", + resolves: false, + }, + { + name: 'require local const shadow', + source: + "export function load() { const require = (id: string) => id; return require('react-hotkeys-hook') }", + resolves: false, + }, + { + name: 'require local function shadow', + source: + "export function load() { return require('react-hotkeys-hook'); function require(id: string) { return id } }", + resolves: false, + }, + { + name: 'require import shadow', + source: + "import { require } from 'runtime-name'; export function load() { return require('react-hotkeys-hook') }", + resolves: false, + }, + { + name: 'require catch shadow', + source: + "export function load() { try { throw (() => undefined) } catch (require) { return require('react-hotkeys-hook') } }", + resolves: false, + }, + { + name: 'require sibling unshadowed', + source: + "export function load() { { const require = (id: string) => id; require('react-hotkeys-hook') } return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'type-only import declaration', + source: "import type { HotkeyCallback } from 'react-hotkeys-hook'", + resolves: false, + }, + { + name: 'type-only import specifier', + source: "import { type HotkeyCallback } from 'react-hotkeys-hook'", + resolves: false, + }, + { + name: 'mixed value and type import', + source: + "import { type HotkeyCallback, useHotkeys } from 'react-hotkeys-hook'; console.log(useHotkeys)", + resolves: true, + }, + { + name: 'type-only export declaration', + source: "export type { HotkeyCallback } from 'react-hotkeys-hook'", + resolves: false, + }, + { + name: 'type-only export specifier', + source: "export { type HotkeyCallback } from 'react-hotkeys-hook'", + resolves: false, + }, + { + name: 'mixed value and type export', + source: "export { type HotkeyCallback, useHotkeys } from 'react-hotkeys-hook'", + resolves: true, + }, + { + name: 'type-only import equals', + source: "import type Hotkeys = require('react-hotkeys-hook')", + resolves: false, + }, ] as const const ROLLDOWN_PARITY_SCRIPT = ` @@ -260,7 +618,11 @@ const ROLLDOWN_PARITY_SCRIPT = ` const generated = await bundle.generate({ format: 'es' }) const chunk = generated.output.find((output) => output.type === 'chunk') if (!chunk) throw new Error('Rolldown did not generate a JavaScript chunk') - resolutions.push(/import\\(["']react-hotkeys-hook["']\\)/.test(chunk.code)) + resolutions.push( + /(?:from\\s+|import\\s*\\(|import\\s+|__require\\s*\\()\\s*["']react-hotkeys-hook["']/.test( + chunk.code, + ), + ) } finally { await bundle.close() } @@ -455,6 +817,24 @@ describe('runtime hotkey registration coverage', () => { ).toEqual([]) }) + it('fails transparently and deterministically on malformed source', () => { + const sources = [ + { + path: 'src/features/z-malformed.ts', + source: "const hooks = import('react-hotkeys-hook'", + }, + { path: 'src/features/a-malformed.ts', source: 'export const value = }' }, + ] + + expect(() => findReactHotkeysHookImportViolations(sources)).toThrowError( + new SyntaxError( + 'Runtime hotkey import boundary could not parse source:\n' + + 'src/features/a-malformed.ts:1:22 TS1109: Expression expected.\n' + + "src/features/z-malformed.ts:1:42 TS1005: ')' expected.", + ), + ) + }) + it('reports the exact source location and allowed adapter', () => { const path = 'src/features/multiline-import.ts' const source = "// setup\nconst hooks = await import('react-hotkeys-hook')" From 98a863aec4dbdf2f5809920f282b8e5ea96a823c Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 09:19:08 -0700 Subject: [PATCH 54/64] fix(qa): track mutable and erased runtime bindings (cherry picked from commit 25f751d48718bed41d3cfd9bb47345d367904150) --- scripts/runtime-hotkey-import-boundary.mjs | 415 +++++++++++++++--- ...ntime-hotkey-registration-coverage.test.ts | 149 +++++++ 2 files changed, 513 insertions(+), 51 deletions(-) diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index 85c92b679..7daceddad 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -78,9 +78,28 @@ const BARRIER_DECLARATION_KINDS = new Set([ SyntaxKind.ModuleDeclaration, ]) -function createScope(parent, kind, isVarScope = false, isConstantBoundary = false) { +const UNCERTAIN_WRITE_ANCESTOR_KINDS = new Set([ + SyntaxKind.ConditionalExpression, + SyntaxKind.DoStatement, + SyntaxKind.ForInStatement, + SyntaxKind.ForOfStatement, + SyntaxKind.ForStatement, + SyntaxKind.IfStatement, + SyntaxKind.SwitchStatement, + SyntaxKind.TryStatement, + SyntaxKind.WhileStatement, + SyntaxKind.WithStatement, +]) + +function createScope( + parent, + kind, + isVarScope = false, + isConstantBoundary = false, + owner, +) { const region = !parent || isConstantBoundary ? { bindings: new Map() } : parent.region - return { parent, kind, isVarScope, isConstantBoundary, region, bindings: new Map() } + return { parent, kind, isVarScope, isConstantBoundary, region, bindings: new Map(), owner } } function declareBinding(scope, name, binding) { @@ -97,7 +116,16 @@ function declareBinding(scope, name, binding) { } function hasModifier(node, kind) { - return node.modifiers?.some((modifier) => modifier.kind === kind) ?? false + return node?.modifiers?.some((modifier) => modifier.kind === kind) ?? false +} + +function isAmbientDeclaration(node) { + let current = node + while (current) { + if (hasModifier(current, SyntaxKind.DeclareKeyword)) return true + current = current.parent + } + return false } function bindingNames(name) { @@ -120,7 +148,7 @@ function nearestVarScope(scope) { return current } -function variableBinding(declaration, declarationScope, isConst, isResolvableConst) { +function variableBinding(declaration, declarationScope, isConst, isResolvableConst, isHoisted) { if (isResolvableConst && isIdentifier(declaration.name) && declaration.initializer) { return { kind: 'constant', @@ -135,7 +163,9 @@ function variableBinding(declaration, declarationScope, isConst, isResolvableCon initializer: declaration.initializer, scope: declarationScope, mutationPositions: [], - availableAfter: declaration.end, + writes: [], + isHoisted, + availableAfter: isHoisted ? 0 : declaration.end, } } return { kind: 'unknown-shadow', availableAfter: declaration.end } @@ -147,9 +177,18 @@ function declareVariableList(declarationList, scope, { resolveLoopConstants = fa const declarationScope = isBlockScoped ? scope : nearestVarScope(scope) const isResolvableConst = isConst && (declarationScope.kind !== 'loop' || resolveLoopConstants) + const isAmbient = isAmbientDeclaration(declarationList) + + if (isAmbient) return for (const declaration of declarationList.declarations) { - const binding = variableBinding(declaration, declarationScope, isConst, isResolvableConst) + const binding = variableBinding( + declaration, + declarationScope, + isConst, + isResolvableConst, + !isBlockScoped, + ) if (isIdentifier(declaration.name)) { declareBinding(declarationScope, declaration.name.text, binding) } else { @@ -159,28 +198,40 @@ function declareVariableList(declarationList, scope, { resolveLoopConstants = fa } function declareImportBindings(node, scope) { - if (isImportEqualsDeclaration(node)) { - declareBarrier(scope, node.name) - return - } + if (isImportEqualsDeclaration(node)) return declareImportEqualsBinding(node, scope) if (!isImportDeclaration(node) || !node.importClause) return + if (node.importClause.isTypeOnly) return const { name, namedBindings } = node.importClause if (name) declareBarrier(scope, name) + declareNamedImportBindings(namedBindings, scope) +} + +function declareImportEqualsBinding(node, scope) { + if (!node.isTypeOnly) declareBarrier(scope, node.name) +} + +function declareNamedImportBindings(namedBindings, scope) { if (!namedBindings) return if (namedBindings.name) { declareBarrier(scope, namedBindings.name) return } - for (const element of namedBindings.elements) declareBarrier(scope, element.name) + for (const element of namedBindings.elements) { + if (!element.isTypeOnly) declareBarrier(scope, element.name) + } } function createFunctionLexicalScope(node, currentScope) { - if (node.kind === SyntaxKind.FunctionDeclaration && node.name) { + if ( + node.kind === SyntaxKind.FunctionDeclaration && + node.name && + !isAmbientDeclaration(node) + ) { declareBarrier(currentScope, node.name, { kind: 'static-shadow' }) } - const functionScope = createScope(currentScope, 'function', true, true) + const functionScope = createScope(currentScope, 'function', true, true, node) if (NAMED_FUNCTION_SCOPE_KINDS.has(node.kind) && node.name) { declareBarrier(functionScope, node.name) } @@ -189,11 +240,11 @@ function createFunctionLexicalScope(node, currentScope) { } function createClassLexicalScope(node, currentScope) { - if (node.kind === SyntaxKind.ClassDeclaration && node.name) { + if (node.kind === SyntaxKind.ClassDeclaration && node.name && !isAmbientDeclaration(node)) { declareBarrier(currentScope, node.name, { kind: 'static-shadow' }) } - const classScope = createScope(currentScope, 'class', false, true) + const classScope = createScope(currentScope, 'class', false, true, node) if (node.name) declareBarrier(classScope, node.name) return classScope } @@ -250,8 +301,14 @@ function predeclareNodeBindings(node, currentScope) { if (isImportDeclaration(node) || isImportEqualsDeclaration(node)) { return declareImportBindings(node, currentScope) } - if (isConstEnumDeclaration(node)) return declareConstEnum(node, currentScope) - if (BARRIER_DECLARATION_KINDS.has(node.kind) && node.name) { + if (isConstEnumDeclaration(node) && !isAmbientDeclaration(node)) { + return declareConstEnum(node, currentScope) + } + if ( + BARRIER_DECLARATION_KINDS.has(node.kind) && + node.name && + !isAmbientDeclaration(node) + ) { declareBarrier(currentScope, node.name) } } @@ -356,27 +413,91 @@ function findLexicalBinding(scope, name) { return undefined } -function assignmentTargetIdentifier(node) { - const assignment = - isBinaryExpression(node) && - node.operatorToken.kind >= SyntaxKind.FirstAssignment && - node.operatorToken.kind <= SyntaxKind.LastAssignment - if (assignment && isIdentifier(node.left)) return node.left - const update = - isPrefixUnaryExpression(node) || isPostfixUnaryExpression(node) - ? UPDATE_OPERATORS.has(node.operator) - : false - return update && isIdentifier(node.operand) ? node.operand : undefined +function hasUncertainWriteAncestor(node) { + let current = node.parent + while (current) { + if (UNCERTAIN_WRITE_ANCESTOR_KINDS.has(current.kind)) return true + if ( + isBinaryExpression(current) && + (current.operatorToken.kind === SyntaxKind.AmpersandAmpersandToken || + current.operatorToken.kind === SyntaxKind.BarBarToken || + current.operatorToken.kind === SyntaxKind.QuestionQuestionToken) + ) { + return true + } + current = current.parent + } + return false +} + +function crossesNestedFunction(scope, binding) { + let current = scope + while (current && current !== binding.scope) { + if (current.kind === 'function') return true + current = current.parent + } + return current !== binding.scope +} + +function assignmentWriteDescriptors(node) { + if (isBinaryExpression(node)) return binaryAssignmentWriteDescriptors(node) + if (isPrefixUnaryExpression(node) || isPostfixUnaryExpression(node)) { + return updateWriteDescriptors(node) + } + if (node.kind === SyntaxKind.ForInStatement || node.kind === SyntaxKind.ForOfStatement) { + return loopWriteDescriptors(node) + } + return [] +} + +function binaryAssignmentWriteDescriptors(node) { + if ( + node.operatorToken.kind < SyntaxKind.FirstAssignment || + node.operatorToken.kind > SyntaxKind.LastAssignment + ) { + return [] + } + const names = bindingNames(node.left) + if (names.length === 0) return [] + const isSimple = node.operatorToken.kind === SyntaxKind.EqualsToken + return names.map((name) => ({ + name, + expression: isSimple && isIdentifier(node.left) ? node.right : undefined, + isSimple: isSimple && isIdentifier(node.left), + })) +} + +function updateWriteDescriptors(node) { + if (!UPDATE_OPERATORS.has(node.operator) || !isIdentifier(node.operand)) return [] + return [{ name: node.operand.text, expression: undefined, isSimple: false }] +} + +function loopWriteDescriptors(node) { + return bindingNames(node.initializer).map((name) => ({ + name, + expression: undefined, + isSimple: false, + })) } function markMutableBindingWrites(sourceFile, nodeScopes, sourceScope) { walkAst(sourceFile, (node) => { - const identifier = assignmentTargetIdentifier(node) - if (!identifier) return - const scope = nodeScopes.get(identifier) ?? nodeScopes.get(node) ?? sourceScope - const binding = findLexicalBinding(scope, identifier.text) - if (binding?.kind === 'mutable') { - binding.mutationPositions.push(node.end) + const descriptors = assignmentWriteDescriptors(node) + if (descriptors.length === 0) return + const scope = nodeScopes.get(node) ?? sourceScope + const isUncertain = hasUncertainWriteAncestor(node) + for (const descriptor of descriptors) { + const binding = findLexicalBinding(scope, descriptor.name) + if (binding?.kind !== 'mutable') continue + const write = { + position: node.end, + expression: descriptor.expression, + scope, + isSimple: descriptor.isSimple, + isUncertain: isUncertain || crossesNestedFunction(scope, binding), + } + binding.mutationPositions.push(write.position) + binding.writes.push(write) } }) } @@ -475,6 +596,91 @@ function evaluateConstantBindingValue(binding, resolving, depth) { return result } +function evaluateMutableBindingValue( + binding, + resolving, + referenceScope, + referencePosition, + depth, +) { + if (resolving.has(binding) || depth > MAX_CONSTANT_EVALUATION_DEPTH) return undefined + + const writes = binding.writes ?? [] + if (writes.length > 1) return undefined + if (writes.length === 0) { + return evaluateMutableInitializerValue( + binding, + resolving, + referenceScope, + referencePosition, + depth + 1, + ) + } + return evaluateMutableAssignmentValue( + binding, + writes[0], + resolving, + referenceScope, + referencePosition, + depth + 1, + ) +} + +function evaluateMutableInitializerValue( + binding, + resolving, + referenceScope, + referencePosition, + depth, +) { + if (!binding.initializer) return undefined + if (referencePosition < binding.initializer.getStart()) return undefined + if (!isBindingAvailable(binding, referencePosition)) return undefined + const nextResolving = new Set(resolving).add(binding) + const result = evaluateConstantValue( + binding.initializer, + binding.scope, + nextResolving, + referenceScope, + referencePosition, + undefined, + depth + 1, + ) + return result ? { ...result, state: binding.initializer } : undefined +} + +function mutableAssignmentIsFoldable(binding, write, referencePosition) { + if (binding.initializer) return false + if (!write.isSimple || write.isUncertain) return false + if (!write.expression || write.position >= referencePosition) return false + return !expressionReferencesMutableBinding(write.expression, write.scope) +} + +function evaluateMutableAssignmentValue( + binding, + write, + resolving, + referenceScope, + referencePosition, + depth, +) { + // Rolldown only folds a mutable binding with a single, simple assignment + // when there was no initializer. Any explicit reassignment invalidates the + // binding's constant state, including writes after an earlier use. + if (!mutableAssignmentIsFoldable(binding, write, referencePosition)) return undefined + const nextResolving = new Set(resolving).add(binding) + const result = evaluateConstantValue( + write.expression, + write.scope, + nextResolving, + referenceScope, + referencePosition, + undefined, + depth + 1, + ) + return result ? { ...result, state: write } : undefined +} + function mutableShadowBlocks(candidate, referencePosition, resolving, depth) { if (candidate.mutationPositions.some((position) => position <= referencePosition)) return true if (!candidate.initializer) return false @@ -545,6 +751,52 @@ function dependencyMatchesUseSite( return false } const visibleBinding = findBinding(referenceScope, dependency.name) + if (dependency.binding.kind === 'mutable') { + return mutableDependencyMatchesUseSite( + dependency, + visibleBinding, + referenceScope, + referencePosition, + resolving, + depth + 1, + ) + } + return nonMutableDependencyMatchesUseSite( + dependency, + visibleBinding, + referencePosition, + resolving, + depth + 1, + ) +} + +function mutableDependencyMatchesUseSite( + dependency, + visibleBinding, + referenceScope, + referencePosition, + resolving, + depth, +) { + if (visibleBinding !== dependency.binding) return false + if (!isBindingAvailable(visibleBinding, referencePosition)) return false + const current = evaluateMutableBindingValue( + dependency.binding, + resolving, + referenceScope, + referencePosition, + depth + 1, + ) + return current?.state === dependency.state && current.value === dependency.value +} + +function nonMutableDependencyMatchesUseSite( + dependency, + visibleBinding, + referencePosition, + resolving, + depth, +) { if ( !visibleBinding || visibleBinding === dependency.binding || @@ -566,17 +818,26 @@ function evaluateConstantBinding( const binding = findBinding(scope, expression.text) if ( !binding || - binding.kind !== 'constant' || + (binding.kind !== 'constant' && binding.kind !== 'mutable') || resolving.has(binding) || !isBindingAvailable(binding, expression.getStart()) ) { return undefined } - const value = evaluateConstantBindingValue(binding, resolving, depth + 1) + const value = + binding.kind === 'constant' + ? evaluateConstantBindingValue(binding, resolving, depth + 1) + : evaluateMutableBindingValue( + binding, + resolving, + referenceScope, + referencePosition, + depth + 1, + ) if (!value) return undefined const dependencies = [ - { name: expression.text, binding, value: value.value }, + { name: expression.text, binding, value: value.value, state: value.state }, ...value.dependencies, ] if ( @@ -854,25 +1115,60 @@ function possibleConditionalTarget(expression, context) { ) } +function expressionReferencesMutableBinding(expression, scope) { + let found = false + walkAst(expression, (node) => { + if (!isIdentifier(node)) return + const binding = findBinding(scope, node.text) + if (binding?.kind === 'mutable') found = true + }) + return found +} + function possibleIdentifierTarget(expression, context) { const { scope, resolving, referenceScope, referencePosition, depth } = context const binding = findBinding(scope, expression.text) - if ( - !binding || - binding.kind !== 'constant' || - resolving.has(binding) || - !isBindingAvailable(binding, expression.getStart()) - ) { + if (!identifierTargetBindingIsAvailable(binding, resolving, expression.getStart())) { return false } + if (binding.kind === 'mutable') { + return mutableIdentifierTarget(binding, context) + } + if (binding.kind !== 'constant') return false + return constantIdentifierTarget(expression, binding, context) +} + +function identifierTargetBindingIsAvailable(binding, resolving, referencePosition) { + return Boolean( + binding && + !resolving.has(binding) && + isBindingAvailable(binding, referencePosition), + ) +} + +function mutableIdentifierTarget(binding, context) { + const value = evaluateMutableBindingValue( + binding, + context.resolving, + context.referenceScope, + context.referencePosition, + context.depth + 1, + ) + return value?.value === REACT_HOTKEYS_HOOK_MODULE +} + +function constantIdentifierTarget(expression, binding, context) { + const value = evaluateConstantBindingValue(binding, context.resolving, context.depth + 1) + if (value) return constantTargetDependenciesMatch(expression, binding, value, context) + if (expressionReferencesMutableBinding(binding.initializer, binding.scope)) return false const dependency = { name: expression.text, binding } if ( !dependencyMatchesUseSite( dependency, - referenceScope, - referencePosition, - resolving, - depth + 1, + context.referenceScope, + context.referencePosition, + context.resolving, + context.depth + 1, ) ) { return false @@ -880,10 +1176,27 @@ function possibleIdentifierTarget(expression, context) { return expressionMayResolveToReactHotkeys( binding.initializer, binding.scope, - new Set(resolving).add(binding), - referenceScope, - referencePosition, - depth + 1, + new Set(context.resolving).add(binding), + context.referenceScope, + context.referencePosition, + context.depth + 1, + ) +} + +function constantTargetDependenciesMatch(expression, binding, value, context) { + if (value.value !== REACT_HOTKEYS_HOOK_MODULE) return false + const dependencies = [ + { name: expression.text, binding, value: value.value }, + ...value.dependencies, + ] + return dependencies.every((dependency) => + dependencyMatchesUseSite( + dependency, + context.referenceScope, + context.referencePosition, + context.resolving, + context.depth + 1, + ), ) } diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index 94dfeaebf..d90c38330 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -16,6 +16,89 @@ const ROLLDOWN_PARITY_CASES = [ source: "export function load() { const pkg = 'react-hotkeys-hook'; return import(pkg) }", resolves: true, }, + { + name: 'function-local let initializer', + source: "export function load() { let pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: true, + }, + { + name: 'function-local var initializer', + source: "export function load() { var pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: true, + }, + { + name: 'function-local let simple assignment', + source: "export function load() { let pkg; pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: true, + }, + { + name: 'function-local var simple assignment', + source: "export function load() { var pkg; pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: true, + }, + { + name: 'mutable read before assignment', + source: "export function load() { let pkg; import(pkg); pkg = 'react-hotkeys-hook' }", + resolves: false, + }, + { + name: 'mutable reassignment before use', + source: + "export function load() { let pkg = 'react-hotkeys-hook'; pkg = 'other'; return import(pkg) }", + resolves: false, + }, + { + name: 'mutable reassignment after use', + source: "export function load() { let pkg = 'react-hotkeys-hook'; import(pkg); pkg = 'other' }", + resolves: false, + }, + { + name: 'mutable branch write', + source: + "declare const enabled: boolean; export function load() { let pkg; if (enabled) pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: false, + }, + { + name: 'mutable loop write', + source: + "declare const enabled: boolean; export function load() { let pkg; while (enabled) pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: false, + }, + { + name: 'mutable unknown write', + source: + "declare function moduleName(): string; export function load() { let pkg = 'react-hotkeys-hook'; pkg = moduleName(); return import(pkg) }", + resolves: false, + }, + { + name: 'mutable nested block assignment', + source: "export function load() { let pkg; { pkg = 'react-hotkeys-hook' } return import(pkg) }", + resolves: true, + }, + { + name: 'mutable alias after assignment', + source: + "export function load() { let pkg; pkg = 'react-hotkeys-hook'; const alias = pkg; return import(alias) }", + resolves: true, + }, + { + name: 'mutable alias before assignment', + source: + "export function load() { let pkg; const alias = pkg; pkg = 'react-hotkeys-hook'; return import(alias) }", + resolves: false, + }, + { + name: 'mutable alias captured before later write', + source: + "export function load() { let pkg = 'react-hotkeys-hook'; const alias = pkg; pkg = 'other'; return import(alias) }", + resolves: false, + }, + { + name: 'mutable closure uncertainty', + source: + "export function load() { let pkg = 'react-hotkeys-hook'; const inner = () => import(pkg); return inner }", + resolves: false, + }, { name: 'shadowed parameter', source: @@ -514,6 +597,72 @@ const ROLLDOWN_PARITY_CASES = [ source: "export function load() { return require('react-hotkeys-hook') }", resolves: true, }, + { + name: 'type-only named require binding', + source: + "import { type require } from 'runtime-name'; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'type-only default require binding', + source: + "import type require from 'runtime-name'; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'type-only namespace require binding', + source: + "import type * as require from 'runtime-name'; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'type-only import does not shadow package binding', + source: + "const pkg = 'react-hotkeys-hook'; import type { pkg } from 'runtime-name'; import(pkg)", + resolves: true, + }, + { + name: 'mixed value import still shadows require', + source: + "import { type Other, require } from 'runtime-name'; export function load() { return require('react-hotkeys-hook') }", + resolves: false, + }, + { + name: 'ambient function require binding', + source: + "declare function require(id: string): unknown; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'ambient const require binding', + source: + "declare const require: (id: string) => unknown; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'ambient let require binding', + source: + "declare let require: (id: string) => unknown; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'ambient var require binding', + source: + "declare var require: (id: string) => unknown; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'ambient class require binding', + source: + "declare class require {} export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'ambient namespace require binding', + source: + "declare namespace require {} export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, { name: 'require parameter shadow', source: From dcf85d7379928838fb8cc88dee2d686dca96c686 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 11:05:15 -0700 Subject: [PATCH 55/64] fix(hotkeys): match Rolldown control-flow folding (cherry picked from commit 3726994b77f154e57602c66b5f6efb818287fd79) --- scripts/runtime-hotkey-import-boundary.mjs | 140 ++++++++++++++---- ...ntime-hotkey-registration-coverage.test.ts | 53 +++++++ 2 files changed, 168 insertions(+), 25 deletions(-) diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index 7daceddad..a918af357 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -37,6 +37,12 @@ const BINARY_VALUE_RESOLVERS = new Map([ const UPDATE_OPERATORS = new Set([SyntaxKind.PlusPlusToken, SyntaxKind.MinusMinusToken]) +const SHORT_CIRCUIT_OPERATORS = new Set([ + SyntaxKind.AmpersandAmpersandToken, + SyntaxKind.BarBarToken, + SyntaxKind.QuestionQuestionToken, +]) + const FUNCTION_SCOPE_KINDS = new Set([ SyntaxKind.FunctionDeclaration, SyntaxKind.FunctionExpression, @@ -99,7 +105,15 @@ function createScope( owner, ) { const region = !parent || isConstantBoundary ? { bindings: new Map() } : parent.region - return { parent, kind, isVarScope, isConstantBoundary, region, bindings: new Map(), owner } + return { + parent, + kind, + isVarScope, + isConstantBoundary, + region, + bindings: new Map(), + owner: owner ?? parent?.owner, + } } function declareBinding(scope, name, binding) { @@ -164,8 +178,10 @@ function variableBinding(declaration, declarationScope, isConst, isResolvableCon scope: declarationScope, mutationPositions: [], writes: [], + owner: declarationScope.owner, isHoisted, availableAfter: isHoisted ? 0 : declaration.end, + assignmentAvailableAfter: declaration.end, } } return { kind: 'unknown-shadow', availableAfter: declaration.end } @@ -269,6 +285,7 @@ function createChildLexicalScope(node, currentScope) { 'block', BLOCK_VAR_SCOPE_KINDS.has(node.kind), node.kind === SyntaxKind.ModuleBlock, + node.kind === SyntaxKind.ModuleBlock ? node : undefined, ) } @@ -314,7 +331,7 @@ function predeclareNodeBindings(node, currentScope) { } function buildLexicalScopes(sourceFile) { - const sourceScope = createScope(undefined, 'source', true, true) + const sourceScope = createScope(undefined, 'source', true, true, sourceFile) const nodeScopes = new WeakMap() function visitLoopHeader(node, currentScope) { @@ -413,30 +430,85 @@ function findLexicalBinding(scope, name) { return undefined } -function hasUncertainWriteAncestor(node) { - let current = node.parent - while (current) { - if (UNCERTAIN_WRITE_ANCESTOR_KINDS.has(current.kind)) return true - if ( - isBinaryExpression(current) && - (current.operatorToken.kind === SyntaxKind.AmpersandAmpersandToken || - current.operatorToken.kind === SyntaxKind.BarBarToken || - current.operatorToken.kind === SyntaxKind.QuestionQuestionToken) - ) { - return true - } - current = current.parent +function staticControlValue(expression, nodeScopes, sourceScope) { + const scope = nodeScopes.get(expression) ?? sourceScope + const result = evaluateConstantValue( + expression, + scope, + new Set(), + scope, + expression.getStart(), + ) + return result ? { known: true, value: result.value } : { known: false } +} + +function conditionalBranchStatus( + branch, + condition, + whenTrue, + whenFalse, + nodeScopes, + sourceScope, +) { + if (branch === condition) return 'reachable' + const control = staticControlValue(condition, nodeScopes, sourceScope) + if (!control.known) return 'uncertain' + return branch === (control.value ? whenTrue : whenFalse) ? 'reachable' : 'unreachable' +} + +function logicalRightStatus(node, expression, nodeScopes, sourceScope) { + if (node !== expression.right) return 'reachable' + const left = staticControlValue(expression.left, nodeScopes, sourceScope) + if (!left.known) return 'uncertain' + const executes = + expression.operatorToken.kind === SyntaxKind.AmpersandAmpersandToken + ? Boolean(left.value) + : expression.operatorToken.kind === SyntaxKind.BarBarToken + ? !left.value + : left.value === null + return executes ? 'reachable' : 'unreachable' +} + +function controlFlowAncestorStatus(current, branch, nodeScopes, sourceScope) { + if (current.kind === SyntaxKind.IfStatement) { + return conditionalBranchStatus( + branch, + current.expression, + current.thenStatement, + current.elseStatement, + nodeScopes, + sourceScope, + ) } - return false + if (current.kind === SyntaxKind.ConditionalExpression) { + return conditionalBranchStatus( + branch, + current.condition, + current.whenTrue, + current.whenFalse, + nodeScopes, + sourceScope, + ) + } + if ( + isBinaryExpression(current) && + SHORT_CIRCUIT_OPERATORS.has(current.operatorToken.kind) + ) { + return logicalRightStatus(branch, current, nodeScopes, sourceScope) + } + return UNCERTAIN_WRITE_ANCESTOR_KINDS.has(current.kind) ? 'uncertain' : 'reachable' } -function crossesNestedFunction(scope, binding) { - let current = scope - while (current && current !== binding.scope) { - if (current.kind === 'function') return true +function controlFlowWriteStatus(node, owner, nodeScopes, sourceScope) { + let branch = node + let current = node.parent + while (current && current !== owner) { + const status = controlFlowAncestorStatus(current, branch, nodeScopes, sourceScope) + if (status !== 'reachable') return status + branch = current current = current.parent } - return current !== binding.scope + return current === owner ? 'reachable' : 'uncertain' } function assignmentWriteDescriptors(node) { @@ -481,25 +553,42 @@ function loopWriteDescriptors(node) { } function markMutableBindingWrites(sourceFile, nodeScopes, sourceScope) { + const writes = [] walkAst(sourceFile, (node) => { const descriptors = assignmentWriteDescriptors(node) if (descriptors.length === 0) return const scope = nodeScopes.get(node) ?? sourceScope - const isUncertain = hasUncertainWriteAncestor(node) for (const descriptor of descriptors) { const binding = findLexicalBinding(scope, descriptor.name) if (binding?.kind !== 'mutable') continue const write = { + node, + start: node.getStart(), position: node.end, expression: descriptor.expression, scope, isSimple: descriptor.isSimple, - isUncertain: isUncertain || crossesNestedFunction(scope, binding), + isReachable: true, + isUncertain: true, } - binding.mutationPositions.push(write.position) binding.writes.push(write) + writes.push({ binding, write }) } }) + + for (const { binding, write } of writes) { + const status = + scopeOwner(write.scope) === binding.owner + ? controlFlowWriteStatus(write.node, binding.owner, nodeScopes, sourceScope) + : 'uncertain' + write.isReachable = status !== 'unreachable' + write.isUncertain = status !== 'reachable' + if (write.isReachable) binding.mutationPositions.push(write.position) + } +} + +function scopeOwner(scope) { + return scope?.owner } function isBindingAvailable(binding, referencePosition) { @@ -605,7 +694,7 @@ function evaluateMutableBindingValue( ) { if (resolving.has(binding) || depth > MAX_CONSTANT_EVALUATION_DEPTH) return undefined - const writes = binding.writes ?? [] + const writes = (binding.writes ?? []).filter((write) => write.isReachable) if (writes.length > 1) return undefined if (writes.length === 0) { return evaluateMutableInitializerValue( @@ -651,6 +740,7 @@ function evaluateMutableInitializerValue( function mutableAssignmentIsFoldable(binding, write, referencePosition) { if (binding.initializer) return false + if (write.start < binding.assignmentAvailableAfter) return false if (!write.isSimple || write.isUncertain) return false if (!write.expression || write.position >= referencePosition) return false return !expressionReferencesMutableBinding(write.expression, write.scope) diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index d90c38330..ff98cc4db 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -36,6 +36,53 @@ const ROLLDOWN_PARITY_CASES = [ source: "export function load() { var pkg; pkg = 'react-hotkeys-hook'; return import(pkg) }", resolves: true, }, + { + name: 'function owner inside dynamic branch', + source: + "declare const enabled: boolean; if (enabled) { function load() { let pkg; pkg = 'react-hotkeys-hook'; return import(pkg) } load() }", + resolves: true, + }, + { + name: 'class owner inside dynamic branch', + source: + "declare const enabled: boolean; if (enabled) { class Loader { static { let pkg; pkg = 'react-hotkeys-hook'; import(pkg) } } new Loader() }", + resolves: true, + }, + { + name: 'mutable statically true branch write', + source: "let pkg; if (true) pkg = 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'mutable statically false else write', + source: "let pkg; if (false) pkg = 'other'; else pkg = 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'mutable dead branch reassignment', + source: "let pkg = 'react-hotkeys-hook'; if (false) pkg = 'other'; import(pkg)", + resolves: true, + }, + { + name: 'mutable statically executed logical write', + source: "let pkg; true && (pkg = 'react-hotkeys-hook'); import(pkg)", + resolves: true, + }, + { + name: 'mutable statically selected conditional write', + source: "let pkg; true ? pkg = 'react-hotkeys-hook' : pkg = 'other'; import(pkg)", + resolves: true, + }, + { + name: 'var assignment before declaration', + source: "pkg = 'react-hotkeys-hook'; var pkg; import(pkg)", + resolves: false, + }, + { + name: 'let assignment before declaration', + source: "pkg = 'react-hotkeys-hook'; let pkg; import(pkg)", + resolves: false, + }, { name: 'mutable read before assignment', source: "export function load() { let pkg; import(pkg); pkg = 'react-hotkeys-hook' }", @@ -64,6 +111,12 @@ const ROLLDOWN_PARITY_CASES = [ "declare const enabled: boolean; export function load() { let pkg; while (enabled) pkg = 'react-hotkeys-hook'; return import(pkg) }", resolves: false, }, + { + name: 'mutable exception-path write', + source: + "export function load() { let pkg; try { pkg = 'react-hotkeys-hook' } finally {} return import(pkg) }", + resolves: false, + }, { name: 'mutable unknown write', source: From e478e7fb0f7b845584967d6a9feb05c1d92e5b1d Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 12:09:24 -0700 Subject: [PATCH 56/64] fix(integration): preserve host and shortcut boundaries --- .../freecut-editor/consumer-smoke.test.tsx | 16 ++++++ .../components/timeline-header.test.tsx | 10 ++-- .../timeline/components/timeline-header.tsx | 6 +++ .../shortcuts/use-playback-shortcuts.test.tsx | 54 ++++++++++++++++++- 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/packages/freecut-editor/consumer-smoke.test.tsx b/packages/freecut-editor/consumer-smoke.test.tsx index b8ca6abab..65c8e3d7d 100644 --- a/packages/freecut-editor/consumer-smoke.test.tsx +++ b/packages/freecut-editor/consumer-smoke.test.tsx @@ -12,7 +12,10 @@ import { createHostShortcutSettings, isHostCapabilityEnabled, type EditorHost, + type EditorTranscriptPort, type EmbeddedEditorSnapshot, + type HostEditPredicate, + type HostNotice, } from '@quantfive/freecut-editor-surface' const snapshot: EmbeddedEditorSnapshot = { @@ -47,6 +50,7 @@ function fakeHost(): EditorHost { submitEdit: vi.fn(() => { throw new Error('consumer smoke does not submit an edit') }), + subscribe: vi.fn(() => () => undefined), shortcuts: { getSettings: () => createHostShortcutSettings({ @@ -127,9 +131,21 @@ describe('published FreeCut browser entry', () => { EDIT_KEYFRAME_ADD: 'shift+k', }) expect(host.load).toHaveBeenCalledTimes(1) + expect(host.subscribe).toHaveBeenCalledTimes(1) expect(capabilityForCommand('move_item')).toBe('timeline.move') + expect(capabilityForCommand('ripple_delete')).toBe('timeline.remove') expect(capabilityForCommand('set_caption_style')).toBe('timeline.caption') expect(isHostCapabilityEnabled(host.capabilities, 'timeline.add')).toBe(false) + + const predicate: HostEditPredicate = 'sourceRange' + const notice: HostNotice = { + kind: 'unsupported', + message: 'Unsupported edit', + detail: { code: 'ambiguous_change', failedPredicates: [predicate] }, + } + const requestTranscription = vi.fn>() + expect(notice.detail?.failedPredicates).toEqual(['sourceRange']) + expect(requestTranscription).toBeTypeOf('function') }) it('sizes the published surface against its container, never the viewport', async () => { diff --git a/src/features/timeline/components/timeline-header.test.tsx b/src/features/timeline/components/timeline-header.test.tsx index 9110dd176..5aefc9e84 100644 --- a/src/features/timeline/components/timeline-header.test.tsx +++ b/src/features/timeline/components/timeline-header.test.tsx @@ -350,9 +350,13 @@ describe('TimelineHeader zoom slider', () => { name: 'Trim Edit Tool (W) · Ripple: Shift-drag · Roll: Alt-drag', }), ).toHaveAttribute('data-tooltip', 'Trim Edit Tool (W) · Ripple: Shift-drag · Roll: Alt-drag') - expect( - screen.getByRole('button', { name: 'Razor Tool (E) · Split: Shift + X' }), - ).toHaveAttribute('data-tooltip', 'Razor Tool (E) · Split: Shift + X') + const razor = screen.getByRole('button', { + name: /Razor Tool \(E\).*Shift \+ X.*Alt \+ C/, + }) + expect(razor).toHaveAttribute( + 'data-tooltip', + expect.stringMatching(/Razor Tool \(E\).*Shift \+ X.*Alt \+ C/), + ) expect(screen.getByRole('button', { name: 'Rate Stretch Tool (D)' })).toHaveAttribute( 'data-tooltip', 'Rate Stretch Tool (D)', diff --git a/src/features/timeline/components/timeline-header.tsx b/src/features/timeline/components/timeline-header.tsx index e03552f5e..736f363ae 100644 --- a/src/features/timeline/components/timeline-header.tsx +++ b/src/features/timeline/components/timeline-header.tsx @@ -507,6 +507,12 @@ export const TimelineHeader = memo(function TimelineHeader({ t('timeline.header.splitAtPlayheadHint', { shortcut: splitAtPlayheadShortcut }), ) } + const splitAtPlayheadAlternateShortcut = formatHotkeyBinding(hotkeys.SPLIT_AT_PLAYHEAD_ALT) + if (splitAtPlayheadAlternateShortcut) { + razorToolTooltipParts.push( + `${t('projects.settings.hotkeys.items.splitAtPlayhead')} (${splitAtPlayheadAlternateShortcut})`, + ) + } const razorToolTooltip = razorToolTooltipParts.join(' · ') const rateStretchToolTooltip = labelWithShortcut( t('timeline.header.rateStretchToolTooltip'), diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx index 3e0190139..6be762314 100644 --- a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx @@ -4,6 +4,8 @@ import { useSettingsStore } from '@/features/timeline/deps/settings' import { usePlaybackStore } from '@/shared/state/playback' import { useSourcePlayerStore } from '@/shared/state/source-player' import type { SourcePlayerMethods } from '@/shared/state/source-player/types' +import type { VideoItem } from '@/types/timeline' +import { useItemsStore } from '../../stores/items-store' import { usePlaybackShortcuts } from './use-playback-shortcuts' function PlaybackShortcutHarness() { @@ -11,7 +13,7 @@ function PlaybackShortcutHarness() { return } -function sourcePlayerMethods(): SourcePlayerMethods { +function sourcePlayerMethods(durationInFrames = 300): SourcePlayerMethods { return { toggle: vi.fn(), pause: vi.fn(), @@ -21,7 +23,20 @@ function sourcePlayerMethods(): SourcePlayerMethods { seek: vi.fn(), frameBack: vi.fn(), frameForward: vi.fn(), - getDurationInFrames: vi.fn(() => 300), + getDurationInFrames: vi.fn(() => durationInFrames), + } +} + +function videoItem(overrides: Partial = {}): VideoItem { + return { + id: 'clip-1', + type: 'video', + trackId: 'track-1', + from: 10, + durationInFrames: 5, + label: 'Clip', + src: 'clip.mp4', + ...overrides, } } @@ -40,6 +55,9 @@ describe('usePlaybackShortcuts transport routing', () => { hoveredPanel: null, playerMethods: null, }) + useItemsStore + .getState() + .setItems([videoItem(), videoItem({ id: 'clip-2', from: 0, durationInFrames: 7 })]) }) it('routes J, K, and L to reverse, pause, and forward program transport', () => { @@ -132,4 +150,36 @@ describe('usePlaybackShortcuts transport routing', () => { transportMode: 'shuttle', }) }) + + it('clamps timeline ArrowRight to the final valid frame', () => { + usePlaybackStore.setState({ currentFrame: 13 }) + render() + + fireEvent.keyDown(document, { key: 'ArrowRight', code: 'ArrowRight' }) + expect(usePlaybackStore.getState().currentFrame).toBe(14) + + fireEvent.keyDown(document, { key: 'ArrowRight', code: 'ArrowRight' }) + expect(usePlaybackStore.getState().currentFrame).toBe(14) + }) + + it('seeks timeline End to the maximum inclusive item frame, or zero when empty', () => { + render() + + fireEvent.keyDown(document, { key: 'End', code: 'End' }) + expect(usePlaybackStore.getState().currentFrame).toBe(14) + + useItemsStore.getState().setItems([]) + fireEvent.keyDown(document, { key: 'End', code: 'End' }) + expect(usePlaybackStore.getState().currentFrame).toBe(0) + }) + + it('clamps source-player End to a nonnegative frame', () => { + const playerMethods = sourcePlayerMethods(0) + useSourcePlayerStore.setState({ hoveredPanel: 'source', playerMethods }) + render() + + fireEvent.keyDown(document, { key: 'End', code: 'End' }) + + expect(playerMethods.seek).toHaveBeenCalledWith(0) + }) }) From 4fc8b7931b4286eea9a2140e393371b642d8ac20 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 12:09:24 -0700 Subject: [PATCH 57/64] chore(release): bump editor surface to 0.3.9 --- packages/freecut-editor/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/freecut-editor/package.json b/packages/freecut-editor/package.json index cc8bdda58..a7e92d2c7 100644 --- a/packages/freecut-editor/package.json +++ b/packages/freecut-editor/package.json @@ -1,6 +1,6 @@ { "name": "@quantfive/freecut-editor-surface", - "version": "0.3.8", + "version": "0.3.9", "description": "The host-backed FreeCut browser editor surface.", "license": "MIT", "repository": { From bd5f55b8fa810449636d6e4b137e140b0815f4f2 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 12:13:21 -0700 Subject: [PATCH 58/64] chore(provenance): refresh package manifest hash --- provenance/dependency-inventory.json | 2 +- provenance/freecut-baseline.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/provenance/dependency-inventory.json b/provenance/dependency-inventory.json index 5825b5a0c..59b352b18 100644 --- a/provenance/dependency-inventory.json +++ b/provenance/dependency-inventory.json @@ -3,7 +3,7 @@ "generatedFrom": "package.json", "packageName": "freecut", "packageVersion": "0.0.0", - "packageJsonSha256": "0fcaa186e39d48d5e4e95339c0a853543aeea2a8bbbb54691a51502eb4d3bd5b", + "packageJsonSha256": "a2762fb7549da91ee6f5707dd712199198563231a710c8dbed2fa3f910d982ba", "lockfile": { "path": "package-lock.json", "lockfileVersion": 3, diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json index c23d79b8c..272385b17 100644 --- a/provenance/freecut-baseline.json +++ b/provenance/freecut-baseline.json @@ -34,7 +34,7 @@ ], "dependencies": { "packageJson": "package.json", - "packageJsonSha256": "0fcaa186e39d48d5e4e95339c0a853543aeea2a8bbbb54691a51502eb4d3bd5b", + "packageJsonSha256": "a2762fb7549da91ee6f5707dd712199198563231a710c8dbed2fa3f910d982ba", "lockfile": "package-lock.json", "lockfileVersion": 3, "lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e", From 925ebf37b01692c4b066407a9ee6a8563fd8028f Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 12:21:35 -0700 Subject: [PATCH 59/64] chore(format): normalize source monitor imports --- src/features/preview/components/source-monitor.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index 15a446545..578c5ae7c 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -53,10 +53,7 @@ import { } from '../utils/source-io' import { useMediaLibraryStore, getMediaType } from '@/features/preview/deps/media-library' import { useItemsStore } from '@/features/preview/deps/timeline-store' -import { - useResolvedHotkeys, - useSettingsStore, -} from '@/features/preview/deps/settings' +import { useResolvedHotkeys, useSettingsStore } from '@/features/preview/deps/settings' import { useEditorStore } from '@/shared/state/editor' import { useSourcePlayerStore } from '@/shared/state/source-player' import { getNextShuttleRate } from '@/shared/state/playback/shuttle' From 573c72f2b5ca1b7bd6020f59a3000980ed18f13f Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 13:50:06 -0700 Subject: [PATCH 60/64] fix(timeline): expose contiguous rolling trim handle (cherry picked from commit f22464674582b2afe987cbb3486ddc60fd7d53ea) --- .../contiguous-rolling-trim.dom.test.tsx | 313 ++++++++++++++++++ .../components/timeline-item/index.tsx | 72 +++- .../shared-rolling-trim-handle.tsx | 66 ++++ .../components/timeline-item/trim-handles.tsx | 2 + 4 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 src/features/timeline/components/timeline-item/contiguous-rolling-trim.dom.test.tsx create mode 100644 src/features/timeline/components/timeline-item/shared-rolling-trim-handle.tsx diff --git a/src/features/timeline/components/timeline-item/contiguous-rolling-trim.dom.test.tsx b/src/features/timeline/components/timeline-item/contiguous-rolling-trim.dom.test.tsx new file mode 100644 index 000000000..900e4c1c5 --- /dev/null +++ b/src/features/timeline/components/timeline-item/contiguous-rolling-trim.dom.test.tsx @@ -0,0 +1,313 @@ +import { act, fireEvent, render, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import type { TimelineItem as TimelineItemType, TimelineTrack } from '@/types/timeline' +import { useEditorStore } from '@/shared/state/editor' +import { usePlaybackStore } from '@/shared/state/playback' +import { useSelectionStore } from '@/shared/state/selection' +import { makeTimelineAudioItem, makeTimelineTrack, makeTimelineVideoItem } from '../../test-helpers' +import { useItemsStore } from '../../stores/items-store' +import { useKeyframesStore } from '../../stores/keyframes-store' +import { useTimelineCommandStore } from '../../stores/timeline-command-store' +import { useTimelineSettingsStore } from '../../stores/timeline-settings-store' +import { useTransitionsStore } from '../../stores/transitions-store' +import { useZoomStore } from '../../stores/zoom-store' +import { TimelineItem } from './index' + +vi.mock('./clip-content', () => ({ + ClipContent: ({ item }: { item: TimelineItemType }) => ( +
{item.label}
+ ), +})) + +const FPS = 30 +const CLIP_FRAMES = 60 +let rafCallbacks: FrameRequestCallback[] = [] + +function makeTracks(audioParentId?: string): TimelineTrack[] { + const tracks: TimelineTrack[] = [ + makeTimelineTrack({ id: 'track-v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ + id: 'track-a1', + name: 'A1', + kind: 'audio', + order: audioParentId ? 2 : 1, + parentTrackId: audioParentId, + }), + ] + if (audioParentId) { + tracks.splice( + 1, + 0, + makeTimelineTrack({ + id: audioParentId, + name: 'Locked audio group', + order: 1, + isGroup: true, + locked: true, + }), + ) + } + return tracks +} + +function makeVideoPair() { + return [ + makeTimelineVideoItem({ + id: 'video-left', + label: 'red.mp4', + linkedGroupId: 'left-cohort', + }), + makeTimelineVideoItem({ + id: 'video-right', + label: 'blue.mp4', + mediaId: 'media-2', + from: CLIP_FRAMES, + linkedGroupId: 'right-cohort', + }), + ] as const +} + +function makeLinkedFixtureItems() { + const [videoLeft, videoRight] = makeVideoPair() + return [ + videoLeft, + videoRight, + makeTimelineAudioItem({ + id: 'audio-left', + label: 'red.wav', + linkedGroupId: 'left-cohort', + }), + makeTimelineAudioItem({ + id: 'audio-right', + label: 'blue.wav', + mediaId: 'media-2', + from: CLIP_FRAMES, + linkedGroupId: 'right-cohort', + }), + ] +} + +function resetStores(tracks: TimelineTrack[], items: TimelineItemType[]): void { + useEditorStore.setState({ hostMode: false, linkedSelectionEnabled: true }) + useItemsStore.getState().setTracks(tracks) + useItemsStore.getState().setItems(items) + useTransitionsStore.getState().setTransitions([]) + useKeyframesStore.getState().setKeyframes([]) + useTimelineCommandStore.getState().clearHistory() + useTimelineSettingsStore.setState({ fps: FPS, isDirty: false, snapEnabled: false }) + useZoomStore.setState({ + level: 0.3, + pixelsPerSecond: FPS, + contentLevel: 0.3, + contentPixelsPerSecond: FPS, + isZoomInteracting: false, + }) + useSelectionStore.getState().clearSelection() + useSelectionStore.getState().setActiveTool('select') + useSelectionStore.getState().setDragState(null) + useSelectionStore.getState().setActiveSnapTarget(null) + usePlaybackStore.setState({ currentFrame: 0, previewFrame: null, isPlaying: false }) +} + +function renderItems( + items: readonly TimelineItemType[], + lockedTrackIds = new Set(), + isCompactWidth = false, +) { + const view = render( +
+ {items.map((item) => ( + + ))} +
, + ) + + for (const item of items) { + const root = view.container.querySelector(`[data-item-id="${item.id}"]`) + expect(root).toBeTruthy() + const left = item.from + const width = item.durationInFrames + vi.spyOn(root!, 'getBoundingClientRect').mockReturnValue({ + x: left, + y: 0, + left, + top: 0, + right: left + width, + bottom: 80, + width, + height: 80, + toJSON: () => ({}), + }) + } + + return view +} + +function flushAnimationFrame(): void { + act(() => { + const callbacks = rafCallbacks + rafCallbacks = [] + for (const callback of callbacks) callback(performance.now()) + }) +} + +function dragOneFrame(handle: HTMLElement): void { + fireEvent.mouseDown(handle, { button: 0, clientX: CLIP_FRAMES }) + fireEvent.mouseMove(window, { clientX: CLIP_FRAMES + 1 }) + flushAnimationFrame() + fireEvent.mouseUp(window, { clientX: CLIP_FRAMES + 1 }) +} + +function itemSnapshot() { + return structuredClone(useItemsStore.getState().items) +} + +describe('TimelineItem contiguous rolling trim affordance', () => { + beforeEach(() => { + rafCallbacks = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return rafCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + rafCallbacks[id - 1] = () => {} + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('mounts one visible, hit-testable shared-edge handle when either neighbor is selected or the cut is hovered', () => { + const items = makeVideoPair() + resetStores(makeTracks(), [...items]) + const view = renderItems(items, new Set(), true) + + act(() => useSelectionStore.getState().selectItems(['video-left'])) + const leftRoot = view.container.querySelector('[data-item-id="video-left"]')! + expect(leftRoot).toHaveAttribute('data-compact-clip', 'true') + const leftOwnedHandle = within(leftRoot).getByRole('slider', { name: /rolling trim/i }) + expect(leftOwnedHandle).toHaveAttribute('data-rolling-trim-handle', 'end') + expect(leftOwnedHandle).not.toHaveClass('pointer-events-none') + + act(() => useSelectionStore.getState().selectItems(['video-right'])) + expect(within(leftRoot).queryByRole('slider', { name: /rolling trim/i })).toBeNull() + const rightRoot = view.container.querySelector('[data-item-id="video-right"]')! + const rightOwnedHandle = within(rightRoot).getByRole('slider', { name: /rolling trim/i }) + expect(rightOwnedHandle).toHaveAttribute('data-rolling-trim-handle', 'start') + + act(() => useSelectionStore.getState().clearSelection()) + fireEvent.mouseMove(leftRoot, { clientX: CLIP_FRAMES, clientY: 40 }) + expect(within(leftRoot).getByRole('slider', { name: /rolling trim/i })).toBeVisible() + + act(() => useSelectionStore.getState().setActiveTool('razor')) + expect(view.container.querySelector('[data-rolling-trim-handle]')).toBeNull() + }) + + it('drags a linked A/V cut by one frame as one command and undo restores all four clips', () => { + const items = makeLinkedFixtureItems() + resetStores(makeTracks(), items) + useSelectionStore.getState().selectItems(['video-right', 'audio-right']) + const view = renderItems(items) + const videoRight = view.container.querySelector('[data-item-id="video-right"]')! + const handle = within(videoRight).getByRole('slider', { name: /rolling trim/i }) + const undoDepthBefore = useTimelineCommandStore.getState().undoStack.length + + dragOneFrame(handle) + + expect(useItemsStore.getState().itemById).toMatchObject({ + 'video-left': { durationInFrames: 61, sourceEnd: 61 }, + 'video-right': { from: 61, durationInFrames: 59, sourceStart: 1 }, + 'audio-left': { durationInFrames: 61, sourceEnd: 61 }, + 'audio-right': { from: 61, durationInFrames: 59, sourceStart: 1 }, + }) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(undoDepthBefore + 1) + + act(() => useTimelineCommandStore.getState().undo()) + + expect(useItemsStore.getState().itemById).toMatchObject({ + 'video-left': { durationInFrames: 60, sourceEnd: 60 }, + 'video-right': { from: 60, durationInFrames: 60, sourceStart: 0 }, + 'audio-left': { durationInFrames: 60, sourceEnd: 60 }, + 'audio-right': { from: 60, durationInFrames: 60, sourceStart: 0 }, + }) + }) + + it('exposes slider semantics without nesting a button and supports one-frame arrow nudges', () => { + const items = makeVideoPair() + resetStores(makeTracks(), [...items]) + useSelectionStore.getState().selectItems(['video-right']) + const view = renderItems(items) + const videoRight = view.container.querySelector('[data-item-id="video-right"]')! + const handle = within(videoRight).getByRole('slider', { name: /rolling trim/i }) + + expect(handle).toHaveAttribute('tabindex', '0') + expect(handle).toHaveAttribute('aria-valuenow', '60') + expect(handle.closest('button')).toBeNull() + + fireEvent.keyDown(handle, { key: 'ArrowRight' }) + + expect(useItemsStore.getState().itemById).toMatchObject({ + 'video-left': { durationInFrames: 61 }, + 'video-right': { from: 61, durationInFrames: 59 }, + }) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) + + it('rejects the whole gesture when a linked participant is effectively locked', () => { + const items = makeLinkedFixtureItems() + resetStores(makeTracks('locked-group'), items) + useSelectionStore.getState().selectItems(['video-right', 'audio-right']) + const view = renderItems(items) + const videoRight = view.container.querySelector('[data-item-id="video-right"]')! + const handle = within(videoRight).getByRole('slider', { name: /rolling trim/i }) + const before = { + items: itemSnapshot(), + selection: [...useSelectionStore.getState().selectedItemIds], + undoDepth: useTimelineCommandStore.getState().undoStack.length, + redoDepth: useTimelineCommandStore.getState().redoStack.length, + dirty: useTimelineSettingsStore.getState().isDirty, + } + + dragOneFrame(handle) + + expect(useItemsStore.getState().items).toEqual(before.items) + expect(useSelectionStore.getState().selectedItemIds).toEqual(before.selection) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(before.undoDepth) + expect(useTimelineCommandStore.getState().redoStack).toHaveLength(before.redoDepth) + expect(useTimelineSettingsStore.getState().isDirty).toBe(before.dirty) + }) + + it('keeps the ordinary isolated edge trim handle hit-testable and commits only that edge', () => { + const clip = makeTimelineVideoItem({ id: 'isolated' }) + resetStores(makeTracks(), [clip]) + useSelectionStore.getState().selectItems(['isolated']) + const view = renderItems([clip]) + const root = view.container.querySelector('[data-item-id="isolated"]')! + + expect(within(root).queryByRole('slider', { name: /rolling trim/i })).toBeNull() + fireEvent.mouseMove(root, { clientX: CLIP_FRAMES, clientY: 40 }) + const ordinaryHandle = root.querySelector('[data-trim-handle="end"]')! + expect(ordinaryHandle).toBeTruthy() + expect(ordinaryHandle).not.toHaveClass('pointer-events-none') + + dragOneFrame(ordinaryHandle) + + expect(useItemsStore.getState().itemById.isolated).toMatchObject({ + from: 0, + durationInFrames: 61, + sourceStart: 0, + sourceEnd: 61, + }) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) +}) diff --git a/src/features/timeline/components/timeline-item/index.tsx b/src/features/timeline/components/timeline-item/index.tsx index b8ad43ce2..63ae71de4 100644 --- a/src/features/timeline/components/timeline-item/index.tsx +++ b/src/features/timeline/components/timeline-item/index.tsx @@ -3,6 +3,7 @@ import type { TimelineItem as TimelineItemType } from '@/types/timeline' import { useShallow } from 'zustand/react/shallow' import { useTimelineStore } from '../../stores/timeline-store' import { useItemsStore } from '../../stores/items-store' +import { rollingTrimItems } from '../../stores/actions/item-actions' import { selectReplaceableCaptionClipIds } from '../../stores/items-store-indexes' import { useKeyframesStore } from '../../stores/keyframes-store' import { useEffectDropPreviewStore } from '../../stores/effect-drop-preview-store' @@ -62,6 +63,7 @@ import { useLinkedSyncPreview } from './use-linked-sync-preview' import { useClipReadoutLabels } from './use-clip-readout-labels' import { useTimelineItemPointerHandlers } from './use-timeline-item-pointer-handlers' import { ClipFloatingLayer } from './clip-floating-layer' +import { SharedRollingTrimHandle } from './shared-rolling-trim-handle' const EMPTY_SEGMENT_OVERLAYS = [] as const const EMPTY_LINKED_ITEMS: TimelineItemType[] = [] @@ -634,6 +636,46 @@ export const TimelineItem = memo(function TimelineItem({ hasGapBefore, gapBeforeFrames, } = useClipNeighbors(item) + const rightNeighborId = rightNeighbor?.id ?? null + const rightNeighborSelected = useSelectionStore( + useCallback( + (state) => rightNeighborId !== null && state.selectedItemIdSet.has(rightNeighborId), + [rightNeighborId], + ), + ) + const canExposeSharedRollingHandle = + !trackLocked && + (activeTool === 'select' || activeTool === 'trim-edit') && + (!isAnyDragActiveRef.current || isTrimming) + // The clip under the pointer owns a hovered cut. For selection-only display, + // the incoming/right clip owns the cut when both neighbors are selected so + // one physical edit point never gets duplicate hit targets. + const showSharedRollingStart = + canExposeSharedRollingHandle && + leftNeighbor !== null && + (smartTrimIntent === 'roll-start' || (isSelected && rollHoverEdge !== 'start')) + const showSharedRollingEnd = + canExposeSharedRollingHandle && + rightNeighbor !== null && + (smartTrimIntent === 'roll-end' || + (isSelected && !rightNeighborSelected && rollHoverEdge !== 'end')) + const hasSharedRollingHandle = showSharedRollingStart || showSharedRollingEnd + + const handleSharedRollingTrimStart = useCallback( + (event: React.MouseEvent, handle: 'start' | 'end') => { + handleTrimStart(event, handle, { forcedMode: 'rolling' }) + }, + [handleTrimStart], + ) + const handleSharedRollingKeyboardStep = useCallback( + (handle: 'start' | 'end', deltaFrames: number) => { + const left = handle === 'start' ? leftNeighbor : item + const right = handle === 'start' ? item : rightNeighbor + if (!left || !right) return + rollingTrimItems(left.id, right.id, deltaFrames) + }, + [item, leftNeighbor, rightNeighbor], + ) const { getCanJoinSelected, @@ -949,7 +991,10 @@ export const TimelineItem = memo(function TimelineItem({ // paint-containment boundary that Layerize must revisit on every // real-width zoom step. Full-detail buffered clips keep browser // layout/paint skipping while offscreen. - contain: useCompactClipShell ? 'layout style' : 'layout style paint', + contain: + useCompactClipShell || hasSharedRollingHandle + ? 'layout style' + : 'layout style paint', contentVisibility: useCompactClipShell ? 'visible' : 'auto', '--timeline-audio-volume-line-y': `${ item.type === 'audio' && audioVolumeEdit !== null @@ -1216,6 +1261,31 @@ export const TimelineItem = memo(function TimelineItem({ /> )} + {showSharedRollingStart && leftNeighbor && ( + + )} + {showSharedRollingEnd && rightNeighbor && ( + + )} + {/* Rate stretch handles */} {!useCompactClipShell && ( void + onKeyboardStep: (handle: 'start' | 'end', deltaFrames: number) => void +} + +/** + * A compact rolling-edit target centered on a real cut. It occupies only the + * middle of the clip edge so the ordinary full-height trim target remains + * available above and below it. + */ +export const SharedRollingTrimHandle = memo(function SharedRollingTrimHandle({ + edge, + editPointFrame, + minFrame, + maxFrame, + leftLabel, + rightLabel, + onMouseDown, + onKeyboardStep, +}: SharedRollingTrimHandleProps) { + const label = `Rolling trim between ${leftLabel} and ${rightLabel}` + + return ( +
onMouseDown(event, edge)} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + }} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return + event.preventDefault() + event.stopPropagation() + onKeyboardStep(edge, event.key === 'ArrowLeft' ? -1 : 1) + }} + > +
+ ) +}) diff --git a/src/features/timeline/components/timeline-item/trim-handles.tsx b/src/features/timeline/components/timeline-item/trim-handles.tsx index f1a01749a..62b23fc92 100644 --- a/src/features/timeline/components/timeline-item/trim-handles.tsx +++ b/src/features/timeline/components/timeline-item/trim-handles.tsx @@ -150,6 +150,7 @@ export const TrimHandles = memo(function TrimHandles({
Date: Thu, 27 Aug 2026 13:53:13 -0700 Subject: [PATCH 61/64] fix(timeline): add keyboard-accessible clip roots (cherry picked from commit c9494a515e27074482bf7ba8bbd1998f4fc8ced3) --- src/config/hotkeys-dom-guard.test.ts | 20 ++ src/config/hotkeys.ts | 1 + .../components/timeline-content.test.tsx | 125 ++++++++ .../timeline/components/timeline-content.tsx | 179 +++++++++-- .../components/timeline-item-hit-target.tsx | 2 +- .../components/timeline-item/index.tsx | 265 +++++++++------- .../timeline-item-accessibility.test.tsx | 288 ++++++++++++++++++ ...se-timeline-item-pointer-handlers.test.tsx | 16 + .../shortcuts/use-playback-shortcuts.test.tsx | 25 ++ 9 files changed, 779 insertions(+), 142 deletions(-) create mode 100644 src/features/timeline/components/timeline-item/timeline-item-accessibility.test.tsx diff --git a/src/config/hotkeys-dom-guard.test.ts b/src/config/hotkeys-dom-guard.test.ts index 8419e5721..2538c2f03 100644 --- a/src/config/hotkeys-dom-guard.test.ts +++ b/src/config/hotkeys-dom-guard.test.ts @@ -78,6 +78,7 @@ describe('global shortcut DOM guards', () => { ['native link', 'Project'], ['summary', '
Details
'], ['button role', '
Run
'], + ['scrollbar role', '
'], ['menuitem role', ''], ])('guards an interactive %s outside dialogs', (_name, markup) => { expect(dispatchFrom(markup, '#control', 'k')).toEqual({ @@ -92,6 +93,15 @@ describe('global shortcut DOM guards', () => { ).toEqual({ captureSawEvent: true, defaultPrevented: false }) }) + it('guards plain descendants of a native dialog', () => { + expect( + dispatchFrom('Message', '#control', 'j'), + ).toEqual({ + captureSawEvent: true, + defaultPrevented: false, + }) + }) + it('uses the nearest contenteditable value for inherited editing and false islands', () => { expect( dispatchFrom( @@ -117,6 +127,16 @@ describe('global shortcut DOM guards', () => { }) }) + it('preserves explicit canvas opt-in inside a native dialog', () => { + expect( + dispatchFrom( + '', + '#timeline', + 'k', + ), + ).toEqual({ captureSawEvent: true, defaultPrevented: true }) + }) + it('allows an explicitly opted-in dialog control', () => { const result = dispatchFrom( '
', diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index 976db12ef..862123dac 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -1201,6 +1201,7 @@ const INTERACTIVE_CONTROL_SELECTOR = [ '[role="tab"]', '[role="treeitem"]', '[role="slider"]', + '[role="scrollbar"]', '[role="spinbutton"]', '[role="textbox"]', '[role="searchbox"]', diff --git a/src/features/timeline/components/timeline-content.test.tsx b/src/features/timeline/components/timeline-content.test.tsx index 75ad99df0..ed36031ba 100644 --- a/src/features/timeline/components/timeline-content.test.tsx +++ b/src/features/timeline/components/timeline-content.test.tsx @@ -1319,4 +1319,129 @@ describe('TimelineContent playback selection behavior', () => { expect(usePlaybackStore.getState().previewFrame).not.toBe(releaseFrame) animationFrameSpy.mockRestore() }) + + it.each([ + ['video only', 300, 100, ['Video track section scrollbar']], + ['audio only', 100, 300, ['Audio track section scrollbar']], + ['both sections', 300, 300, ['Video track section scrollbar', 'Audio track section scrollbar']], + ['neither section', 100, 100, []], + ])( + 'renders focusable custom scrollbars for %s overflow', + (_name, videoScrollHeight, audioScrollHeight, expectedLabels) => { + const tracks: TimelineTrack[] = [ + VIDEO_TRACK, + { ...VIDEO_TRACK, id: 'track-audio-1', name: 'A1', kind: 'audio', order: 1 }, + ] + useTimelineStore.setState({ tracks, items: [] }) + const clientHeightSpy = vi + .spyOn(HTMLElement.prototype, 'clientHeight', 'get') + .mockImplementation(function (this: HTMLElement) { + return this.hasAttribute('data-track-section-scroll') ? 100 : 12 + }) + const scrollHeightSpy = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockImplementation(function (this: HTMLElement) { + const section = this.getAttribute('data-track-section-scroll') + if (section === 'video') return videoScrollHeight + if (section === 'audio') return audioScrollHeight + return 12 + }) + const videoTracksScrollRef = createRef() + const audioTracksScrollRef = createRef() + + const { queryAllByRole, unmount } = render( + , + ) + const scrollbars = queryAllByRole('scrollbar') + + expect(scrollbars.map((scrollbar) => scrollbar.getAttribute('aria-label'))).toEqual( + expectedLabels, + ) + for (const scrollbar of scrollbars) { + expect(scrollbar).toHaveAttribute('tabindex', '0') + expect(scrollbar).toHaveAttribute('aria-orientation', 'vertical') + expect(scrollbar).toHaveAttribute('aria-valuemin', '0') + expect(scrollbar).toHaveAttribute('aria-valuemax', '100') + scrollbar.focus() + expect(scrollbar).toHaveFocus() + } + + unmount() + clientHeightSpy.mockRestore() + scrollHeightSpy.mockRestore() + }, + ) + + it('bounds each overflowing split scrollbar for Arrow, Page, Home, and End keys', () => { + const tracks: TimelineTrack[] = [ + VIDEO_TRACK, + { ...VIDEO_TRACK, id: 'track-audio-1', name: 'A1', kind: 'audio', order: 1 }, + ] + useTimelineStore.setState({ tracks, items: [] }) + const clientHeightSpy = vi + .spyOn(HTMLElement.prototype, 'clientHeight', 'get') + .mockImplementation(function (this: HTMLElement) { + return this.hasAttribute('data-track-section-scroll') ? 100 : 12 + }) + const scrollHeightSpy = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockImplementation(function (this: HTMLElement) { + return this.hasAttribute('data-track-section-scroll') ? 300 : 12 + }) + const videoTracksScrollRef = createRef() + const audioTracksScrollRef = createRef() + + const { container, getByRole, unmount } = render( + , + ) + const videoContainer = container.querySelector( + '[data-track-section-scroll="video"]', + )! + const audioContainer = container.querySelector( + '[data-track-section-scroll="audio"]', + )! + const videoScrollbar = getByRole('scrollbar', { + name: 'Video track section scrollbar', + }) + const audioScrollbar = getByRole('scrollbar', { + name: 'Audio track section scrollbar', + }) + + fireEvent.keyDown(videoScrollbar, { key: 'ArrowDown' }) + expect(videoContainer.scrollTop).toBe(40) + expect(audioContainer.scrollTop).toBe(0) + fireEvent.keyDown(videoScrollbar, { key: 'PageDown' }) + expect(videoContainer.scrollTop).toBe(130) + fireEvent.keyDown(videoScrollbar, { key: 'End' }) + expect(videoContainer.scrollTop).toBe(200) + expect(videoScrollbar).toHaveAttribute('aria-valuenow', '100') + fireEvent.keyDown(videoScrollbar, { key: 'ArrowDown' }) + expect(videoContainer.scrollTop).toBe(200) + fireEvent.keyDown(videoScrollbar, { key: 'Home' }) + expect(videoContainer.scrollTop).toBe(0) + fireEvent.keyDown(videoScrollbar, { key: 'PageUp' }) + expect(videoContainer.scrollTop).toBe(0) + + fireEvent.keyDown(audioScrollbar, { key: 'ArrowDown' }) + expect(audioContainer.scrollTop).toBe(40) + expect(videoContainer.scrollTop).toBe(0) + + unmount() + clientHeightSpy.mockRestore() + scrollHeightSpy.mockRestore() + }) }) diff --git a/src/features/timeline/components/timeline-content.tsx b/src/features/timeline/components/timeline-content.tsx index feb02c7e8..5b7d71092 100644 --- a/src/features/timeline/components/timeline-content.tsx +++ b/src/features/timeline/components/timeline-content.tsx @@ -242,6 +242,7 @@ function TrackSectionScrollbarOverlay({ height: number scrollRef?: React.RefObject }) { + const scrollbarRef = useRef(null) const railRef = useRef(null) const thumbRef = useRef(null) const dragOffsetRef = useRef(0) @@ -251,6 +252,19 @@ function TrackSectionScrollbarOverlay({ const layoutRef = useRef({ railHeight: 0, thumbHeight: 0, maxThumbTravel: 0, overflowHeight: 0 }) const railInset = 4 + const updateAriaValue = useCallback(() => { + const element = scrollRef?.current + const scrollbar = scrollbarRef.current + if (!element || !scrollbar) return + + const overflowHeight = Math.max(0, element.scrollHeight - element.clientHeight) + const value = + overflowHeight > 0 + ? Math.round(Math.max(0, Math.min(1, element.scrollTop / overflowHeight)) * 100) + : 0 + scrollbar.setAttribute('aria-valuenow', String(value)) + }, [scrollRef]) + // Compute layout metrics and update thumb size/position imperatively const updateThumbLayout = useCallback(() => { const element = scrollRef?.current @@ -274,7 +288,8 @@ function TrackSectionScrollbarOverlay({ thumb.style.height = `${thumbHeight}px` thumb.style.top = `${railInset + thumbTop}px` thumb.style.display = thumbHeight > 0 ? '' : 'none' - }, [height, scrollRef]) + updateAriaValue() + }, [height, scrollRef, updateAriaValue]) // Update thumb position only (cheaper — called on scroll) const updateThumbPosition = useCallback(() => { @@ -283,11 +298,12 @@ function TrackSectionScrollbarOverlay({ if (!element || !thumb) return const { maxThumbTravel, overflowHeight } = layoutRef.current + updateAriaValue() if (overflowHeight <= 0 || maxThumbTravel <= 0) return const thumbTop = (element.scrollTop / overflowHeight) * maxThumbTravel thumb.style.top = `${railInset + thumbTop}px` - }, [scrollRef]) + }, [scrollRef, updateAriaValue]) // Listen for scroll + resize, update thumb imperatively (no setState) useEffect(() => { @@ -344,8 +360,9 @@ function TrackSectionScrollbarOverlay({ ) scrollElement.scrollTop = (nextThumbTop / maxThumbTravel) * overflowHeight + updateThumbPosition() }, - [scrollRef], + [scrollRef, updateThumbPosition], ) const stopDragging = useCallback(() => { @@ -399,12 +416,54 @@ function TrackSectionScrollbarOverlay({ [stopDragging, syncScrollFromClientY], ) + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const scrollElement = scrollRef?.current + if (!scrollElement) return + + const overflowHeight = Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight) + const lineStep = 40 + const pageStep = Math.max(1, scrollElement.clientHeight * 0.9) + let nextScrollTop: number + + switch (event.key) { + case 'ArrowUp': + nextScrollTop = scrollElement.scrollTop - lineStep + break + case 'ArrowDown': + nextScrollTop = scrollElement.scrollTop + lineStep + break + case 'PageUp': + nextScrollTop = scrollElement.scrollTop - pageStep + break + case 'PageDown': + nextScrollTop = scrollElement.scrollTop + pageStep + break + case 'Home': + nextScrollTop = 0 + break + case 'End': + nextScrollTop = overflowHeight + break + default: + return + } + + event.preventDefault() + event.stopPropagation() + scrollElement.scrollTop = Math.max(0, Math.min(overflowHeight, nextScrollTop)) + updateThumbPosition() + }, + [scrollRef, updateThumbPosition], + ) + if (height <= 0) { return null } return (
+ audioTracksScrollRef?: React.RefObject + allTracksScrollRef?: React.RefObject +}) { + if (!anyOverflow) return null + + return ( +
+
+ {hasTrackSections ? ( + <> + {videoSectionHasOverflow ? ( + + ) : ( +