{
+ it('uses the registered shared media URL instead of a page-owned throwaway blob URL', () => {
+ const source = readFileSync(new URL('./audio-meter-panel.tsx', import.meta.url), 'utf8')
+
+ expect(source).toContain(
+ "import { resolveMediaUrl } from '@/features/editor/deps/media-library'",
+ )
+ expect(source).toContain('resolveMediaUrl(mediaId)')
+ expect(source).not.toContain('.getMediaBlobUrl(mediaId)')
+ expect(source).not.toContain('URL.revokeObjectURL(blobUrl)')
+ })
+})
diff --git a/src/features/editor/components/editor.test.tsx b/src/features/editor/components/editor.test.tsx
index 11bf87910..b3c35a122 100644
--- a/src/features/editor/components/editor.test.tsx
+++ b/src/features/editor/components/editor.test.tsx
@@ -1,5 +1,5 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
-import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
const mocks = vi.hoisted(() => ({
@@ -30,6 +30,9 @@ const mocks = vi.hoisted(() => ({
initTransitionChainSubscription: vi.fn(() => vi.fn()),
createProjectUpgradeBackup: vi.fn(),
resizablePanelGroup: vi.fn(),
+ editorWidth: 1440,
+ setLeftSidebarOpen: vi.fn(),
+ setRightSidebarOpen: vi.fn(),
}))
vi.mock('@tanstack/react-router', () => ({
@@ -69,15 +72,21 @@ vi.mock('@/app/error-boundary', () => ({
}))
vi.mock('./toolbar', () => ({
- Toolbar: () =>
,
+ Toolbar: ({ compact }: { compact?: boolean }) => (
+
+ ),
}))
vi.mock('./media-sidebar', () => ({
- MediaSidebar: () =>
,
+ MediaSidebar: ({ mobileDrawer }: { mobileDrawer?: boolean }) => (
+
+ ),
}))
vi.mock('./properties-sidebar', () => ({
- PropertiesSidebar: () =>
,
+ PropertiesSidebar: ({ mobileDrawer }: { mobileDrawer?: boolean }) => (
+
+ ),
}))
vi.mock('./preview-area', () => ({
@@ -106,11 +115,17 @@ vi.mock('./interaction-lock-region', () => ({
}))
vi.mock('./audio-meter-panel', () => ({
- AudioMeterPanel: () =>
,
+ AudioMeterPanel: ({ mobileDrawer }: { mobileDrawer?: boolean }) => (
+
+ ),
}))
vi.mock('@/features/editor/deps/timeline-ui', () => ({
- importTimeline: vi.fn().mockResolvedValue({ Timeline: () =>
}),
+ importTimeline: vi.fn().mockResolvedValue({
+ Timeline: ({ compact }: { compact?: boolean }) => (
+
+ ),
+ }),
importBentoLayoutDialog: vi.fn().mockResolvedValue({ BentoLayoutDialog: () => null }),
importFillerRemovalDialog: vi.fn().mockResolvedValue({ FillerRemovalDialog: () => null }),
importReverseConformDialog: vi.fn().mockResolvedValue({ ReverseConformDialog: () => null }),
@@ -149,6 +164,7 @@ vi.mock('../hooks/use-auto-save', () => ({
}))
vi.mock('@/features/editor/deps/timeline-hooks', () => ({
+ useHostTimelineShortcuts: vi.fn(),
useTimelineShortcuts: vi.fn(),
useTransitionBreakageNotifications: vi.fn(),
}))
@@ -234,6 +250,9 @@ vi.mock('@/shared/state/editor', () => ({
propertiesFullColumn: boolean
mediaFullColumn: boolean
workspace: string
+ sourcePreviewMediaId: string | null
+ setLeftSidebarOpen: typeof mocks.setLeftSidebarOpen
+ setRightSidebarOpen: typeof mocks.setRightSidebarOpen
}) => unknown,
) =>
selector({
@@ -241,6 +260,9 @@ vi.mock('@/shared/state/editor', () => ({
propertiesFullColumn: mocks.editorState.propertiesFullColumn,
mediaFullColumn: mocks.editorState.mediaFullColumn,
workspace: mocks.editorState.workspace,
+ sourcePreviewMediaId: null,
+ setLeftSidebarOpen: mocks.setLeftSidebarOpen,
+ setRightSidebarOpen: mocks.setRightSidebarOpen,
}),
}))
@@ -516,3 +538,135 @@ describe('editor shell height ownership', () => {
expect(standalone).toContainElement(screen.getByRole('application'))
})
})
+
+describe('editor responsive shell', () => {
+ const project = { id: 'project-1', name: 'Project', width: 1920, height: 1080, fps: 30 }
+ const migration = { storedSchemaVersion: 9, currentSchemaVersion: 9, requiresUpgrade: false }
+
+ beforeAll(() => {
+ vi.stubGlobal('requestIdleCallback', (callback: IdleRequestCallback) => {
+ callback({ didTimeout: false, timeRemaining: () => 50 } as IdleDeadline)
+ return 1
+ })
+ vi.stubGlobal('cancelIdleCallback', vi.fn())
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class ResizeObserverMock {
+ private readonly callback: ResizeObserverCallback
+
+ constructor(callback: ResizeObserverCallback) {
+ this.callback = callback
+ }
+
+ observe(target: Element) {
+ this.callback(
+ [
+ {
+ target,
+ contentRect: { width: mocks.editorWidth },
+ borderBoxSize: [{ inlineSize: mocks.editorWidth, blockSize: 844 }],
+ } as unknown as ResizeObserverEntry,
+ ],
+ this as unknown as ResizeObserver,
+ )
+ }
+
+ disconnect() {}
+ unobserve() {}
+ },
+ )
+ })
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.editorWidth = 1440
+ mocks.editorState.workspace = 'edit'
+ mocks.editorState.propertiesFullColumn = false
+ mocks.editorState.mediaFullColumn = false
+ mocks.loadTimeline.mockResolvedValue(undefined)
+ mocks.loadMediaItems.mockResolvedValue(undefined)
+ })
+
+ afterEach(() => cleanup())
+
+ it('starts 390px standalone with core surfaces visible and persisted desktop panels untouched', async () => {
+ mocks.editorWidth = 390
+
+ render(
)
+
+ const shell = screen.getByRole('application')
+ await waitFor(() => expect(shell).toHaveAttribute('data-editor-layout', 'mobile'))
+ expect(screen.getByTestId('toolbar')).toHaveAttribute('data-compact', 'true')
+ expect(await screen.findByTestId('timeline')).toHaveAttribute('data-compact', 'true')
+ expect(screen.queryByTestId('media-sidebar')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('properties-sidebar')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('audio-meter-panel')).not.toBeInTheDocument()
+ expect(mocks.setLeftSidebarOpen).not.toHaveBeenCalled()
+ expect(mocks.setRightSidebarOpen).not.toHaveBeenCalled()
+ expect(mocks.syncSidebarLayout).not.toHaveBeenCalled()
+ })
+
+ it('opens each 390px panel as a focus-restoring drawer and closes it with Escape', async () => {
+ mocks.editorWidth = 390
+ render(
)
+ await waitFor(() =>
+ expect(screen.getByRole('application')).toHaveAttribute('data-editor-layout', 'mobile'),
+ )
+
+ for (const name of ['Media', 'Properties', 'Meters']) {
+ const trigger = screen.getByRole('button', { name })
+ trigger.focus()
+ fireEvent.click(trigger)
+
+ const drawer = await screen.findByRole('dialog', { name })
+ expect(drawer).toBeInTheDocument()
+ expect(drawer).toContainElement(document.activeElement as HTMLElement)
+ const expectedTestId =
+ name === 'Media'
+ ? 'media-sidebar'
+ : name === 'Properties'
+ ? 'properties-sidebar'
+ : 'audio-meter-panel'
+ expect(screen.getByTestId(expectedTestId)).toHaveAttribute('data-mobile-drawer', 'true')
+
+ fireEvent.keyDown(document, { key: 'Escape' })
+ await waitFor(() => expect(screen.queryByRole('dialog', { name })).not.toBeInTheDocument())
+ await waitFor(() => expect(trigger).toHaveFocus())
+ }
+ })
+
+ it('uses the same 390px layout inside a definite-height host surface', async () => {
+ mocks.editorWidth = 390
+ const hostRuntime = { mountStores: vi.fn(), unmountStores: vi.fn() }
+
+ render(
+
+
+
,
+ )
+
+ await waitFor(() =>
+ expect(screen.getByRole('application')).toHaveAttribute('data-editor-layout', 'mobile'),
+ )
+ expect(screen.getByTestId('toolbar')).toHaveAttribute('data-compact', 'true')
+ expect(await screen.findByTestId('timeline')).toHaveAttribute('data-compact', 'true')
+ expect(hostRuntime.mountStores).toHaveBeenCalledTimes(1)
+ })
+
+ it('preserves the existing 320/288/84 desktop layout branch at 1440px', async () => {
+ render(
)
+
+ const shell = screen.getByRole('application')
+ await waitFor(() => expect(shell).toHaveAttribute('data-editor-layout', 'desktop'))
+ expect(screen.getByTestId('toolbar')).toHaveAttribute('data-compact', 'false')
+ expect(await screen.findByTestId('timeline')).toHaveAttribute('data-compact', 'false')
+ expect(screen.getByTestId('media-sidebar')).toHaveAttribute('data-mobile-drawer', 'false')
+ expect(screen.getByTestId('properties-sidebar')).toHaveAttribute('data-mobile-drawer', 'false')
+ expect(screen.getByTestId('audio-meter-panel')).toHaveAttribute('data-mobile-drawer', 'false')
+ })
+})
diff --git a/src/features/editor/components/editor.tsx b/src/features/editor/components/editor.tsx
index 22bd989ac..bf4924977 100644
--- a/src/features/editor/components/editor.tsx
+++ b/src/features/editor/components/editor.tsx
@@ -13,6 +13,12 @@ import { PreviewArea } from './preview-area'
import { MotionPreviewArea, MotionTimelineDock } from './compose-workspace/compose-layout'
import { InteractionLockRegion } from './interaction-lock-region'
import { AudioMeterPanel } from './audio-meter-panel'
+import {
+ MobileEditorDrawer,
+ MobileEditorPanelBar,
+ type MobileEditorPanel,
+} from './mobile-editor-layout'
+import { MOBILE_EDITOR_MAX_WIDTH, useCompactEditorLayout } from '../hooks/use-compact-editor-layout'
import {
importTimeline,
importBentoLayoutDialog,
@@ -465,11 +471,17 @@ export const LoadedEditor = memo(function LoadedEditor({
const syncSidebarLayout = useEditorStore((s) => s.syncSidebarLayout)
const propertiesFullColumn = useEditorStore((s) => s.propertiesFullColumn)
const mediaFullColumn = useEditorStore((s) => s.mediaFullColumn)
+ const sourcePreviewMediaId = useEditorStore((s) => s.sourcePreviewMediaId)
const workspace = useEditorStore((s) => s.workspace)
const isMaskEditingActive = useMaskEditorStore((s) => s.isEditing)
const hasRefreshedMigrationStateRef = useRef(false)
const localRefreshMigrationRef = useRef<(() => Promise
) | null>(null)
const timelinePanelRef = useRef(null)
+ const editorRootRef = useRef(null)
+ const compact = useCompactEditorLayout(editorRootRef)
+ const [mobilePanel, setMobilePanel] = useState(null)
+ const mobilePanelTriggerRef = useRef(null)
+ const previousSourcePreviewMediaIdRef = useRef(sourcePreviewMediaId)
const previousWorkspaceRef = useRef(workspace)
// Guard against concurrent saves (e.g., spamming Ctrl+S)
@@ -620,8 +632,34 @@ export const LoadedEditor = memo(function LoadedEditor({
])
useEffect(() => {
+ const measuredWidth = editorRootRef.current?.getBoundingClientRect().width
+ if (compact || (measuredWidth !== undefined && measuredWidth <= MOBILE_EDITOR_MAX_WIDTH)) return
syncSidebarLayout(editorLayout)
- }, [editorLayout, syncSidebarLayout])
+ }, [compact, editorLayout, syncSidebarLayout])
+
+ useEffect(() => {
+ const previousSource = previousSourcePreviewMediaIdRef.current
+ previousSourcePreviewMediaIdRef.current = sourcePreviewMediaId
+ if (compact && sourcePreviewMediaId && sourcePreviewMediaId !== previousSource) {
+ setMobilePanel('source')
+ } else if (!sourcePreviewMediaId && mobilePanel === 'source') {
+ setMobilePanel(null)
+ }
+ }, [compact, mobilePanel, sourcePreviewMediaId])
+
+ useEffect(() => {
+ if (!compact) setMobilePanel(null)
+ }, [compact])
+
+ const openMobilePanel = useCallback((panel: MobileEditorPanel) => {
+ mobilePanelTriggerRef.current =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null
+ setMobilePanel(panel)
+ }, [])
+
+ const closeMobilePanel = useCallback(() => {
+ setMobilePanel(null)
+ }, [])
// Apply the per-workspace timeline split when switching workspaces:
// snapshot the outgoing workspace's split, then restore the incoming
@@ -751,14 +789,16 @@ export const LoadedEditor = memo(function LoadedEditor({
return (
{!hostRuntime &&
}
{!hostRuntime &&
}
@@ -778,13 +818,21 @@ export const LoadedEditor = memo(function LoadedEditor({
onExportBundle={hostRuntime ? undefined : handleExportBundle}
onOpenRenderQueue={hostRuntime ? undefined : handleOpenRenderQueue}
renderQueueCount={hostRuntime ? 0 : renderQueueActiveCount}
+ compact={compact}
/>
+ {compact && (
+
+ )}
+
{/* Main Layout: Full-height sidebar + vertical split */}
{/* Left Sidebar - Media Library (full column mode) */}
- {mediaFullColumn && !hidesDefaultSidebars && (
+ {!compact && mediaFullColumn && !hidesDefaultSidebars && (
@@ -797,7 +845,7 @@ export const LoadedEditor = memo(function LoadedEditor({
@@ -828,7 +876,7 @@ export const LoadedEditor = memo(function LoadedEditor({
>
{/* Left Sidebar - Media Library (inline with preview) */}
- {!mediaFullColumn && (
+ {!compact && !mediaFullColumn && (
@@ -841,12 +889,12 @@ export const LoadedEditor = memo(function LoadedEditor({
{isMotionWorkspace ? (
) : (
-
+
)}
{/* Right Sidebar - Properties (inline with preview) */}
- {!propertiesFullColumn && (
+ {!compact && !propertiesFullColumn && (
@@ -876,11 +924,11 @@ export const LoadedEditor = memo(function LoadedEditor({
) : (
-
+
)}
-
+ {!compact && }
@@ -889,7 +937,7 @@ export const LoadedEditor = memo(function LoadedEditor({
)}
{/* Right Sidebar - Properties (full column mode) */}
- {propertiesFullColumn && !hidesDefaultSidebars && (
+ {!compact && propertiesFullColumn && !hidesDefaultSidebars && (
@@ -898,6 +946,20 @@ export const LoadedEditor = memo(function LoadedEditor({
)}
+ {compact && (
+
{
+ useEditorStore.getState().setSourcePreviewMediaId(null)
+ closeMobilePanel()
+ }}
+ />
+ )}
+
{!hostRuntime && (
{/* Export Dialog */}
diff --git a/src/features/editor/components/media-sidebar.tsx b/src/features/editor/components/media-sidebar.tsx
index 5d57f3ae8..edd8ce02b 100644
--- a/src/features/editor/components/media-sidebar.tsx
+++ b/src/features/editor/components/media-sidebar.tsx
@@ -52,6 +52,7 @@ import {
} from '@/features/editor/deps/timeline-utils'
import { addAdjustmentLayer } from '../utils/add-adjustment-layer'
import type { TextItem, ShapeItem, ShapeType } from '@/types/timeline'
+import type { EditorSidebarTab } from '@/config/editor-workspaces'
import { useMaskEditorStore } from '@/features/editor/deps/preview'
import type { VisualEffect, GpuEffect } from '@/types/effects'
import { EFFECT_PRESETS } from '@/types/effects'
@@ -288,7 +289,127 @@ const TEXT_TEMPLATE_GROUPS: ReadonlyArray<{
const DEFAULT_TEXT_TEMPLATE_LABEL = 'Text'
const ADD_TEXT_TEMPLATE_LABEL = 'Add Text'
-export const MediaSidebar = memo(function MediaSidebar() {
+interface MediaSidebarProps {
+ mobileDrawer?: boolean
+ onRequestClose?: () => void
+}
+
+function resolveMediaSidebarPresentation({
+ mobileDrawer,
+ leftSidebarOpen,
+ sidebarWidth,
+ drawerWidth,
+ onRequestClose,
+ toggleLeftSidebar,
+}: {
+ mobileDrawer: boolean
+ leftSidebarOpen: boolean
+ sidebarWidth: number
+ drawerWidth: number
+ onRequestClose?: () => void
+ toggleLeftSidebar: () => void
+}) {
+ if (mobileDrawer) {
+ return {
+ panelOpen: true,
+ panelWidth: drawerWidth,
+ mode: 'drawer' as const,
+ collapsePanel: onRequestClose ?? toggleLeftSidebar,
+ }
+ }
+ return {
+ panelOpen: leftSidebarOpen,
+ panelWidth: sidebarWidth,
+ mode: 'inline' as const,
+ collapsePanel: toggleLeftSidebar,
+ }
+}
+
+function selectMediaSidebarCategory({
+ id,
+ mobileDrawer,
+ activeTab,
+ leftSidebarOpen,
+ setActiveTab,
+ toggleLeftSidebar,
+ triggerPreviews,
+}: {
+ id: EditorSidebarTab
+ mobileDrawer: boolean
+ activeTab: EditorSidebarTab
+ leftSidebarOpen: boolean
+ setActiveTab: (tab: EditorSidebarTab) => void
+ toggleLeftSidebar: () => void
+ triggerPreviews: () => void
+}) {
+ if (mobileDrawer) {
+ setActiveTab(id)
+ if (id === 'effects') triggerPreviews()
+ return
+ }
+ if (activeTab === id && leftSidebarOpen) {
+ toggleLeftSidebar()
+ return
+ }
+ setActiveTab(id)
+ if (!leftSidebarOpen) toggleLeftSidebar()
+ if (id === 'effects') triggerPreviews()
+}
+
+function MediaSidebarDockButton({
+ mobileDrawer,
+ mediaFullColumn,
+ toggleMediaFullColumn,
+}: {
+ mobileDrawer: boolean
+ mediaFullColumn: boolean
+ toggleMediaFullColumn: () => void
+}) {
+ const { t } = useTranslation()
+ if (mobileDrawer) return null
+ const label = mediaFullColumn
+ ? t('editor.propertiesSidebar.dockToPreview')
+ : t('editor.propertiesSidebar.expandFullColumn')
+ return (
+
+ )
+}
+
+function MediaSidebarResizeHandle({
+ visible,
+ onMouseDown,
+}: {
+ visible: boolean
+ onMouseDown: (event: React.MouseEvent) => void
+}) {
+ if (!visible) return null
+ return (
+
+ )
+}
+
+export const MediaSidebar = memo(function MediaSidebar({
+ mobileDrawer = false,
+ onRequestClose,
+}: MediaSidebarProps) {
const { t } = useTranslation()
const hostMode = useEditorHostMode()
const { host } = useEditorHostContext()
@@ -306,6 +427,14 @@ export const MediaSidebar = memo(function MediaSidebar() {
const sidebarWidth = useEditorStore((s) => s.sidebarWidth)
const setSidebarWidth = useEditorStore((s) => s.setSidebarWidth)
const prefersReducedMotion = useReducedMotion()
+ const { panelOpen, panelWidth, mode, collapsePanel } = resolveMediaSidebarPresentation({
+ mobileDrawer,
+ leftSidebarOpen,
+ sidebarWidth,
+ drawerWidth: editorLayout.leftSidebarDefaultWidth,
+ onRequestClose,
+ toggleLeftSidebar,
+ })
const [aiTabActivated, setAiTabActivated] = useState(activeTab === 'ai')
// The Lottie panel hits an external API on mount, so keep it unmounted until
@@ -322,10 +451,10 @@ export const MediaSidebar = memo(function MediaSidebar() {
// order without yanking focus mid-animation; clear it immediately on open so
// the panel is interactive as it slides in. Mirrors the right sidebar's
// contentVisible/onAnimationComplete handoff.
- const [contentInert, setContentInert] = useState(!leftSidebarOpen)
+ const [contentInert, setContentInert] = useState(!panelOpen)
useEffect(() => {
- if (leftSidebarOpen) setContentInert(false)
- }, [leftSidebarOpen])
+ if (panelOpen) setContentInert(false)
+ }, [panelOpen])
// NOTE: the heavy media-library subtree is deliberately NOT gated behind
// Activity `hidden` when collapsed. React defers the hidden→visible reveal, so
@@ -608,7 +737,7 @@ export const MediaSidebar = memo(function MediaSidebar() {
}, [])
return (
-
+
{/* Vertical Category Bar */}
{/* Resize Handle */}
- {leftSidebarOpen && (
-
- )}
+
)
diff --git a/src/features/editor/components/mobile-editor-layout.tsx b/src/features/editor/components/mobile-editor-layout.tsx
new file mode 100644
index 000000000..6beda7098
--- /dev/null
+++ b/src/features/editor/components/mobile-editor-layout.tsx
@@ -0,0 +1,123 @@
+import { AudioLines, FolderOpen, MonitorPlay, SlidersHorizontal } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
+import { MediaSidebar } from './media-sidebar'
+import { PropertiesSidebar } from './properties-sidebar'
+import { AudioMeterPanel } from './audio-meter-panel'
+import { MobileSourceMonitor } from './preview-area'
+
+export type MobileEditorPanel = 'media' | 'properties' | 'source' | 'meters'
+
+interface MobileEditorPanelBarProps {
+ sourceAvailable: boolean
+ onOpen(panel: MobileEditorPanel): void
+}
+
+export function MobileEditorPanelBar({ sourceAvailable, onOpen }: MobileEditorPanelBarProps) {
+ const { t } = useTranslation()
+ const items = [
+ {
+ id: 'media' as const,
+ label: t('editor.mediaSidebar.media'),
+ icon: FolderOpen,
+ },
+ {
+ id: 'properties' as const,
+ label: t('editor.propertiesSidebar.title'),
+ icon: SlidersHorizontal,
+ },
+ ...(sourceAvailable
+ ? [
+ {
+ id: 'source' as const,
+ label: t('media.info.source'),
+ icon: MonitorPlay,
+ },
+ ]
+ : []),
+ {
+ id: 'meters' as const,
+ label: t('editor.audioMeters.meters'),
+ icon: AudioLines,
+ },
+ ]
+
+ return (
+
+ {items.map(({ id, label, icon: Icon }) => (
+ onOpen(id)}
+ >
+
+ {label}
+
+ ))}
+
+ )
+}
+
+interface MobileEditorDrawerProps {
+ panel: MobileEditorPanel | null
+ container: HTMLElement | null
+ sourceMediaId: string | null
+ restoreFocusTo: HTMLElement | null
+ onClose(): void
+ onCloseSource(): void
+}
+
+export function MobileEditorDrawer({
+ panel,
+ container,
+ sourceMediaId,
+ restoreFocusTo,
+ onClose,
+ onCloseSource,
+}: MobileEditorDrawerProps) {
+ const { t } = useTranslation()
+ const titles: Record
= {
+ media: t('editor.mediaSidebar.media'),
+ properties: t('editor.propertiesSidebar.title'),
+ source: t('media.info.source'),
+ meters: t('editor.audioMeters.meters'),
+ }
+ const title = panel ? titles[panel] : ''
+
+ return (
+
+ )
+}
diff --git a/src/features/editor/components/preview-area.tsx b/src/features/editor/components/preview-area.tsx
index b9eb0587f..8c822d785 100644
--- a/src/features/editor/components/preview-area.tsx
+++ b/src/features/editor/components/preview-area.tsx
@@ -32,6 +32,7 @@ interface PreviewAreaProps {
}
durationInFrames?: number
preferProjectStoreMetadata?: boolean
+ compact?: boolean
}
type PreviewChrome = 'edit' | 'color'
@@ -181,6 +182,7 @@ export const PreviewArea = memo(function PreviewArea({
project,
durationInFrames,
preferProjectStoreMetadata = true,
+ compact = false,
}: PreviewAreaProps) {
const { t } = useTranslation()
const previewContainerRef = useRef(null)
@@ -507,7 +509,8 @@ export const PreviewArea = memo(function PreviewArea({
}
}, [])
- const hasSidePanels = !!sourcePreviewMediaId || scopesPanelOpen
+ const showInlineSourceMonitor = !!sourcePreviewMediaId && !compact
+ const hasSidePanels = showInlineSourceMonitor || scopesPanelOpen
const previewChrome: PreviewChrome = workspace === 'color' ? 'color' : 'edit'
const programPanelPercent = Math.max(
0,
@@ -521,7 +524,7 @@ export const PreviewArea = memo(function PreviewArea({
role="region"
aria-label="Preview area"
>
- {sourcePreviewMediaId && (
+ {showInlineSourceMonitor && sourcePreviewMediaId && (
<>
)
})
+
+export function MobileSourceMonitor({
+ mediaId,
+ onClose,
+}: {
+ mediaId: string
+ onClose: () => void
+}) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/src/features/editor/components/properties-sidebar/index.tsx b/src/features/editor/components/properties-sidebar/index.tsx
index 116a2d7e5..3de484224 100644
--- a/src/features/editor/components/properties-sidebar/index.tsx
+++ b/src/features/editor/components/properties-sidebar/index.tsx
@@ -147,7 +147,15 @@ function getClipHeader(items: HeaderItem[]) {
* Shows TransitionPanel when a transition is selected, MarkerPanel when a marker
* is selected, ClipPanel when clips are selected, CanvasPanel otherwise.
*/
-export const PropertiesSidebar = memo(function PropertiesSidebar() {
+interface PropertiesSidebarProps {
+ mobileDrawer?: boolean
+ onRequestClose?: () => void
+}
+
+export const PropertiesSidebar = memo(function PropertiesSidebar({
+ mobileDrawer = false,
+ onRequestClose,
+}: PropertiesSidebarProps) {
const { t } = useTranslation()
const hostMode = useEditorHostMode()
const editorDensity = useSettingsStore((s) => s.editorDensity)
@@ -161,6 +169,8 @@ export const PropertiesSidebar = memo(function PropertiesSidebar() {
const togglePropertiesFullColumn = useEditorStore((s) => s.togglePropertiesFullColumn)
const workspace = useEditorStore((s) => s.workspace)
const clipInspectorTab = useEditorStore((s) => s.clipInspectorTab)
+ const panelOpen = mobileDrawer || rightSidebarOpen
+ const panelWidth = mobileDrawer ? editorLayout.rightSidebarDefaultWidth : rightSidebarWidth
const selectedItemIds = useSelectionStore((s) => s.selectedItemIds)
const selectedMarkerId = useSelectionStore((s) => s.selectedMarkerId)
const selectedTransitionId = useSelectionStore((s) => s.selectedTransitionId)
@@ -218,10 +228,10 @@ export const PropertiesSidebar = memo(function PropertiesSidebar() {
// Keep the panel content mounted + visible while the collapse animation plays
// so it slides out smoothly instead of blinking away. Only switch Activity to
// `hidden` (the perf win) once the close animation has actually settled.
- const [contentVisible, setContentVisible] = useState(rightSidebarOpen)
+ const [contentVisible, setContentVisible] = useState(panelOpen)
useEffect(() => {
- if (rightSidebarOpen) setContentVisible(true)
- }, [rightSidebarOpen])
+ if (panelOpen) setContentVisible(true)
+ }, [panelOpen])
// Resize handle logic
const isResizingRef = useRef(false)
@@ -278,53 +288,56 @@ export const PropertiesSidebar = memo(function PropertiesSidebar() {
tracks the pointer instead of easing behind it. */}
{
- if (!rightSidebarOpen) setContentVisible(false)
+ if (!panelOpen) setContentVisible(false)
}}
>
{/* Use Activity for React 19 performance optimization */}
-
+
{/* Sidebar Header */}
-
- {propertiesFullColumn ? (
-
- ) : (
-
- )}
-
+ {!mobileDrawer && (
+
+ {propertiesFullColumn ? (
+
+ ) : (
+
+ )}
+
+ )}
{headerLabel}
@@ -345,7 +358,7 @@ export const PropertiesSidebar = memo(function PropertiesSidebar() {
width: EDITOR_LAYOUT_CSS_VALUES.sidebarHeaderButtonSize,
height: EDITOR_LAYOUT_CSS_VALUES.sidebarHeaderButtonSize,
}}
- onClick={toggleRightSidebar}
+ onClick={mobileDrawer ? onRequestClose : toggleRightSidebar}
aria-label={t('editor.mediaSidebar.collapsePanel')}
>
@@ -401,7 +414,7 @@ export const PropertiesSidebar = memo(function PropertiesSidebar() {
{/* Resize Handle */}
- {rightSidebarOpen && (
+ {rightSidebarOpen && !mobileDrawer && (
0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`
}
+function projectDurationSeconds(maxItemEndFrame: number, fps: number): number {
+ return fps > 0 ? maxItemEndFrame / fps : 0
+}
+
+function LocalInferenceToolbarStatus({ hostMode }: { hostMode: boolean }) {
+ return hostMode ? null :
+}
+
interface ToolbarProps {
projectId: string
project: {
@@ -76,6 +86,208 @@ interface ToolbarProps {
onOpenRenderQueue?: () => void
/** Number of queued + rendering jobs, shown as a badge on the queue button. */
renderQueueCount?: number
+ compact?: boolean
+}
+
+interface MobileToolbarProps extends ToolbarProps {
+ hostMode: boolean
+ showUnsavedDialog: boolean
+ showShortcutsDialog: boolean
+ showSettingsDialog: boolean
+ showWhatsNewDialog: boolean
+ isSaveAnimating: boolean
+ saveAnimationKey: number
+ handleBackClick(): void
+ handleSave(): Promise
+ openWhatsNew(): void
+ setShowUnsavedDialog(open: boolean): void
+ setShowShortcutsDialog(open: boolean): void
+ setShowSettingsDialog(open: boolean): void
+ setShowWhatsNewDialog(open: boolean): void
+}
+
+// The compact toolbar keeps the four essential actions visible while gathering
+// host/local utility branches into one tested overflow surface. Its standalone
+// and host variants are covered by tests/browser/responsive-editor.spec.ts.
+// fallow-ignore-next-line complexity
+function MobileToolbar({
+ project,
+ onBack,
+ onSave,
+ onExport,
+ onExportBundle,
+ onOpenRenderQueue,
+ renderQueueCount = 0,
+ hostMode,
+ showUnsavedDialog,
+ showShortcutsDialog,
+ showSettingsDialog,
+ showWhatsNewDialog,
+ isSaveAnimating,
+ saveAnimationKey,
+ handleBackClick,
+ handleSave,
+ openWhatsNew,
+ setShowUnsavedDialog,
+ setShowShortcutsDialog,
+ setShowSettingsDialog,
+ setShowWhatsNewDialog,
+}: MobileToolbarProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+
+ {onSave && onBack && (
+
+ )}
+
+
+
+
+
+ {onSave && (
+
+ {isSaveAnimating ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ {(onExport || onExportBundle) && (
+
+
+
+
+
+
+
+
+
+ {t('toolbar.exportVideo')}
+
+
+
+ {t('toolbar.downloadProjectZip')}
+
+
+
+ )}
+
+
+ {!hostMode && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {project.name}
+ {onOpenRenderQueue && (
+
+
+ {t('toolbar.renderQueue')}
+ {renderQueueCount > 0 && {renderQueueCount}}
+
+ )}
+
+
+ {t('toolbar.whatsNew')}
+
+ setShowSettingsDialog(true)} disabled={hostMode}>
+
+ {t('toolbar.settings')}
+
+ setShowShortcutsDialog(true)}>
+
+ {t('toolbar.keyboardShortcuts')}
+
+
+
+
+ User Guide
+
+
+
+
+
+ GitHub
+
+
+
+
+
+ Discord
+
+
+
+
+
+
+
+
+ )
+}
+
+function requestToolbarBack({
+ onSave,
+ onBack,
+ showUnsavedDialog,
+}: {
+ onSave?: () => Promise
+ onBack?: () => void
+ showUnsavedDialog: () => void
+}) {
+ if (!useTimelineStore.getState().isDirty) {
+ onBack?.()
+ return
+ }
+ if (onSave && onBack) showUnsavedDialog()
}
export const Toolbar = memo(function Toolbar({
@@ -87,6 +299,7 @@ export const Toolbar = memo(function Toolbar({
onExportBundle,
onOpenRenderQueue,
renderQueueCount = 0,
+ compact = false,
}: ToolbarProps) {
const { t } = useTranslation()
const hostMode = useEditorHostMode()
@@ -105,7 +318,7 @@ export const Toolbar = memo(function Toolbar({
const projectSummary = useMemo(() => {
const projectMediaIds = new Set(mediaDependencyIds)
return {
- durationSeconds: project.fps > 0 ? maxItemEndFrame / project.fps : 0,
+ durationSeconds: projectDurationSeconds(maxItemEndFrame, project.fps),
clipCount: itemCount,
mediaCount: mediaDependencyIds.length,
brokenMediaCount: brokenMediaIds.filter((mediaId) => projectMediaIds.has(mediaId)).length,
@@ -130,11 +343,11 @@ export const Toolbar = memo(function Toolbar({
}
const handleBackClick = () => {
- if (useTimelineStore.getState().isDirty) {
- if (onSave && onBack) setShowUnsavedDialog(true)
- return
- }
- onBack?.()
+ requestToolbarBack({
+ onSave,
+ onBack,
+ showUnsavedDialog: () => setShowUnsavedDialog(true),
+ })
}
const handleSave = async () => {
@@ -166,6 +379,35 @@ export const Toolbar = memo(function Toolbar({
}
}
+ if (compact) {
+ return (
+
+ )
+ }
+
return (
- {!hostMode &&
}
+
diff --git a/src/features/editor/components/workspace-switcher.tsx b/src/features/editor/components/workspace-switcher.tsx
index ba388b9b7..93afdab82 100644
--- a/src/features/editor/components/workspace-switcher.tsx
+++ b/src/features/editor/components/workspace-switcher.tsx
@@ -22,7 +22,11 @@ const WORKSPACE_ITEMS: readonly {
* (scopes, inspector tab, sidebar tab, timeline split) without touching
* selection, playhead, or project state.
*/
-export const WorkspaceSwitcher = memo(function WorkspaceSwitcher() {
+export const WorkspaceSwitcher = memo(function WorkspaceSwitcher({
+ compact = false,
+}: {
+ compact?: boolean
+}) {
const { t } = useTranslation()
const workspace = useEditorStore((s) => s.workspace)
const setWorkspace = useEditorStore((s) => s.setWorkspace)
@@ -49,7 +53,8 @@ export const WorkspaceSwitcher = memo(function WorkspaceSwitcher() {
aria-selected={isActive}
onClick={() => setWorkspace(id)}
className={cn(
- 'flex h-7 items-center gap-1.5 rounded-[5px] px-3 text-xs font-medium transition-colors',
+ 'flex h-7 min-w-0 items-center gap-1 rounded-[5px] text-xs font-medium transition-colors',
+ compact ? 'px-1.5' : 'gap-1.5 px-3',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
diff --git a/src/features/editor/hooks/use-compact-editor-layout.ts b/src/features/editor/hooks/use-compact-editor-layout.ts
new file mode 100644
index 000000000..a8c611390
--- /dev/null
+++ b/src/features/editor/hooks/use-compact-editor-layout.ts
@@ -0,0 +1,29 @@
+import { useLayoutEffect, useState, type RefObject } from 'react'
+
+export const MOBILE_EDITOR_MAX_WIDTH = 767
+
+export function useCompactEditorLayout(rootRef: RefObject
): boolean {
+ const [compact, setCompact] = useState(
+ () => typeof window !== 'undefined' && window.innerWidth <= MOBILE_EDITOR_MAX_WIDTH,
+ )
+
+ useLayoutEffect(() => {
+ const root = rootRef.current
+ if (!root) return
+
+ const update = (width: number) => {
+ const nextCompact = width <= MOBILE_EDITOR_MAX_WIDTH
+ setCompact((current) => (current === nextCompact ? current : nextCompact))
+ }
+
+ update(root.getBoundingClientRect().width || root.clientWidth || window.innerWidth)
+ const observer = new ResizeObserver((entries) => {
+ const entry = entries[0]
+ if (entry) update(entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width)
+ })
+ observer.observe(root)
+ return () => observer.disconnect()
+ }, [rootRef])
+
+ return compact
+}
diff --git a/src/features/editor/hooks/use-editor-hotkeys.ts b/src/features/editor/hooks/use-editor-hotkeys.ts
index aeb6b5ad7..e84395c61 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 { useCommandHotkey } from '@/hooks/use-hotkey-registration'
import { HOTKEY_OPTIONS } from '@/config/hotkeys'
-import { useResolvedHotkeys } from '@/features/editor/deps/settings'
import { useEditorStore } from '@/shared/state/editor'
import { useSceneBrowserStore } from '@/features/editor/deps/scene-browser'
@@ -24,12 +23,11 @@ interface EditorHotkeyCallbacks {
* Uses react-hotkeys-hook with granular Zustand selectors
*/
export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) {
- const hotkeys = useResolvedHotkeys()
const enableLocalUi = callbacks.enableLocalUi ?? true
// Save: Cmd/Ctrl+S
- useHotkeys(
- hotkeys.SAVE,
+ useCommandHotkey(
+ 'SAVE',
(event) => {
event.preventDefault()
if (callbacks.onSave) {
@@ -41,8 +39,8 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) {
)
// Export: Cmd/Ctrl+Shift+E
- useHotkeys(
- hotkeys.EXPORT,
+ useCommandHotkey(
+ 'EXPORT',
(event) => {
event.preventDefault()
if (callbacks.onExport) {
@@ -56,8 +54,8 @@ 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(
- hotkeys.OPEN_SCENE_BROWSER,
+ useCommandHotkey(
+ 'OPEN_SCENE_BROWSER',
(event) => {
if (!enableLocalUi) return
event.preventDefault()
@@ -69,8 +67,8 @@ 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(
- hotkeys.WORKSPACE_EDIT,
+ useCommandHotkey(
+ 'WORKSPACE_EDIT',
(event) => {
if (!enableLocalUi) return
event.preventDefault()
@@ -80,8 +78,8 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) {
[enableLocalUi],
)
- useHotkeys(
- hotkeys.WORKSPACE_COLOR,
+ useCommandHotkey(
+ 'WORKSPACE_COLOR',
(event) => {
if (!enableLocalUi) return
event.preventDefault()
@@ -91,8 +89,8 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) {
[enableLocalUi],
)
- useHotkeys(
- hotkeys.WORKSPACE_ANIMATE,
+ useCommandHotkey(
+ 'WORKSPACE_ANIMATE',
(event) => {
if (!enableLocalUi) return
event.preventDefault()
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..535eba118 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,53 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) {
useEffect(() => {
let cancelled = false
let unsubscribe: (() => void) | undefined
+ let unmountShortcutSettings: (() => void) | undefined
+ const shortcutSettingsAbortController = new AbortController()
setState(null)
setError(null)
- void Promise.all([Promise.resolve(host.load()), i18nReady])
- .then(([snapshot]) => {
- if (cancelled) return
+
+ const initialize = async () => {
+ unmountShortcutSettings = await mountHostShortcutSettings(
+ host,
+ shortcutSettingsAbortController.signal,
+ )
+ 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
+ shortcutSettingsAbortController.abort()
+ unmountShortcutSettings?.()
+ unmountShortcutSettings = undefined
+ if (cancelled) return
+ setError(caught instanceof Error ? caught : new Error(String(caught)))
})
+
return () => {
cancelled = true
unsubscribe?.()
unsubscribe = undefined
+ shortcutSettingsAbortController.abort()
+ 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..0db8de565
--- /dev/null
+++ b/src/features/editor/host/shortcut-settings.test.ts
@@ -0,0 +1,541 @@
+// @vitest-environment jsdom
+
+import { createElement } from 'react'
+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'
+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'
+import { createHostShortcutSettings, type EditorHost, type HostShortcutSettings } from './contract'
+import { HOST_SHORTCUT_RETRY_DELAYS_MS, mountHostShortcutSettings } from './shortcut-settings'
+
+function HostShortcutHarness() {
+ useHostTimelineShortcuts()
+ 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()
+ 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,
+ listenerCount: () => listeners.size,
+ emit: (settings: HostShortcutSettings) => {
+ for (const listener of listeners) listener(settings)
+ },
+ }
+}
+
+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 }
+}
+
+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()
+ usePlaybackStore.setState({
+ isPlaying: false,
+ playbackRate: 1,
+ transportMode: 'normal',
+ })
+ })
+
+ 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(
+ 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: 'q',
+ SHUTTLE_PAUSE: 'w',
+ SHUTTLE_FORWARD: 'e',
+ }),
+ )
+
+ expect(useSettingsStore.getState().hotkeyOverrides).toEqual({
+ SHUTTLE_REVERSE: 'q',
+ SHUTTLE_PAUSE: 'w',
+ SHUTTLE_FORWARD: 'e',
+ })
+ expect(harness.notify).not.toHaveBeenCalled()
+
+ unmount()
+ expect(useSettingsStore.getState().hotkeyOverrides).toEqual({
+ 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('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)
+ 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: 'w' }))
+ await Promise.resolve()
+ expect(host.setSettings).not.toHaveBeenCalled()
+ expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'w' })
+ 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('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()
+ 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', 'f10')
+ 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)
+ expect(host.listenerCount()).toBe(1)
+ unmount()
+ expect(host.listenerCount()).toBe(0)
+ })
+
+ it('resolves a host collision so capture and bubbling listeners fire one intended action', async () => {
+ const harness = createShortcutHost(
+ createHostShortcutSettings({
+ 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()
+ })
+
+ 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()
+ })
+
+ 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
new file mode 100644
index 000000000..703a6fed6
--- /dev/null
+++ b/src/features/editor/host/shortcut-settings.ts
@@ -0,0 +1,277 @@
+import {
+ resolveHotkeyConfiguration,
+ type HotkeyConflictWarning,
+ 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'
+
+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[]
+} {
+ if (settings.schema !== HOST_SHORTCUTS_SCHEMA || settings.version !== HOST_SHORTCUTS_VERSION) {
+ throw new Error('Unsupported host shortcut settings schema')
+ }
+
+ const resolution = resolveHotkeyConfiguration(settings.overrides)
+ return {
+ settings: createHostShortcutSettings(resolution.overrides),
+ warnings: resolution.warnings,
+ }
+}
+
+function copyOverrides(overrides: HotkeyOverrideMap): HotkeyOverrideMap {
+ return { ...overrides }
+}
+
+interface ShortcutOwnership {
+ epoch: number
+ standaloneOverrides: HotkeyOverrideMap
+ dispose?: () => void
+}
+
+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.
+ */
+export async function mountHostShortcutSettings(
+ host: EditorHost,
+ signal?: AbortSignal,
+ retryScheduler: HostShortcutRetryScheduler = DEFAULT_HOST_SHORTCUT_RETRY_SCHEDULER,
+): Promise<() => void> {
+ const previousOwnership = currentOwnership
+ const standaloneOverrides = copyOverrides(
+ previousOwnership?.standaloneOverrides ?? useSettingsStore.getState().hotkeyOverrides,
+ )
+ previousOwnership?.dispose?.()
+ const ownership: ShortcutOwnership = {
+ epoch: ++nextOwnershipEpoch,
+ standaloneOverrides,
+ }
+ currentOwnership = ownership
+ let applyingHostSettings = false
+ 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)
+ if (currentOwnership?.epoch !== ownership.epoch) return
+ currentOwnership = null
+ useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides)
+ }
+ ownership.dispose = dispose
+
+ 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 })
+ }
+
+ 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
+ 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
+ if (inFlightSettings || !desiredSettings) return false
+ if (reconcileAfterFlight || !settledSettings) return true
+ 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
+ retryAttempt = 0
+ }
+ const desiredChanged = desiredDiffersFrom(settingsToWrite)
+ inFlightSettings = null
+ if (desiredChanged || reconcileAfterFlight) {
+ scheduleReconcile()
+ return
+ }
+ if (!succeeded && hasUnsettledDesiredSettings()) {
+ scheduleRetry()
+ }
+ }
+
+ 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)
+ }
+
+ 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)
+ if (normalized.warnings.length > 0) {
+ for (const warning of normalized.warnings) {
+ host.notify?.({
+ kind: 'conflict',
+ message: `Shortcut ${warning.binding} for ${warning.command} conflicts with ${warning.conflictingCommand}; retained the last valid shortcut settings.`,
+ })
+ }
+ return
+ }
+ applyingHostSettings = true
+ try {
+ useSettingsStore.getState().replaceHotkeyOverrides(normalized.settings.overrides)
+ } finally {
+ 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)
+ }
+ }
+
+ let initialSettings: HostShortcutSettings
+ try {
+ initialSettings = await Promise.resolve(port.getSettings())
+ } catch (error) {
+ dispose()
+ throw error
+ }
+ if (!isCurrent()) {
+ return dispose
+ }
+ desiredSettings = createHostShortcutSettings(
+ copyOverrides(useSettingsStore.getState().hotkeyOverrides),
+ )
+ settledSettings = initialSettings
+ applyHostSettings(initialSettings)
+
+ unsubscribeHost = port.subscribe?.((settings) => {
+ if (!isCurrent()) return
+ try {
+ applyHostSettings(settings)
+ } catch {
+ reportFailure('Could not apply keyboard shortcuts from the host.')
+ }
+ })
+
+ unsubscribeStore = useSettingsStore.subscribe((state, previousState) => {
+ if (
+ disposed ||
+ applyingHostSettings ||
+ state.hotkeyOverrides === previousState.hotkeyOverrides
+ ) {
+ return
+ }
+
+ const settings = createHostShortcutSettings(copyOverrides(state.hotkeyOverrides))
+ desiredSettings = settings
+ cancelRetry(true)
+ scheduleReconcile()
+ })
+
+ return dispose
+}
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.test.tsx b/src/features/export/hooks/use-client-render.test.tsx
new file mode 100644
index 000000000..df351144d
--- /dev/null
+++ b/src/features/export/hooks/use-client-render.test.tsx
@@ -0,0 +1,333 @@
+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(),
+ 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<
+ string,
+ unknown
+>
+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(), {
+ wrapper: ({ children }: PropsWithChildren) => {children},
+ })
+
+ 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('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,
+ 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 aa2009645..70c866b7a 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
@@ -84,15 +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 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)
@@ -119,14 +149,38 @@ 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)
+ const runToken = ++latestRunTokenRef.current
+ abortActiveRun()
+ const controller = new AbortController()
+ const run = { token: runToken, controller }
+ activeRunRef.current = run
+ let temporaryResult: ClientRenderResult | null = null
+
+ 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 (!isActive()) {
+ throw new DOMException('Render cancelled', 'AbortError')
+ }
+ }
+ const handleRunProgress = (progressData: RenderProgress) => {
+ if (isActive()) applyProgress(progressData)
+ }
try {
- const previousResult = resultRef.current
- resultRef.current = null
- void releaseTemporaryExportOutput(previousResult)
+ const previousResultOwner = resultOwnerRef.current
+ resultOwnerRef.current = null
+ releaseOwnedResult(previousResultOwner)
setIsExporting(true)
setProgress(0)
setProgressMessage(undefined)
@@ -134,28 +188,31 @@ 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 { 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.
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(
{
@@ -173,12 +230,19 @@ export function useClientRender(): UseClientRenderReturn {
masterBusDb,
},
signal,
- handleProgress,
+ handleRunProgress,
)
+ ensureActive()
if (smartCopy.result) {
- resultRef.current = smartCopy.result
- setResult(smartCopy.result)
+ temporaryResult = smartCopy.result
+ setResult(temporaryResult)
+ resultOwnerRef.current = {
+ runToken,
+ result: temporaryResult,
+ released: false,
+ }
+ temporaryResult = null
setStatus('completed')
setProgress(100)
event.set('renderPath', 'smart-copy')
@@ -192,6 +256,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)
@@ -246,7 +311,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
@@ -291,8 +360,10 @@ export function useClientRender(): UseClientRenderReturn {
exportMode,
composition,
signal,
- onProgress: handleProgress,
+ onProgress: handleRunProgress,
})
+ temporaryResult = renderResult
+ ensureActive()
if (fallbackReason) event.set('workerFallbackReason', fallbackReason)
// Sidecar mode: the video is muxed clean; build the .srt from the same
@@ -310,8 +381,10 @@ export function useClientRender(): UseClientRenderReturn {
}
}
- resultRef.current = finalResult
+ if (finalResult !== renderResult) temporaryResult = finalResult
setResult(finalResult)
+ resultOwnerRef.current = { runToken, result: finalResult, released: false }
+ temporaryResult = null
setStatus('completed')
setProgress(100)
@@ -322,6 +395,8 @@ export function useClientRender(): UseClientRenderReturn {
duration: renderResult.duration,
})
} catch (err) {
+ releaseTemporaryResult()
+ if (runToken !== latestRunTokenRef.current) return
if (err instanceof DOMException && err.name === 'AbortError') {
event.set('outcome', 'cancelled')
event.set('duration_ms', Date.now())
@@ -334,11 +409,13 @@ export function useClientRender(): UseClientRenderReturn {
setStatus('failed')
}
} finally {
- setIsExporting(false)
- abortControllerRef.current = null
+ if (activeRunRef.current === run) {
+ activeRunRef.current = null
+ setIsExporting(false)
+ }
}
},
- [handleProgress],
+ [abortActiveRun, applyProgress, releaseOwnedResult],
)
/**
@@ -346,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
@@ -402,8 +479,8 @@ export function useClientRender(): UseClientRenderReturn {
* Reset state
*/
const resetState = useCallback(() => {
- abortControllerRef.current?.abort()
- abortControllerRef.current = null
+ latestRunTokenRef.current++
+ abortActiveRun()
setIsExporting(false)
setProgress(0)
setProgressMessage(undefined)
@@ -411,18 +488,21 @@ export function useClientRender(): UseClientRenderReturn {
setTotalFrames(undefined)
setStatus('idle')
setError(null)
- const previousResult = resultRef.current
- resultRef.current = null
- void releaseTemporaryExportOutput(previousResult)
+ const previousResultOwner = resultOwnerRef.current
+ resultOwnerRef.current = null
+ releaseOwnedResult(previousResultOwner)
setResult(null)
- }, [])
+ }, [abortActiveRun, releaseOwnedResult])
useEffect(
() => () => {
- void releaseTemporaryExportOutput(resultRef.current)
- resultRef.current = null
+ latestRunTokenRef.current++
+ abortActiveRun()
+ const ownedResult = resultOwnerRef.current
+ resultOwnerRef.current = null
+ releaseOwnedResult(ownedResult)
},
- [],
+ [abortActiveRun, releaseOwnedResult],
)
/**
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/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/canvas-video-extractor.test.ts b/src/features/export/utils/canvas-video-extractor.test.ts
index 854060020..67c6756d5 100644
--- a/src/features/export/utils/canvas-video-extractor.test.ts
+++ b/src/features/export/utils/canvas-video-extractor.test.ts
@@ -1,7 +1,10 @@
-import { describe, expect, it, vi } from 'vite-plus/test'
+import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { VideoFrameExtractor } from './canvas-video-extractor'
describe('VideoFrameExtractor lifecycle', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
it('closes a sample yielded after the extractor was disposed', async () => {
let resolveNext!: (result: IteratorResult<{ close: () => void }>) => void
const nextResult = new Promise void }>>((resolve) => {
@@ -34,4 +37,45 @@ describe('VideoFrameExtractor lifecycle', () => {
expect(sample.close).toHaveBeenCalledTimes(1)
expect(iterator.return).toHaveBeenCalledTimes(1)
})
+
+ it('reports an expected no-sample fallback as structured debug telemetry only', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const debug = vi.spyOn(console, 'log').mockImplementation(() => undefined)
+ const extractor = new VideoFrameExtractor('blob:test', 'test-item')
+ const internals = extractor as unknown as {
+ lastFailureKind: 'none' | 'no-sample' | 'decode-error'
+ reportDrawFailure: (timestamp: number, clampedTime: number, error: unknown) => boolean
+ }
+ internals.lastFailureKind = 'no-sample'
+
+ expect(internals.reportDrawFailure(1, 0.99, null)).toBe(false)
+
+ expect(warn).not.toHaveBeenCalled()
+ expect(debug).toHaveBeenCalledWith(
+ '[VideoFrameExtractor] Mediabunny frame extraction fallback',
+ expect.objectContaining({ itemId: 'test-item', reason: 'no-sample', failures: 1 }),
+ )
+ })
+
+ it('keeps a genuine decode failure visible as one clear warning', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const extractor = new VideoFrameExtractor('blob:test', 'test-item')
+ const internals = extractor as unknown as {
+ lastFailureKind: 'none' | 'no-sample' | 'decode-error'
+ reportDrawFailure: (timestamp: number, clampedTime: number, error: unknown) => boolean
+ }
+ internals.lastFailureKind = 'decode-error'
+
+ internals.reportDrawFailure(1, 0.99, new Error('decoder exploded'))
+
+ expect(warn).toHaveBeenCalledTimes(1)
+ expect(warn).toHaveBeenCalledWith(
+ '[VideoFrameExtractor] Mediabunny frame extraction failed',
+ expect.objectContaining({
+ itemId: 'test-item',
+ reason: 'decode-error',
+ error: 'decoder exploded',
+ }),
+ )
+ })
})
diff --git a/src/features/export/utils/canvas-video-extractor.ts b/src/features/export/utils/canvas-video-extractor.ts
index d452fabec..534ba664a 100644
--- a/src/features/export/utils/canvas-video-extractor.ts
+++ b/src/features/export/utils/canvas-video-extractor.ts
@@ -139,11 +139,10 @@ export class VideoFrameExtractor {
if (typeof this.videoTrack.canDecode === 'function') {
const decodable = await this.videoTrack.canDecode()
if (!decodable) {
- this.logInitFailure(
- 'Video track is not decodable via mediabunny/WebCodecs',
- { itemId: this.itemId },
- 'warn',
- )
+ log.debug('Skipping unsupported mediabunny decode path', {
+ itemId: this.itemId,
+ reason: 'track-not-decodable',
+ })
return false
}
}
@@ -597,7 +596,9 @@ export class VideoFrameExtractor {
error: error instanceof Error ? error.message : String(error),
}
- if (shouldWarn && !this.options.logFrameFailuresAsDebug) {
+ if (this.lastFailureKind === 'no-sample') {
+ log.debug('Mediabunny frame extraction fallback', logData)
+ } else if (shouldWarn && !this.options.logFrameFailuresAsDebug) {
log.warn('Mediabunny frame extraction failed', logData)
} else {
log.debug('Mediabunny frame extraction failed', logData)
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/export/utils/pipelined-frame-loop.test.ts b/src/features/export/utils/pipelined-frame-loop.test.ts
index e4e503dce..8b5a0e7c1 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,23 +40,31 @@ 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
+ onAbortImpl?: () => void | Promise
}
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 = {
@@ -61,27 +73,31 @@ function createHarness(totalFrames: number, opts: HarnessOptions = {}) {
close() {
this.closed = true
events.push(`close-${frame}`)
+ opts.closeImpl?.(this)
},
}
samples.push(sample)
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}`)
})
},
onAbort: async () => {
events.push('abort-cancel')
+ await opts.onAbortImpl?.()
},
onFrameProgress: (frame) => {
events.push(`progress-${frame}`)
},
})
- return { events, samples, run }
+ return { events, samples, failureState, run }
}
const indexOf = (events: string[], event: string) => {
@@ -90,7 +106,282 @@ 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('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()
@@ -182,6 +473,253 @@ 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 outcome: Promise | undefined
+
+ try {
+ const { events, samples, failureState, run } = createHarness(2, {
+ renderImpl: (frame) => (frame === 1 ? nextRender.promise : undefined),
+ encodeImpl: () => encode.promise,
+ })
+
+ outcome = run().then(
+ () => null,
+ (error: unknown) => error,
+ )
+ await tick()
+ expect(events).toContain('render-1')
+
+ failureState.reportFailure(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 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 outcome: Promise | undefined
+
+ try {
+ const { events, samples, failureState, run } = createHarness(2, {
+ signal: controller.signal,
+ 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')
+
+ failureState.reportFailure(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')
+ 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')
+ 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)
+ const failureState = createPipelinedFrameLoopFailureState()
+
+ try {
+ const { events, samples, run } = createHarness(4, {
+ failureState,
+ renderImpl: (frame) => {
+ if (frame === 1) failureState.reportFailure(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()
@@ -236,13 +774,121 @@ 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('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 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,
+ failureState,
+ 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()
+ audio.reject(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 observedAudio
+ 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
+ const failureState = createPipelinedFrameLoopFailureState()
const { events, run } = createHarness(5, {
- getPendingError: () => raised,
+ failureState,
renderImpl: (frame) => {
- if (frame === 1) raised = pendingError
+ if (frame === 1) failureState.reportFailure(pendingError)
},
})
@@ -251,20 +897,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 583a5fb36..e13a5c245 100644
--- a/src/features/export/utils/pipelined-frame-loop.ts
+++ b/src/features/export/utils/pipelined-frame-loop.ts
@@ -7,29 +7,76 @@
* 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. 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 {
close(): void
}
+interface RecordedFailure {
+ error: unknown
+}
+
+export interface PipelinedFrameLoopFailureState {
+ readonly firstFailure: RecordedFailure | null
+ readonly firstPrimaryFailure: RecordedFailure | null
+ readonly firstCleanupFailure: RecordedFailure | null
+ reportFailure(error: unknown): void
+ reportCleanupFailure(error: unknown): void
+}
+
+/**
+ * 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. Cleanup has its
+ * own first-error latch so continuation order cannot let cleanup mask a
+ * primary source failure.
+ */
+export function createPipelinedFrameLoopFailureState(): PipelinedFrameLoopFailureState {
+ let firstPrimaryFailure: RecordedFailure | null = null
+ let firstCleanupFailure: RecordedFailure | null = null
+ return {
+ get firstFailure() {
+ return firstPrimaryFailure ?? firstCleanupFailure
+ },
+ get firstPrimaryFailure() {
+ return firstPrimaryFailure
+ },
+ get firstCleanupFailure() {
+ return firstCleanupFailure
+ },
+ reportFailure(error) {
+ firstPrimaryFailure ??= { error }
+ },
+ reportCleanupFailure(error) {
+ firstCleanupFailure ??= { 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,25 +84,84 @@ 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 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 OperationSettlement {
+ status: 'fulfilled' | 'rejected'
+ reason?: unknown
+}
+
+async function encodeAndCloseSample(
+ sample: S,
+ keyFrame: boolean,
+ encodeSample: (
+ sample: S,
+ keyFrame: boolean,
+ reportFailure: (error: unknown) => void,
+ ) => Promise,
+ recordSettledFailure: (error: unknown) => void,
+ recordSynchronousFailure: (error: unknown) => Promise,
+ recordCleanupFailure: (error: unknown) => void,
+): Promise {
+ let failure: RecordedFailure | null = null
+ let encoding: Promise | null = null
+ try {
+ encoding = encodeSample(sample, keyFrame, recordSettledFailure)
+ } catch (error) {
+ failure = { 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 {
+ // 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 }
+ recordCleanupFailure(error)
+ }
+ }
+
+ return failure ? { status: 'rejected', reason: failure.error } : { status: 'fulfilled' }
+}
+
export async function runPipelinedFrameLoop(
deps: PipelinedFrameLoopDeps,
): Promise {
const {
totalFrames,
signal,
- getPendingError,
+ failureState = createPipelinedFrameLoopFailureState(),
renderFrame,
captureSample,
encodeSample,
@@ -63,58 +169,180 @@ export async function runPipelinedFrameLoop(
onFrameProgress,
} = deps
- let pendingEncode: Promise | null = null
-
- 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) {
- try {
- await pendingEncode
- } catch {
- /* discarded — aborting */
- }
- }
+ // 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 abortError: DOMException | null = null
+ let abortCleanupStarted = false
+ let abortPublicationQueued = false
+ let listenerActive = true
+
+ const recordFailure = (error: unknown) => {
+ failureState.reportFailure(error)
+ }
+
+ const recordCleanupFailure = (error: unknown) => {
+ failureState.reportCleanupFailure(error)
+ }
+
+ const getAbortError = () => {
+ abortError ??= new DOMException('Render cancelled', 'AbortError')
+ return abortError
+ }
+
+ const publishAbort = () => {
+ if (abortPublicationQueued) return
+ abortPublicationQueued = true
+ queueMicrotask(() => {
+ if (listenerActive) recordFailure(getAbortError())
+ })
+ }
+
+ 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 {
+ return await encode
+ } finally {
+ pendingEncode = null
+ }
+ }
+
+ const runAbortCleanup = async () => {
+ if (abortCleanupStarted) return
+ abortCleanupStarted = true
+ try {
await onAbort()
- throw new DOMException('Render cancelled', 'AbortError')
+ } catch (error) {
+ recordCleanupFailure(error)
}
+ }
+
+ const throwRecordedFailureAfterDrain = async () => {
+ if (signal?.aborted) await runAbortCleanup()
+ const failure = failureState.firstFailure
+ if (!failure) return
+ 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 })
+
+ 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 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()
+
+ // Snapshot pixels into a sample. The capture copies pixel data
+ // immediately — the surface is free for the next render.
+ const sample = captureSample(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
-
- // 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 = 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
- if (pendingEncode) await pendingEncode
+ 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
+ }
}
diff --git a/src/features/export/utils/render-pipeline.test.ts b/src/features/export/utils/render-pipeline.test.ts
new file mode 100644
index 000000000..b4dc44e4e
--- /dev/null
+++ b/src/features/export/utils/render-pipeline.test.ts
@@ -0,0 +1,229 @@
+// @vitest-environment node
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+
+const renderMocks = vi.hoisted(() => ({
+ renderAudioOnly: vi.fn(),
+ renderComposition: vi.fn(),
+}))
+
+vi.mock('./canvas-render-orchestrator', () => renderMocks)
+
+import { runRender } from './render-pipeline'
+import type { ClientExportSettings, ClientRenderResult } from './client-renderer'
+import type { CompositionInputProps } from '@/types/export'
+
+const result: ClientRenderResult = {
+ blob: new Blob(['rendered']),
+ fileSize: 8,
+ duration: 1,
+ mimeType: 'video/mp4',
+}
+
+const settings = {
+ mode: 'video',
+ codec: 'avc',
+ container: 'mp4',
+ resolution: { width: 640, height: 360 },
+} as ClientExportSettings
+
+function compositionWithAudio(): CompositionInputProps {
+ return {
+ fps: 30,
+ durationInFrames: 30,
+ width: 640,
+ height: 360,
+ tracks: [
+ {
+ id: 'a1',
+ name: 'A1',
+ order: 0,
+ height: 80,
+ locked: false,
+ visible: true,
+ muted: false,
+ solo: false,
+ items: [
+ {
+ id: 'audio-1',
+ trackId: 'a1',
+ type: 'audio',
+ from: 0,
+ durationInFrames: 30,
+ label: 'generated.wav',
+ src: 'blob:generated',
+ },
+ ],
+ },
+ ],
+ }
+}
+
+type WorkerBehavior = 'no-audio-context' | 'render-error' | 'runtime-error' | 'deferred-probe'
+
+function installWorker(behavior: WorkerBehavior) {
+ const messages: string[] = []
+ const instances: FakeWorker[] = []
+
+ class FakeWorker {
+ onmessage: ((event: MessageEvent) => void) | null = null
+ onerror: ((event: ErrorEvent) => void) | null = null
+ terminate = vi.fn()
+
+ constructor() {
+ instances.push(this)
+ }
+
+ postMessage(message: { type: string; requestId: string }) {
+ messages.push(message.type)
+ queueMicrotask(() => {
+ if (message.type === 'probe') {
+ if (behavior === 'deferred-probe') return
+ this.onmessage?.({
+ data: {
+ type: 'capabilities',
+ requestId: message.requestId,
+ capabilities: { offlineAudioContext: behavior !== 'no-audio-context' },
+ },
+ } as MessageEvent)
+ return
+ }
+ if (message.type !== 'start') return
+ if (behavior === 'runtime-error') {
+ this.onerror?.({
+ message: 'worker crashed',
+ filename: 'export-render.worker.ts',
+ lineno: 12,
+ colno: 3,
+ } as ErrorEvent)
+ return
+ }
+ this.onmessage?.({
+ data: { type: 'error', requestId: message.requestId, error: 'ENCODER_FAILED:primary' },
+ } as MessageEvent)
+ })
+ }
+ }
+
+ vi.stubGlobal('Worker', FakeWorker as unknown as typeof Worker)
+ return { messages, instances }
+}
+
+describe('runRender worker capability routing', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ renderMocks.renderComposition.mockResolvedValue(result)
+ renderMocks.renderAudioOnly.mockResolvedValue(result)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('preflights worker audio capability and routes normally to the main thread without errors', async () => {
+ const { messages, instances } = installWorker('no-audio-context')
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+
+ const outcome = await runRender({
+ clientSettings: settings,
+ exportMode: 'video',
+ composition: compositionWithAudio(),
+ signal: new AbortController().signal,
+ onProgress: vi.fn(),
+ })
+
+ expect(messages).toEqual(['probe'])
+ expect(outcome).toEqual({
+ result,
+ renderPath: 'main-thread',
+ fallbackReason: 'WORKER_REQUIRES_MAIN_THREAD:audio-context',
+ })
+ expect(renderMocks.renderComposition).toHaveBeenCalledTimes(1)
+ expect(instances[0]?.terminate).toHaveBeenCalledTimes(1)
+ expect(consoleError).not.toHaveBeenCalled()
+ expect(consoleWarn).not.toHaveBeenCalled()
+ })
+
+ it('preserves a genuine worker render failure as the primary error', async () => {
+ const { messages, instances } = installWorker('render-error')
+
+ await expect(
+ runRender({
+ clientSettings: settings,
+ exportMode: 'video',
+ composition: compositionWithAudio(),
+ signal: new AbortController().signal,
+ onProgress: vi.fn(),
+ }),
+ ).rejects.toThrow('ENCODER_FAILED:primary')
+
+ expect(messages).toEqual(['probe', 'start'])
+ expect(renderMocks.renderComposition).not.toHaveBeenCalled()
+ expect(instances[0]?.terminate).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not disguise an actual worker runtime crash as a capability fallback', async () => {
+ installWorker('runtime-error')
+
+ await expect(
+ runRender({
+ clientSettings: settings,
+ exportMode: 'video',
+ composition: compositionWithAudio(),
+ signal: new AbortController().signal,
+ onProgress: vi.fn(),
+ }),
+ ).rejects.toThrow('EXPORT_WORKER_RUNTIME_ERROR:worker crashed @export-render.worker.ts:12:3')
+ expect(renderMocks.renderComposition).not.toHaveBeenCalled()
+ })
+
+ it('settles cancellation during the capability probe without starting render work', async () => {
+ const { messages, instances } = installWorker('deferred-probe')
+ const controller = new AbortController()
+ const unhandledRejections: unknown[] = []
+ const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason)
+ process.on('unhandledRejection', onUnhandledRejection)
+ const pending = runRender({
+ clientSettings: settings,
+ exportMode: 'video',
+ composition: compositionWithAudio(),
+ signal: controller.signal,
+ onProgress: vi.fn(),
+ })
+ const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
+
+ await vi.waitFor(() => expect(messages).toEqual(['probe']))
+ controller.abort()
+
+ try {
+ await rejection
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(messages).toEqual(['probe', 'cancel'])
+ expect(renderMocks.renderComposition).not.toHaveBeenCalled()
+ expect(instances[0]?.terminate).toHaveBeenCalledTimes(1)
+ expect(unhandledRejections).toEqual([])
+ } finally {
+ process.off('unhandledRejection', onUnhandledRejection)
+ }
+ })
+
+ it('routes a missing Worker capability directly to the main thread', async () => {
+ vi.stubGlobal('Worker', undefined)
+
+ const outcome = await runRender({
+ clientSettings: settings,
+ exportMode: 'video',
+ composition: compositionWithAudio(),
+ signal: new AbortController().signal,
+ onProgress: vi.fn(),
+ })
+
+ expect(outcome).toEqual({
+ result,
+ renderPath: 'main-thread',
+ fallbackReason: 'WORKER_UNAVAILABLE',
+ })
+ expect(renderMocks.renderComposition).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/features/export/utils/render-pipeline.ts b/src/features/export/utils/render-pipeline.ts
index dcfd3bc0e..a91b50ca7 100644
--- a/src/features/export/utils/render-pipeline.ts
+++ b/src/features/export/utils/render-pipeline.ts
@@ -165,18 +165,33 @@ export interface RunRenderOutcome {
type ExportWorkerManager = ReturnType>
+type WorkerRenderOutcome =
+ | { kind: 'complete'; result: ClientRenderResult }
+ | { kind: 'requires-main-thread'; reason: string }
+
+function itemHasAudibleMedia(item: CompositionInputProps['tracks'][number]['items'][number]) {
+ if (item.type !== 'audio' && item.type !== 'video') return false
+ return !('muted' in item) || item.muted !== true
+}
+
+function compositionHasAudibleMedia(composition: CompositionInputProps): boolean {
+ return (composition.tracks ?? []).some(
+ (track) => !track.muted && (track.items ?? []).some(itemHasAudibleMedia),
+ )
+}
+
function renderInWorker(
workerManager: ExportWorkerManager,
clientSettings: ClientExportSettings,
composition: CompositionInputProps,
signal: AbortSignal,
onProgress: (progress: RenderProgress) => void,
-): Promise {
+): Promise {
if (typeof Worker === 'undefined') {
return Promise.reject(new Error('WORKER_UNAVAILABLE'))
}
- return new Promise((resolve, reject) => {
+ return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException('Render cancelled', 'AbortError'))
return
@@ -184,51 +199,90 @@ function renderInWorker(
const requestId = `export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const worker = workerManager.getWorker()
+ let startPosted = false
+ let settled = false
+
+ const cleanup = () => signal.removeEventListener('abort', onAbort)
+ const resolveOnce = (outcome: WorkerRenderOutcome) => {
+ if (settled) return
+ settled = true
+ cleanup()
+ resolve(outcome)
+ }
+ const rejectOnce = (error: Error) => {
+ if (settled) return
+ settled = true
+ cleanup()
+ reject(error)
+ }
const onAbort = () => {
const cancelMessage: ExportRenderWorkerRequest = { type: 'cancel', requestId }
worker.postMessage(cancelMessage)
+ rejectOnce(new DOMException('Render cancelled', 'AbortError'))
}
- const cleanup = () => signal.removeEventListener('abort', onAbort)
signal.addEventListener('abort', onAbort, { once: true })
- worker.onmessage = (event: MessageEvent) => {
+ const startRender = () => {
+ if (startPosted) return
+ startPosted = true
+ const startMessage: ExportRenderWorkerRequest = {
+ type: 'start',
+ requestId,
+ settings: clientSettings,
+ composition,
+ }
+ worker.postMessage(startMessage)
+ }
+
+ const handleCapabilities = (
+ response: Extract,
+ ) => {
+ if (compositionHasAudibleMedia(composition) && !response.capabilities.offlineAudioContext) {
+ resolveOnce({
+ kind: 'requires-main-thread',
+ reason: 'WORKER_REQUIRES_MAIN_THREAD:audio-context',
+ })
+ return
+ }
+ startRender()
+ }
+
+ const handleWorkerMessage = (event: MessageEvent) => {
+ if (settled || event.data.requestId !== requestId) return
const response = event.data
- if (response.requestId !== requestId) return
switch (response.type) {
+ case 'capabilities':
+ handleCapabilities(response)
+ return
case 'progress':
onProgress(response.progress)
- break
+ return
case 'complete':
- cleanup()
- resolve(response.result)
- break
+ resolveOnce({ kind: 'complete', result: response.result })
+ return
case 'cancelled':
- cleanup()
- reject(new DOMException('Render cancelled', 'AbortError'))
- break
+ rejectOnce(new DOMException('Render cancelled', 'AbortError'))
+ return
case 'error':
- cleanup()
- reject(new Error(response.error))
- break
+ if (response.error.startsWith('WORKER_REQUIRES_MAIN_THREAD:')) {
+ resolveOnce({ kind: 'requires-main-thread', reason: response.error })
+ } else {
+ rejectOnce(new Error(response.error))
+ }
}
}
+ worker.onmessage = handleWorkerMessage
worker.onerror = (event: ErrorEvent) => {
- cleanup()
const location = event.filename ? ` @${event.filename}:${event.lineno}:${event.colno}` : ''
- reject(new Error(`EXPORT_WORKER_RUNTIME_ERROR:${event.message}${location}`))
+ rejectOnce(new Error(`EXPORT_WORKER_RUNTIME_ERROR:${event.message}${location}`))
}
- const startMessage: ExportRenderWorkerRequest = {
- type: 'start',
- requestId,
- settings: clientSettings,
- composition,
- }
- worker.postMessage(startMessage)
+ const capabilityMessage: ExportRenderWorkerRequest = { type: 'probe', requestId }
+ worker.postMessage(capabilityMessage)
})
}
@@ -257,6 +311,17 @@ export async function runRender({
signal,
onProgress,
}: RunRenderArgs): Promise {
+ if (typeof Worker === 'undefined') {
+ const result = await renderOnMainThread(
+ exportMode,
+ clientSettings,
+ composition,
+ signal,
+ onProgress,
+ )
+ return { result, renderPath: 'main-thread', fallbackReason: 'WORKER_UNAVAILABLE' }
+ }
+
const workerManager = createManagedWorker({
createWorker: () =>
new Worker(new URL('../workers/export-render.worker.ts', import.meta.url), {
@@ -269,27 +334,17 @@ export async function runRender({
})
try {
- const result = await renderInWorker(
+ const workerOutcome = await renderInWorker(
workerManager,
clientSettings,
composition,
signal,
onProgress,
)
- return { result, renderPath: 'worker' }
- } catch (workerError) {
- if (workerError instanceof DOMException && workerError.name === 'AbortError') {
- throw workerError
+ if (workerOutcome.kind === 'complete') {
+ return { result: workerOutcome.result, renderPath: 'worker' }
}
- const workerMessage = workerError instanceof Error ? workerError.message : String(workerError)
- const shouldFallbackToMainThread =
- workerMessage.startsWith('WORKER_REQUIRES_MAIN_THREAD:') ||
- workerMessage.startsWith('WORKER_UNAVAILABLE') ||
- workerMessage.startsWith('EXPORT_WORKER_RUNTIME_ERROR:')
-
- if (!shouldFallbackToMainThread) throw workerError
-
const result = await renderOnMainThread(
exportMode,
clientSettings,
@@ -297,7 +352,11 @@ export async function runRender({
signal,
onProgress,
)
- return { result, renderPath: 'main-thread', fallbackReason: workerMessage }
+ return {
+ result,
+ renderPath: 'main-thread',
+ fallbackReason: workerOutcome.reason,
+ }
} finally {
workerManager.terminate()
}
diff --git a/src/features/export/workers/export-render-worker.types.ts b/src/features/export/workers/export-render-worker.types.ts
index 01f7af43c..bd863c1cc 100644
--- a/src/features/export/workers/export-render-worker.types.ts
+++ b/src/features/export/workers/export-render-worker.types.ts
@@ -17,7 +17,23 @@ export interface ExportRenderCancelRequest {
requestId: string
}
-export type ExportRenderWorkerRequest = ExportRenderStartRequest | ExportRenderCancelRequest
+export interface ExportRenderCapabilityRequest {
+ type: 'probe'
+ requestId: string
+}
+
+export type ExportRenderWorkerRequest =
+ | ExportRenderStartRequest
+ | ExportRenderCancelRequest
+ | ExportRenderCapabilityRequest
+
+export interface ExportRenderCapabilityResponse {
+ type: 'capabilities'
+ requestId: string
+ capabilities: {
+ offlineAudioContext: boolean
+ }
+}
export interface ExportRenderProgressResponse {
type: 'progress'
@@ -43,6 +59,7 @@ export interface ExportRenderErrorResponse {
}
export type ExportRenderWorkerResponse =
+ | ExportRenderCapabilityResponse
| ExportRenderProgressResponse
| ExportRenderCompleteResponse
| ExportRenderCancelledResponse
diff --git a/src/features/export/workers/export-render.worker.ts b/src/features/export/workers/export-render.worker.ts
index 9d779d411..c0f15d631 100644
--- a/src/features/export/workers/export-render.worker.ts
+++ b/src/features/export/workers/export-render.worker.ts
@@ -86,6 +86,18 @@ function compositionHasAudio(
self.onmessage = async (event: MessageEvent) => {
const message = event.data
+ if (message.type === 'probe') {
+ const capabilities: ExportRenderWorkerResponse = {
+ type: 'capabilities',
+ requestId: message.requestId,
+ capabilities: {
+ offlineAudioContext: typeof OfflineAudioContext !== 'undefined',
+ },
+ }
+ self.postMessage(capabilities)
+ return
+ }
+
if (message.type === 'cancel') {
const controller = activeRequests.get(message.requestId)
if (controller) {
@@ -156,9 +168,14 @@ self.onmessage = async (event: MessageEvent) => {
const messageText = error instanceof Error ? error.message : String(error)
const stack = error instanceof Error ? error.stack : undefined
- // Surface the stack: bare mediabunny asserts report only "Assertion failed",
- // so without the stack the failing call site is invisible on the main thread.
- log.error('Export worker failed', { requestId, error: messageText, stack })
+ const isExpectedMainThreadRequirement = messageText.startsWith('WORKER_REQUIRES_MAIN_THREAD:')
+ if (isExpectedMainThreadRequirement) {
+ log.debug('Export worker capability fallback', { requestId, reason: messageText })
+ } else {
+ // Surface the stack: bare mediabunny asserts report only "Assertion failed",
+ // so without the stack the failing call site is invisible on the main thread.
+ log.error('Export worker failed', { requestId, error: messageText, stack })
+ }
// A DOM global reaching worker code is a bug in that code, not something the
// user can act on — and it used to fail the whole export. Report it as a
// main-thread fallback instead: the render still completes, and the reason
diff --git a/src/features/keyframes/components/dopesheet-editor/index.tsx b/src/features/keyframes/components/dopesheet-editor/index.tsx
index 57b347873..e91e5e375 100644
--- a/src/features/keyframes/components/dopesheet-editor/index.tsx
+++ b/src/features/keyframes/components/dopesheet-editor/index.tsx
@@ -17,7 +17,7 @@ import {
} from 'react'
import { flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
-import { useHotkeys } from 'react-hotkeys-hook'
+import { useCommandHotkey, useLocalHotkey } from '@/hooks/use-hotkey-registration'
import {
ChevronDown,
ChevronLeft,
@@ -477,14 +477,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 +905,6 @@ export const DopesheetEditor = memo(function DopesheetEditor({
showPlayhead = true,
shortcutsEnabled = false,
addKeyframeShortcutEnabled = false,
- shortcuts,
className,
}: DopesheetEditorProps) {
perfMarkRender('DopesheetEditor')
@@ -1583,13 +1574,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 +1677,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 +1928,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 +2537,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({
? propertyRowByProperty.get(selectedProperty)
: undefined
- useHotkeys(
- shortcuts?.addKeyframe ?? '',
+ useCommandHotkey(
+ 'EDIT_KEYFRAME_ADD',
(event) => {
event.preventDefault()
if (activePropertyRow) {
@@ -2571,9 +2551,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 +2562,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({
],
)
- useHotkeys(
- shortcuts?.previousKeyframe ?? '',
+ useCommandHotkey(
+ 'KEYFRAME_PREVIOUS',
(event) => {
event.preventDefault()
if (activePropertyRow) {
@@ -2594,14 +2572,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(
+ 'KEYFRAME_NEXT',
(event) => {
event.preventDefault()
if (activePropertyRow) {
@@ -2610,14 +2587,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(
+ 'KEYFRAME_TOGGLE_AUTO',
(event) => {
event.preventDefault()
if (activePropertyRow) {
@@ -2626,29 +2602,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(
+ '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 +2632,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({
[disabled, selectedRefs, onRemoveKeyframes],
)
- useHotkeys(
- 'left',
+ useLocalHotkey(
+ 'DOPESHEET_NUDGE_LEFT',
(event) => {
event.preventDefault()
nudgeSelectedKeyframes(-1)
@@ -2669,8 +2642,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({
[disabled, selectedRefs.length, nudgeSelectedKeyframes],
)
- useHotkeys(
- 'right',
+ useLocalHotkey(
+ 'DOPESHEET_NUDGE_RIGHT',
(event) => {
event.preventDefault()
nudgeSelectedKeyframes(1)
@@ -2679,8 +2652,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 +2662,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 f3b1f2b92..96528bb66 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(
{
height={240}
onAddKeyframe={onAddKeyframe}
shortcutsEnabled
- shortcuts={{
- addKeyframe: "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(
{
onAddKeyframe={onAddKeyframe}
onRemoveKeyframes={onRemoveKeyframes}
shortcutsEnabled
- shortcuts={{
- addKeyframe: "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(
{
height={240}
onAddKeyframe={onAddKeyframe}
shortcutsEnabled={false}
- shortcuts={{
- addKeyframe: "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(
{
onNavigateToKeyframe={onNavigateToKeyframe}
shortcutsEnabled={false}
addKeyframeShortcutEnabled
- shortcuts={{
- addKeyframe: "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()
+ })
+
+ it('does not add a keyframe on plain K', () => {
+ const onAddKeyframe = vi.fn()
+
+ render(
+ ,
+ )
+
+ fireEvent.keyDown(document, { key: 'k', code: 'KeyK' })
- expect(onAddKeyframe).toHaveBeenCalledWith("x", 24);
- expect(onNavigateToKeyframe).not.toHaveBeenCalled();
- });
-});
+ expect(onAddKeyframe).not.toHaveBeenCalled()
+ })
+})
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 a90c96756..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
}
@@ -190,6 +238,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 +259,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 +273,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 +305,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/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 be9e1e115..05b7ca6c3 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 { useBlobUrlEpoch } 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 blobUrlEpoch = useBlobUrlEpoch(mediaId)
const zoom = usePlaybackStore((s) => s.zoom)
const mediaWidth = media?.width || 640
const mediaHeight = media?.height || 360
- useEffect(() => {
- let cancelled = false
+ useLayoutEffect(() => {
setBlobUrl('')
+ }, [blobUrlEpoch, mediaId])
+ useEffect(() => {
+ let cancelled = false
resolveMediaUrl(mediaId)
.then((url) => {
if (!cancelled) {
@@ -74,7 +78,7 @@ const InlineSourcePreviewContent = memo(function InlineSourcePreviewContent({
return () => {
cancelled = true
}
- }, [mediaId])
+ }, [blobUrlEpoch, 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
new file mode 100644
index 000000000..80ede9aec
--- /dev/null
+++ b/src/features/preview/components/source-composition.generation.test.tsx
@@ -0,0 +1,342 @@
+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()
+ const replacementDraw = deferred()
+ decoderHarness.createImageBitmap.mockReturnValueOnce(bitmapCompletion.promise)
+ decoderHarness.extractors.set('blob:old', makeExtractor())
+ const replacementExtractor = makeExtractor()
+ replacementExtractor.drawFrame.mockReturnValue(replacementDraw.promise)
+ decoderHarness.extractors.set('blob:new', replacementExtractor)
+
+ 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')
+ 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 () => {
+ 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 8fcc5cfb7..d1328f21d 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,
@@ -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 (
-
+
)
}
@@ -175,8 +194,10 @@ 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 renderInFlightGenerationRef = useRef(null)
+ const pumpLatestDecodedFrameRef = useRef<() => void>(() => {})
const pendingTimeRef = useRef(null)
const latestTargetTimeRef = useRef(0)
const consecutiveDecodeFailuresRef = useRef(0)
@@ -246,7 +267,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 +291,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 +412,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) {
@@ -382,10 +434,21 @@ 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
setHasDecodedFrame(true)
setDecodedFrameKey((prev) => (prev === cacheKey ? prev : cacheKey))
+ return true
}
const cache = frameCacheRef.current
const cacheOrder = frameCacheOrderRef.current
@@ -398,8 +461,7 @@ function VideoSource({
cacheOrder.splice(cacheIndex, 1)
cacheOrder.push(cacheKey)
}
- markDecodedFrame()
- return true
+ return markDecodedFrame()
}
const drawSharedBitmap = (bitmap: ImageBitmap): boolean => {
@@ -425,24 +487,36 @@ 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,
+ 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)
+ return false
+ }
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
+ }
cache.set(cacheKey, bitmap)
cacheOrder.push(cacheKey)
while (cacheOrder.length > SOURCE_MONITOR_FRAME_CACHE_MAX) {
@@ -457,19 +531,42 @@ function VideoSource({
// Cache population is best-effort only.
}
- markDecodedFrame()
- return true
+ return markDecodedFrame()
},
[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 (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 &&
@@ -479,28 +576,17 @@ 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 {
- renderInFlightRef.current = false
+ if (renderInFlightGenerationRef.current === generation) {
+ renderInFlightGenerationRef.current = null
+ }
if (
+ renderInFlightGenerationRef.current === null &&
decoderReadyRef.current &&
pendingTimeRef.current !== null &&
mountedRef.current &&
@@ -508,14 +594,15 @@ function VideoSource({
) {
queueMicrotask(() => {
if (!mountedRef.current) return
- pumpLatestDecodedFrame()
+ pumpLatestDecodedFrameRef.current()
})
}
}
}
void run()
- }, [drawDecodedFrame, queueDirectionalPrewarm])
+ }, [commitDecodedFrameResult, drawDecodedFrame])
+ pumpLatestDecodedFrameRef.current = pumpLatestDecodedFrame
// Acquire/release pooled element when source changes.
useEffect(() => {
@@ -579,12 +666,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 +691,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))
})
@@ -826,6 +926,7 @@ function ImageSource({ src }: { src: string }) {
return (
({
+ 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,
@@ -63,6 +75,30 @@ 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',
+ },
+}))
+
+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}>,
@@ -78,7 +114,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,7 +141,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: () =>
+ 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', () => {
@@ -133,7 +196,11 @@ vi.mock('@/features/preview/deps/settings', () => {
{ getState: () => settingsState },
)
- return { useSettingsStore }
+ return {
+ useSettingsStore,
+ useResolvedHotkeys: () => resolvedHotkeysState.hotkeys,
+ useRuntimeHotkeys: () => runtimeHotkeysState.hotkeys,
+ }
})
vi.mock('@/shared/state/editor', () => {
@@ -192,9 +259,108 @@ 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
+ 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',
+ }
+ runtimeHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys }
+ })
+
+ 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',
+ }
+ runtimeHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys }
+ 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('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())
+ 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' })
+ 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 () => {
@@ -283,4 +449,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 1063dcd78..578c5ae7c 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,
@@ -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'
@@ -70,6 +70,9 @@ import {
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 { doesHotkeyEventMatchBinding, formatHotkeyBinding } from '@/config/hotkeys'
+import { useCommandHotkeyBinding } from '@/hooks/use-hotkey-registration'
interface SourceMonitorProps {
mediaId: string
@@ -205,6 +208,8 @@ const SourceMonitorContent = memo(function SourceMonitorContent({
}: SourceMonitorProps) {
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(() => {
@@ -228,8 +233,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('')
+ }, [blobUrlEpoch, 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 +253,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({
return () => {
cancelled = true
}
- }, [mediaId])
+ }, [blobUrlEpoch, mediaId])
if (!media) return null
@@ -253,7 +264,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 (
{}}>
@@ -277,6 +287,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({
interactive={interactive}
seekFrame={seekFrame}
onClose={onClose}
+ hotkeys={hotkeys}
/>
@@ -300,6 +311,7 @@ interface SourceMonitorInnerProps {
interactive: boolean
seekFrame: number | null
onClose?: () => void
+ hotkeys: ReturnType
}
function SourceMonitorInner({
@@ -316,6 +328,7 @@ function SourceMonitorInner({
interactive,
seekFrame,
onClose,
+ hotkeys,
}: SourceMonitorInnerProps) {
const containerRef = useRef(null)
const contentHostRef = useRef(null)
@@ -440,6 +453,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(
@@ -448,21 +464,21 @@ function SourceMonitorInner({
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
const { currentSourceFrame, setInPoint, setOutPoint, clearInOutPoints } =
useSourcePlayerStore.getState()
- if (e.key === 'i' || e.key === 'I') {
+ if (doesHotkeyEventMatchBinding(e, markInHotkey)) {
e.preventDefault()
e.stopPropagation()
setInPoint(currentSourceFrame)
- } else if (e.key === 'o' || e.key === 'O') {
+ } else if (doesHotkeyEventMatchBinding(e, markOutHotkey)) {
e.preventDefault()
e.stopPropagation()
setOutPoint(getExclusiveSourceOutPoint(currentSourceFrame, durationInFrames))
- } else if (e.altKey && (e.key === 'x' || e.key === 'X')) {
+ } else if (doesHotkeyEventMatchBinding(e, clearInOutHotkey)) {
e.preventDefault()
e.stopPropagation()
clearInOutPoints()
}
},
- [durationInFrames, interactive],
+ [clearInOutHotkey, durationInFrames, interactive, markInHotkey, markOutHotkey],
)
const handleMouseEnter = useCallback(() => {
@@ -544,6 +560,7 @@ function SourceMonitorInner({
hasAudio={hasAudio}
interactive={interactive}
seekFrame={seekFrame}
+ hotkeys={hotkeys}
/>
)
@@ -558,6 +575,7 @@ function SourcePlaybackControls({
hasAudio,
interactive,
seekFrame,
+ hotkeys,
}: {
durationInFrames: number
fps: number
@@ -565,6 +583,7 @@ function SourcePlaybackControls({
hasAudio: boolean
interactive: boolean
seekFrame: number | null
+ hotkeys: ReturnType
}) {
const clock = useClock()
const player = usePlayer(durationInFrames)
@@ -587,6 +606,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
@@ -1289,12 +1309,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)})
@@ -1306,12 +1326,14 @@ 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)})
+
@@ -1323,12 +1345,14 @@ 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)})
+
)}
@@ -1371,12 +1395,14 @@ 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)})
+
@@ -1388,12 +1414,14 @@ 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)})
+
@@ -1404,7 +1432,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 ? (
@@ -1413,7 +1441,9 @@ function SourcePlaybackControls({
)}
- {playing ? 'Pause' : 'Play'} (Space)
+
+ {playing ? 'Pause' : 'Play'} ({shortcutLabel(hotkeys.PLAY_PAUSE)})
+
@@ -1425,12 +1455,14 @@ 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)})
+
@@ -1442,12 +1474,14 @@ 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)})
+
@@ -1545,12 +1579,14 @@ 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)})
+
@@ -1562,12 +1598,14 @@ 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/components/video-preview.sync.test.tsx b/src/features/preview/components/video-preview.sync.test.tsx
index c47e53631..08fa70f36 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
@@ -104,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,
}
})
@@ -257,6 +293,7 @@ vi.mock('@/infrastructure/browser/blob-url-manager', async () => {
return {
blobUrlManager: {
get: (mediaId: string) => mockState.blobUrls.get(mediaId) ?? null,
+ 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),
@@ -274,9 +311,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
@@ -534,6 +571,61 @@ 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 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 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,
@@ -903,12 +995,14 @@ describe('VideoPreview sync behavior', () => {
completeDeferredPlayerSeek = null
lastPlayerDimensions = null
playerDimensionsHistory = []
+ rendererMockState.renderedSources.length = 0
seekToMock.mockReset()
playMock.mockReset()
pauseMock.mockReset()
lastCompositionKeyframes = []
lastCompositionMediaSources = []
mockBlobUrls.clear()
+ mockMediaEpochs.clear()
blobUrlListeners.clear()
mockBlobUrlVersion.current = 0
resolveMediaUrlMock.mockClear()
@@ -1464,6 +1558,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',
@@ -1962,6 +2144,399 @@ 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('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('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',
@@ -4281,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/components/video-preview.tsx b/src/features/preview/components/video-preview.tsx
index 0405cabb2..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
*
@@ -123,13 +144,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,
@@ -291,6 +317,7 @@ const VideoPreviewBase = memo(function VideoPreviewBase({
fastScrubInputProps,
fastScrubPreviewItems,
fastScrubTracksTopologyFingerprint,
+ sourceBindingIdentity,
getPreviewTransformOverride,
getPreviewEffectsOverride,
getPreviewCornerPinOverride,
@@ -377,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,
@@ -391,6 +419,7 @@ const VideoPreviewBase = memo(function VideoPreviewBase({
project.width,
renderSize.height,
renderSize.width,
+ sourceBindingIdentity,
useProxy,
],
)
@@ -404,12 +433,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 +458,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 +495,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 +552,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.
@@ -703,6 +743,41 @@ 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(() => {
+ // 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')
+ 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()
+ }, [
+ disposeFastScrubRenderer,
+ fastScrubRendererStructureKey,
+ hideFastScrubOverlay,
+ hidePlaybackTransitionOverlay,
+ scrubCanvasRef,
+ showFastScrubOverlayForFrame,
+ showFastScrubOverlayRef,
+ showPlaybackTransitionOverlayForFrame,
+ showPlaybackTransitionOverlayRef,
+ ])
+
useEffect(() => {
if (!shouldWarmGpuEffectsRenderer || isResolving) return
@@ -863,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`
@@ -870,34 +953,43 @@ 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
+ if (!isComparisonSourceBindingReady) {
+ splitAfterPendingFrameRef.current = null
+ setSplitAfterPresentation((presentation) => (presentation === null ? presentation : null))
+ 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] })
@@ -905,36 +997,56 @@ 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,
+ isComparisonSourceBindingReady,
livePreviewEdits,
stageColorGradeComparisonMode,
])
- useEffect(() => () => disposeSplitAfterRenderer(), [disposeSplitAfterRenderer])
-
const livePlayerFrame = playerRef.current?.getCurrentFrame()
const normalizedLivePlayerFrame =
livePlayerFrame === undefined || !Number.isFinite(livePlayerFrame)
@@ -944,9 +1056,11 @@ const VideoPreviewBase = memo(function VideoPreviewBase({
const isColorGradeComparisonActive = stageColorGradeComparisonMode !== 'off'
const isSplitGradeComparison = stageColorGradeComparisonMode === 'split'
const isColorGradeComparisonFrameReady =
+ isComparisonSourceBindingReady &&
comparisonDisplayedFrame === comparisonTargetFrame &&
(isSplitGradeComparison
- ? splitAfterRenderedFrame === comparisonTargetFrame
+ ? splitAfterPresentation?.frame === comparisonTargetFrame &&
+ splitAfterPresentation.structureKey === fastScrubRendererStructureKey
: stageColorGradeComparisonMode === 'before' ||
effectivePlayerDisplayedFrame === comparisonTargetFrame)
const stageRenderedOverlayVisible = isColorGradeComparisonActive
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/preview/hooks/use-preview-composition-model.test.ts b/src/features/preview/hooks/use-preview-composition-model.test.ts
index a1edca27c..b296527ac 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 = {
@@ -114,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')
@@ -127,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',
@@ -268,7 +435,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 +465,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 e99bb8cf2..53e7a03f9 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'
@@ -78,6 +82,7 @@ interface BuildPreviewCompositionDataParams {
previewRenderSize?: PreviewPlayerSize
resolveProxyUrlFn?: (mediaId: string) => string | null
getBlobUrlFn?: (mediaId: string) => string | null
+ authoritativeSourceUrls?: ReadonlyMap
}
interface UsePreviewCompositionModelParams {
@@ -101,6 +106,63 @@ interface UsePreviewCompositionBaseModelParams {
mediaById: Record[0]>
}
+interface PreviewSourceBindings {
+ identity: string
+ urls: ReadonlyMap
+}
+
+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 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 }
+}
+
/**
* 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 +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 buildPreviewSourceBindings({
+ tracks: combinedTracks,
+ compositionById,
+ getEpoch: (mediaId) => blobUrlManager.getEpoch(mediaId),
+ getUrl: (mediaId) => blobUrlManager.get(mediaId),
+ })
+ }, [blobUrlVersion, combinedTracks, compositionById])
const {
playbackVideoSourceSpans,
scrubVideoSourceSpans,
@@ -252,6 +328,7 @@ export function usePreviewCompositionModel({
blobUrlVersion,
project,
previewRenderSize,
+ authoritativeSourceUrls: sourceBindings.urls,
})
}, [
blobUrlVersion,
@@ -264,6 +341,7 @@ export function usePreviewCompositionModel({
previewRenderSize,
proxyReadyCount,
resolvedUrls,
+ sourceBindings.urls,
transitions,
useProxy,
])
@@ -374,6 +452,7 @@ export function usePreviewCompositionModel({
fastScrubInputProps,
fastScrubPreviewItems,
fastScrubTracksTopologyFingerprint,
+ sourceBindingIdentity: sourceBindings.identity,
getPreviewTransformOverride,
getPreviewEffectsOverride,
getPreviewCornerPinOverride,
@@ -397,6 +476,7 @@ export function buildPreviewCompositionData({
previewRenderSize,
resolveProxyUrlFn = resolveProxyUrl,
getBlobUrlFn = (mediaId: string) => blobUrlManager.get(mediaId),
+ authoritativeSourceUrls,
}: BuildPreviewCompositionDataParams) {
void blobUrlVersion
const resolvedTrackList: CompositionInputProps['tracks'] = []
@@ -423,9 +503,13 @@ export function buildPreviewCompositionData({
continue
}
- const sourceUrl = resolvedUrls.get(item.mediaId) ?? getBlobUrlFn(item.mediaId) ?? ''
+ const sourceUrl = authoritativeSourceUrls
+ ? (authoritativeSourceUrls.get(item.mediaId) ?? '')
+ : (getBlobUrlFn(item.mediaId) ?? resolvedUrls.get(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
@@ -509,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-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