From 0049a743b441f0bd1952e8e490870836bcf8028c Mon Sep 17 00:00:00 2001 From: changqingdata <1151528746@qq.com> Date: Sat, 24 Oct 2026 11:03:12 +0800 Subject: [PATCH] fix(export): block browser fast export when video layers overlap on Windows Several video layers that overlap in time share Remotion's OffthreadVideo compositor and frame cache. On Windows the WebCodecs decoder stalls when more than one of them requests frames: the fast export neither advances nor reports an error, so the user can only cancel it. browserTimelineBlocker() was a stub that always returned null, so nothing stopped the browser path for those timelines. It now sweeps the clip intervals of the timeline (nested sequences included) and, when a Windows browser would run two or more video sources on the same frame, returns an explicit reason so the export falls back to the local renderer instead of hanging. The platform is a parameter with a browser-derived default, so the verify script drives the Windows, other-platform and empty-timeline cases without a DOM. Verification: npx tsc -b, npm run lint, src/export/browserExport.verify.ts --- src/export/browserExport.ts | 60 ++++++++++++++++++++++++++++-- src/export/browserExport.verify.ts | 24 ++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/export/browserExport.ts b/src/export/browserExport.ts index ebcc6c37f..e406f83b9 100644 --- a/src/export/browserExport.ts +++ b/src/export/browserExport.ts @@ -23,6 +23,7 @@ import { DEFAULT_RENDER_TIMEOUT_MS } from '../../remotion/render-timeout.mjs'; */ export const BROWSER_ENCODER_UNSUPPORTED_REASON = '浏览器编码器不支持此导出规格'; export const BROWSER_RENDER_INCOMPLETE_REASON = '浏览器快导未能完成'; +export const WINDOWS_MULTISTREAM_WEBCODECS_BLOCKER = 'Windows 上多个重叠视频的 WebCodecs 解码可能停滞,请使用本机渲染器'; // Mediabunny's messages when WebCodecs refuses the encoder the render asked for. const ENCODER_REFUSED = /encoder configuration .* is not supported|VideoEncoder is not supported|cannot be encoded by this browser/i; @@ -93,10 +94,61 @@ export function browserFallbackReason(error: unknown): string { * Non-raster sources (svg/gif) and frame-rate retiming are handled separately by * `staticBrowserBlocker`. */ -export function browserTimelineBlocker(state: TimelineState, project?: ProjectDoc, timelineId?: string): string | null { - void state; - void project; - void timelineId; +function hasOverlappingVideoSources( + state: { id?: string; items: TimelineState['items'] }, + project?: ProjectDoc, +): boolean { + const nestedTimelines = new Map(project?.timelines.map((timeline) => [timeline.id, timeline]) ?? []); + const intervals: Array<{ start: number; end: number }> = []; + + const visit = ( + timeline: { id?: string; items: TimelineState['items'] }, + offset: number, + limit: number, + path: ReadonlySet, + ) => { + for (const item of timeline.items) { + const start = offset + item.startFrame; + const end = Math.min(limit, start + item.durationInFrames); + if (end <= start) continue; + if (item.kind === 'video' && item.src) { + intervals.push({ start, end }); + } else if (item.kind === 'sequence' && item.timelineId) { + const nested = nestedTimelines.get(item.timelineId); + if (nested && !path.has(nested.id)) { + visit(nested, start, end, new Set([...path, nested.id])); + } + } + } + }; + + visit(state, 0, Number.POSITIVE_INFINITY, state.id ? new Set([state.id]) : new Set()); + const events = intervals.flatMap(({ start, end }) => [ + { frame: start, delta: 1 }, + { frame: end, delta: -1 }, + ]).sort((left, right) => left.frame - right.frame || left.delta - right.delta); + let active = 0; + for (const event of events) { + active += event.delta; + if (active > 1) return true; + } + return false; +} + +function currentBrowserPlatform(): string { + return typeof window === 'undefined' ? '' : `${window.navigator.platform} ${window.navigator.userAgent}`; +} + +export function browserTimelineBlocker( + state: TimelineState, + project?: ProjectDoc, + timelineId?: string, + platform = currentBrowserPlatform(), +): string | null { + if (/windows|win(?:32|64)/i.test(platform) + && hasOverlappingVideoSources({ ...state, id: timelineId }, project)) { + return WINDOWS_MULTISTREAM_WEBCODECS_BLOCKER; + } return null; } diff --git a/src/export/browserExport.verify.ts b/src/export/browserExport.verify.ts index 77595fe8b..caa2557b0 100644 --- a/src/export/browserExport.verify.ts +++ b/src/export/browserExport.verify.ts @@ -7,6 +7,7 @@ import { browserTimelineBlocker, exportVideoWithFallback, renderTimelineInBrowser, + WINDOWS_MULTISTREAM_WEBCODECS_BLOCKER, type BrowserExportAttempt, } from './browserExport'; import type { ExportDestination } from './exportDestination'; @@ -144,6 +145,29 @@ assert.equal(browserTimelineBlocker({ }], }), null, 'GLSL transition must not block the browser fast-export path'); +const overlappingVideos: TimelineState = { + ...state, + items: [ + { ...state.items[0]!, id: 'video_1', kind: 'video', src: '/a.mp4', startFrame: 0, durationInFrames: 90 }, + { ...state.items[0]!, id: 'video_2', kind: 'video', src: '/b.mp4', startFrame: 30, durationInFrames: 90 }, + ], +}; +assert.equal( + browserTimelineBlocker(overlappingVideos, undefined, undefined, 'Win32'), + WINDOWS_MULTISTREAM_WEBCODECS_BLOCKER, + 'Windows must keep overlapping video sources off the WebCodecs browser route', +); +assert.equal( + browserTimelineBlocker({ ...overlappingVideos, items: overlappingVideos.items.map((item) => ({ ...item, startFrame: item.id === 'video_1' ? 0 : 90 })) }, undefined, undefined, 'Win32'), + null, + 'sequential video clips do not exhaust the simultaneous WebCodecs decoder pool', +); +assert.equal( + browserTimelineBlocker(overlappingVideos, undefined, undefined, 'Linux x86_64'), + null, + 'the Windows D3D11 decoder guard must not change other platforms', +); + const capabilityCalls: Array> = []; const renderCalls: Array> = []; const progressSnapshots: number[] = [];