diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9c402f02c..0145f2733 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -339,6 +339,11 @@ async function writeSmokeExportReport( const SMOKE_EXPORT_READY_TIMEOUT_MS = 30_000; const DEFAULT_MP4_EXPORT_FRAME_RATE: ExportMp4FrameRate = 30; const SOURCE_AUDIO_FALLBACK_TOAST_ID = "source-audio-fallback-error"; +const SOURCE_AUDIO_PREVIEW_PLAYING_SEEK_DRIFT_SECONDS = 0.18; +const SOURCE_AUDIO_PREVIEW_PAUSED_SEEK_DRIFT_SECONDS = 0.01; +const SOURCE_AUDIO_PREVIEW_RATE_TOLERANCE_SECONDS = 0.08; +const SOURCE_AUDIO_PREVIEW_RATE_CORRECTION_WINDOW_SECONDS = 8; +const SOURCE_AUDIO_PREVIEW_MAX_RATE_ADJUSTMENT = 0.015; const PROJECT_AUTOSAVE_DELAY_MS = 1000; const EXPORT_ERROR_TOAST_DURATION_MS = 20000; @@ -439,7 +444,9 @@ function getSmokeExportConfig(search: string): SmokeExportConfig { : enabled && params.get("smokeBackendPreference") === "breeze" ? "breeze" : undefined, - renderBackend: enabled ? parseSmokeRenderBackend(params.get("smokeRenderBackend")) : undefined, + renderBackend: enabled + ? parseSmokeRenderBackend(params.get("smokeRenderBackend")) + : undefined, maxEncodeQueue: enabled ? parseSmokeExportNumber(params.get("smokeMaxEncodeQueue")) : undefined, @@ -595,9 +602,7 @@ export default function VideoEditor() { ); const devOpenRecordingConfig = useMemo( () => - getDevOpenRecordingConfig( - typeof window === "undefined" ? "" : window.location.search, - ), + getDevOpenRecordingConfig(typeof window === "undefined" ? "" : window.location.search), [], ); const [appPlatform, setAppPlatform] = useState( @@ -1730,16 +1735,16 @@ export default function VideoEditor() { }, [activeEffectSection]); const buildPersistedEditorState = useCallback( - ( - editor: Partial<{ - wallpaper: string; - shadowIntensity: number; - backgroundBlur: number; - zoomMotionBlur: number; - zoomMotionBlurTuning: ZoomMotionBlurTuning; - zoomTemporalMotionBlur: number; - zoomMotionBlurSampleCount: number | null; - zoomMotionBlurShutterFraction: number | null; + ( + editor: Partial<{ + wallpaper: string; + shadowIntensity: number; + backgroundBlur: number; + zoomMotionBlur: number; + zoomMotionBlurTuning: ZoomMotionBlurTuning; + zoomTemporalMotionBlur: number; + zoomMotionBlurSampleCount: number | null; + zoomMotionBlurShutterFraction: number | null; connectZooms: boolean; zoomInDurationMs: number; zoomInOverlapMs: number; @@ -2135,15 +2140,16 @@ export default function VideoEditor() { preserveProjectPath: Boolean(path), }, ); - } else { - await window.electronAPI.setCurrentVideoPath(sourcePath, { - preserveProjectPath: Boolean(path), - }); - } const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); applySessionPresentation(sessionResult?.success ? sessionResult.session : null); + } else { + await window.electronAPI.setCurrentVideoPath(sourcePath, { + preserveProjectPath: Boolean(path), + }); + applySessionPresentation(null); + } - setWallpaper(normalizedEditor.wallpaper); + setWallpaper(normalizedEditor.wallpaper); setShadowIntensity(normalizedEditor.shadowIntensity); setBackgroundBlur(normalizedEditor.backgroundBlur); setZoomMotionBlur(normalizedEditor.zoomMotionBlur); @@ -2157,10 +2163,10 @@ export default function VideoEditor() { setZoomOutDurationMs(normalizedEditor.zoomOutDurationMs); setConnectedZoomGapMs(normalizedEditor.connectedZoomGapMs); setConnectedZoomDurationMs(normalizedEditor.connectedZoomDurationMs); - setZoomInEasing(normalizedEditor.zoomInEasing); - setZoomOutEasing(normalizedEditor.zoomOutEasing); - setConnectedZoomEasing(normalizedEditor.connectedZoomEasing); - setShowCursor(normalizedEditor.showCursor); + setZoomInEasing(normalizedEditor.zoomInEasing); + setZoomOutEasing(normalizedEditor.zoomOutEasing); + setConnectedZoomEasing(normalizedEditor.connectedZoomEasing); + setShowCursor(normalizedEditor.showCursor); setLoopCursor(normalizedEditor.loopCursor); setCursorStyle(normalizedEditor.cursorStyle); setCursorSize(normalizedEditor.cursorSize); @@ -2249,7 +2255,12 @@ export default function VideoEditor() { await refreshProjectLibrary(); return true; }, - [buildPersistedEditorState, refreshProjectLibrary, syncHistoryButtons], + [ + applySessionPresentation, + buildPersistedEditorState, + refreshProjectLibrary, + syncHistoryButtons, + ], ); const currentProjectSnapshot = useMemo(() => { @@ -3907,7 +3918,7 @@ export default function VideoEditor() { ), ); }, - [applySessionPresentation], + [], ); const handleAnnotationDelete = useCallback( @@ -4326,7 +4337,9 @@ export default function VideoEditor() { const previousTimelineTime = lastSourceAudioSyncTimeRef.current; const timelineJumped = previousTimelineTime === null || Math.abs(currentTime - previousTimelineTime) > 0.25; - const driftThreshold = isPlaying ? 0.35 : 0.01; + const driftThreshold = isPlaying + ? SOURCE_AUDIO_PREVIEW_PLAYING_SEEK_DRIFT_SECONDS + : SOURCE_AUDIO_PREVIEW_PAUSED_SEEK_DRIFT_SECONDS; for (const audio of sourceAudioElementsRef.current.values()) { enablePitchPreservingPlayback(audio); @@ -4354,6 +4367,9 @@ export default function VideoEditor() { basePlaybackRate: targetPlaybackRate, currentTime: audio.currentTime, targetTime, + toleranceSeconds: SOURCE_AUDIO_PREVIEW_RATE_TOLERANCE_SECONDS, + correctionWindowSeconds: SOURCE_AUDIO_PREVIEW_RATE_CORRECTION_WINDOW_SECONDS, + maxAdjustment: SOURCE_AUDIO_PREVIEW_MAX_RATE_ADJUSTMENT, }); if (Math.abs(audio.playbackRate - syncedPlaybackRate) > 0.001) { audio.playbackRate = syncedPlaybackRate; @@ -4606,8 +4622,7 @@ export default function VideoEditor() { ? (smokeExportConfig.fps ?? settings.mp4FrameRate ?? mp4FrameRate) : (settings.mp4FrameRate ?? mp4FrameRate); const pipelineModel = smokeExportConfig.enabled - ? (smokeExportConfig.pipelineModel ?? - "modern") + ? (smokeExportConfig.pipelineModel ?? "modern") : (settings.pipelineModel ?? exportPipelineModel); const useExperimentalNativeExport = pipelineModel === "modern" && @@ -4615,12 +4630,12 @@ export default function VideoEditor() { const backendPreference = pipelineModel === "legacy" ? "webcodecs" - : useExperimentalNativeExport - ? "auto" : smokeExportConfig.enabled ? (smokeExportConfig.backendPreference ?? (smokeExportConfig.useNativeExport ? "breeze" : "webcodecs")) - : (settings.backendPreference ?? exportBackendPreference); + : useExperimentalNativeExport + ? "auto" + : (settings.backendPreference ?? exportBackendPreference); const supportedSourceDimensions = await ensureSupportedMp4SourceDimensions(selectedMp4FrameRate); const { width: exportWidth, height: exportHeight } = @@ -5368,31 +5383,35 @@ export default function VideoEditor() { ? isExportPreparing ? t("editor.exportStatus.preparing", "Preparing export...") : isExportSaving - ? t("editor.exportStatus.saving", "Opening save dialog...") - : isRenderingAudio - ? t("editor.exportStatus.renderingAudio", "Rendering audio {{percent}}%", { - percent: Math.round((exportProgress.audioProgress ?? 0) * 100), - }) - : isExportFinalizing - ? exportFormat === "mp4" && exportPipelineModel === "modern" - ? isExportFinalSaveIndeterminate - ? t( - "editor.exportStatus.muxingAndSaving", - "Muxing audio and saving file...", - ) + ? t("editor.exportStatus.saving", "Opening save dialog...") + : isRenderingAudio + ? t("editor.exportStatus.renderingAudio", "Rendering audio {{percent}}%", { + percent: Math.round((exportProgress.audioProgress ?? 0) * 100), + }) + : isExportFinalizing + ? exportFormat === "mp4" && exportPipelineModel === "modern" + ? isExportFinalSaveIndeterminate + ? t( + "editor.exportStatus.muxingAndSaving", + "Muxing audio and saving file...", + ) + : t( + "editor.exportStatus.muxingAndSavingPercent", + "Muxing and saving {{percent}}%", + { + percent: exportFinalizingPercent ?? 100, + }, + ) : t( - "editor.exportStatus.muxingAndSavingPercent", - "Muxing and saving {{percent}}%", - { - percent: exportFinalizingPercent ?? 100, - }, - ) - : t("editor.exportStatus.finalizingPercent", "Finalizing {{percent}}%", { - percent: exportFinalizingPercent ?? 100, + "editor.exportStatus.finalizingPercent", + "Finalizing {{percent}}%", + { + percent: exportFinalizingPercent ?? 100, + }, + ) + : t("editor.exportStatus.completePercent", "{{percent}}% complete", { + percent: Math.round(exportProgress.percentage), }) - : t("editor.exportStatus.completePercent", "{{percent}}% complete", { - percent: Math.round(exportProgress.percentage), - }) : t("editor.exportStatus.preparing", "Preparing export..."); const projectBrowser = ( diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts index 671c1c4bf..651bb08f0 100644 --- a/src/lib/exporter/frameRenderer.test.ts +++ b/src/lib/exporter/frameRenderer.test.ts @@ -1,7 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_WEBCAM_OVERLAY } from "../../components/video-editor/types"; -const { initializeForwardFrameSourceMock, resolveMediaElementSourceMock } = vi.hoisted(() => ({ +const { + cancelForwardFrameSourceMock, + destroyForwardFrameSourceMock, + getForwardFrameAtTimeMock, + initializeForwardFrameSourceMock, + resolveMediaElementSourceMock, +} = vi.hoisted(() => ({ + cancelForwardFrameSourceMock: vi.fn(), + destroyForwardFrameSourceMock: vi.fn(async () => undefined), + getForwardFrameAtTimeMock: vi.fn(async () => null), initializeForwardFrameSourceMock: vi.fn(async () => undefined), resolveMediaElementSourceMock: vi.fn(async () => ({ src: "blob:background", @@ -68,6 +77,9 @@ vi.mock("@/components/video-editor/videoPlayback/cursorRenderer", () => ({ vi.mock("./forwardFrameSource", () => ({ ForwardFrameSource: class { + cancel = cancelForwardFrameSourceMock; + destroy = destroyForwardFrameSourceMock; + getFrameAtTime = getForwardFrameAtTimeMock; initialize = initializeForwardFrameSourceMock; }, })); @@ -449,6 +461,7 @@ describe("FrameRenderer webcam export path", () => { }); it("prefers decoder-backed sync for video wallpapers during export", async () => { + vi.clearAllMocks(); const renderer = new FrameRenderer({ width: 1920, height: 1080, @@ -479,4 +492,46 @@ describe("FrameRenderer webcam export path", () => { expect(renderer.backgroundVideoElement).toBeNull(); expect(renderer.backgroundSprite).toBeTruthy(); }); + + it("falls back to media-element sync when video wallpaper packet streaming fails", async () => { + vi.clearAllMocks(); + initializeForwardFrameSourceMock.mockResolvedValue(undefined); + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + resolveMediaElementSourceMock.mockResolvedValueOnce({ + src: "blob:background-video", + revoke: vi.fn(), + }); + const renderer = new FrameRenderer({ + width: 1920, + height: 1080, + wallpaper: "/wallpapers/wispysky.mp4", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: false, + }, + videoWidth: 1920, + videoHeight: 1080, + }) as unknown as { + setupBackground: () => Promise; + syncBackgroundFrame: (timeSeconds: number) => Promise; + backgroundForwardFrameSource: unknown; + backgroundVideoElement: FakeVideoElement | null; + }; + + await renderer.setupBackground(); + await expect(renderer.syncBackgroundFrame(1)).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(resolveMediaElementSourceMock).toHaveBeenCalledWith("wallpapers/wispysky.mp4"); + expect(renderer.backgroundForwardFrameSource).toBeNull(); + expect(renderer.backgroundVideoElement).toBeTruthy(); + }); }); diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 8e723d68d..3a01403e8 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -153,10 +153,14 @@ type PixiRendererAttempt = { }; const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; +const BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; function isCanvasRenderer(renderer: Application): boolean { const rendererName = renderer?.renderer?.constructor?.name?.toLowerCase(); - return Boolean(rendererName && (rendererName.includes("canvasrenderer") || rendererName.includes("canvas"))); + return Boolean( + rendererName && + (rendererName.includes("canvasrenderer") || rendererName.includes("canvas")), + ); } function toErrorMessage(error: unknown): string { @@ -357,7 +361,8 @@ export class FrameRenderer { backend, ); const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); if (isCanvasRenderer(app)) { throw new Error( @@ -367,9 +372,13 @@ export class FrameRenderer { return { app, backend }; } catch (error) { const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); - failures.push({ backend, message: `${toErrorMessage(error)} (after ${elapsed}ms)` }); + failures.push({ + backend, + message: `${toErrorMessage(error)} (after ${elapsed}ms)`, + }); console.warn( `[FrameRenderer] ${backend} renderer unavailable after ${elapsed}ms; trying next backend.`, error, @@ -574,44 +583,9 @@ export class FrameRenderer { ); } - const backgroundSource = await resolveMediaElementSource(videoSrc); - this.cleanupBackgroundSource = backgroundSource.revoke; - - const video = document.createElement("video"); - video.muted = true; - video.loop = true; - video.playsInline = true; - video.preload = "auto"; - video.src = backgroundSource.src; - video.load(); - - await new Promise((resolve, reject) => { - const onReady = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - return; - } - cleanup(); - resolve(); - }; - const onError = () => { - cleanup(); - reject(new Error(`Failed to load video wallpaper: ${wallpaper}`)); - }; - const cleanup = () => { - video.removeEventListener("loadeddata", onReady); - video.removeEventListener("canplay", onReady); - video.removeEventListener("error", onError); - }; - - video.addEventListener("loadeddata", onReady); - video.addEventListener("canplay", onReady); - video.addEventListener("error", onError); - onReady(); - }); - - this.backgroundVideoElement = video; - this.lastSyncedBackgroundLoopTimeSec = null; - this.drawVideoFrameToBackground(); + if (!(await this.loadBackgroundMediaElementSource(videoSrc, wallpaper))) { + throw new Error(`Failed to load video wallpaper: ${wallpaper}`); + } this.backgroundSprite = bgCanvas; return; } @@ -828,8 +802,20 @@ export class FrameRenderer { } } - const decodedFrame = - await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + let decodedFrame: VideoFrame | null = null; + try { + decodedFrame = + await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed video wallpaper failed during export; falling back to media element sync:", + error, + ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; + } const resolvedDecodedDuration = this.backgroundForwardFrameSource.getResolvedDurationSec(); if ( @@ -865,6 +851,10 @@ export class FrameRenderer { "[FrameRenderer] Unable to wrap looping video wallpaper at decoded EOF during export:", error, ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; } } this.closeBackgroundDecodedFrame(); @@ -883,6 +873,122 @@ export class FrameRenderer { await this.syncBackgroundVideo(timeSeconds); } + private async fallbackBackgroundForwardFrameSourceToMediaElement(): Promise { + const sourceUrl = this.backgroundForwardFrameSourceUrl; + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.closeBackgroundDecodedFrame(); + this.lastSyncedBackgroundLoopTimeSec = null; + + return sourceUrl ? this.loadBackgroundMediaElementSource(sourceUrl, sourceUrl) : false; + } + + private async loadBackgroundMediaElementSource( + videoSrc: string, + errorLabel: string, + ): Promise { + if (this.backgroundVideoElement) { + try { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + } catch { + // Ignore media element teardown errors during export fallback. + } + this.backgroundVideoElement = null; + } + this.backgroundSeekPromise = null; + this.cleanupBackgroundSource?.(); + this.cleanupBackgroundSource = null; + + let backgroundSource: Awaited>; + try { + backgroundSource = await resolveMediaElementSource(videoSrc); + } catch (error) { + console.warn( + "[FrameRenderer] Unable to resolve video wallpaper fallback source:", + error, + ); + return false; + } + this.cleanupBackgroundSource = backgroundSource.revoke; + + const video = document.createElement("video"); + video.muted = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.src = backgroundSource.src; + video.load(); + + const ready = await new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | null = null; + + function cleanup() { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("error", onError); + } + + function settle(value: boolean) { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + } + function onReady() { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + settle(true); + } + function onError() { + settle(false); + } + + video.addEventListener("loadeddata", onReady); + video.addEventListener("canplay", onReady); + video.addEventListener("error", onError); + timeoutId = setTimeout(() => { + console.warn( + `[FrameRenderer] Video wallpaper media element fallback did not become ready within ${BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS}ms`, + ); + settle(false); + }, BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS); + onReady(); + }); + + if (!ready) { + console.warn(`[FrameRenderer] Failed to load video wallpaper: ${errorLabel}`); + try { + video.pause(); + video.src = ""; + video.load(); + } catch { + // Ignore media element teardown errors on failed fallback. + } + backgroundSource.revoke(); + if (this.cleanupBackgroundSource === backgroundSource.revoke) { + this.cleanupBackgroundSource = null; + } + return false; + } + + this.backgroundVideoElement = video; + this.lastSyncedBackgroundLoopTimeSec = null; + this.drawVideoFrameToBackground(); + return true; + } + private async syncBackgroundVideo(timeSeconds: number): Promise { const video = this.backgroundVideoElement; if (!video) return; diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index c0c0927ed..7df341f95 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -183,6 +183,11 @@ describe("ModernFrameRenderer blur export path", () => { beforeEach(() => { Object.assign(globalThis, { window: globalThis, + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + cancelAnimationFrame: vi.fn(), HTMLMediaElement: { HAVE_CURRENT_DATA: 2, }, @@ -232,6 +237,7 @@ describe("ModernFrameRenderer blur export path", () => { }); it("prefers decoder-backed sync for video wallpapers during export", async () => { + vi.clearAllMocks(); const renderer = new FrameRenderer({ width: 1920, height: 1080, @@ -257,6 +263,44 @@ describe("ModernFrameRenderer blur export path", () => { expect(renderer.backgroundForwardFrameSource).toBeTruthy(); expect(renderer.backgroundVideoElement).toBeNull(); }); + + it("falls back to media-element sync when video wallpaper packet streaming fails", async () => { + vi.clearAllMocks(); + initializeForwardFrameSourceMock.mockResolvedValue(undefined); + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + resolveMediaElementSourceMock.mockResolvedValueOnce({ + src: "blob:background-video", + revoke: vi.fn(), + }); + const renderer = new FrameRenderer({ + width: 1920, + height: 1080, + nativeReadbackMode: "pixels", + wallpaper: "/wallpapers/wispysky.mp4", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: false, + }, + videoWidth: 1920, + videoHeight: 1080, + }) as any; + + await renderer.setupBackground(); + await expect(renderer.syncBackgroundFrame(1)).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(resolveMediaElementSourceMock).toHaveBeenCalledWith("wallpapers/wispysky.mp4"); + expect(renderer.backgroundForwardFrameSource).toBeNull(); + expect(renderer.backgroundVideoElement).toBeTruthy(); + }); }); describe("ModernFrameRenderer webcam frame cache", () => { diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 02e648aa4..cf52faeaa 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -246,6 +246,7 @@ type PixiRendererAttempt = { const CANVAS_RENDERER_NOT_IMPLEMENTED_HINT = "CanvasRenderer is not yet implemented"; const NO_RENDERER_HINT = "no available renderer"; const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; +const BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; const WEBCAM_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; function isCanvasRenderer(application: Application): boolean { @@ -1144,44 +1145,9 @@ export class FrameRenderer { ); } - const backgroundSource = await resolveMediaElementSource(videoSrc); - this.cleanupBackgroundSource = backgroundSource.revoke; - - const video = document.createElement("video"); - video.muted = true; - video.loop = true; - video.playsInline = true; - video.preload = "auto"; - video.src = backgroundSource.src; - video.load(); - - await new Promise((resolve, reject) => { - const onReady = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - return; - } - cleanup(); - resolve(); - }; - const onError = () => { - cleanup(); - reject(new Error(`Failed to load video wallpaper: ${wallpaper}`)); - }; - const cleanup = () => { - video.removeEventListener("loadeddata", onReady); - video.removeEventListener("canplay", onReady); - video.removeEventListener("error", onError); - }; - - video.addEventListener("loadeddata", onReady); - video.addEventListener("canplay", onReady); - video.addEventListener("error", onError); - onReady(); - }); - - this.backgroundVideoElement = video; - this.lastSyncedBackgroundLoopTimeSec = null; - this.ensureBackgroundSprite(video, video.videoWidth, video.videoHeight); + if (!(await this.loadBackgroundMediaElementSource(videoSrc, wallpaper))) { + throw new Error(`Failed to load video wallpaper: ${wallpaper}`); + } return; } @@ -1853,8 +1819,20 @@ export class FrameRenderer { } } - const decodedFrame = - await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + let decodedFrame: VideoFrame | null = null; + try { + decodedFrame = + await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed video wallpaper failed during export; falling back to media element sync:", + error, + ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; + } const resolvedDecodedDuration = this.backgroundForwardFrameSource.getResolvedDurationSec(); if ( @@ -1897,6 +1875,10 @@ export class FrameRenderer { "[FrameRenderer] Unable to wrap looping video wallpaper at decoded EOF during export:", error, ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; } } this.closeBackgroundDecodedFrame(); @@ -2092,6 +2074,122 @@ export class FrameRenderer { return getRenderableAssetUrl(wallpaperAsset); } + private async fallbackBackgroundForwardFrameSourceToMediaElement(): Promise { + const sourceUrl = this.backgroundForwardFrameSourceUrl; + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.closeBackgroundDecodedFrame(); + this.lastSyncedBackgroundLoopTimeSec = null; + + return sourceUrl ? this.loadBackgroundMediaElementSource(sourceUrl, sourceUrl) : false; + } + + private async loadBackgroundMediaElementSource( + videoSrc: string, + errorLabel: string, + ): Promise { + if (this.backgroundVideoElement) { + try { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + } catch { + // Ignore media element teardown errors during export fallback. + } + this.backgroundVideoElement = null; + } + this.backgroundSeekPromise = null; + this.cleanupBackgroundSource?.(); + this.cleanupBackgroundSource = null; + + let backgroundSource: Awaited>; + try { + backgroundSource = await resolveMediaElementSource(videoSrc); + } catch (error) { + console.warn( + "[FrameRenderer] Unable to resolve video wallpaper fallback source:", + error, + ); + return false; + } + this.cleanupBackgroundSource = backgroundSource.revoke; + + const video = document.createElement("video"); + video.muted = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.src = backgroundSource.src; + video.load(); + + const ready = await new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | null = null; + + function cleanup() { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("error", onError); + } + + function settle(value: boolean) { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + } + function onReady() { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + settle(true); + } + function onError() { + settle(false); + } + + video.addEventListener("loadeddata", onReady); + video.addEventListener("canplay", onReady); + video.addEventListener("error", onError); + timeoutId = setTimeout(() => { + console.warn( + `[FrameRenderer] Video wallpaper media element fallback did not become ready within ${BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS}ms`, + ); + settle(false); + }, BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS); + onReady(); + }); + + if (!ready) { + console.warn(`[FrameRenderer] Failed to load video wallpaper: ${errorLabel}`); + try { + video.pause(); + video.src = ""; + video.load(); + } catch { + // Ignore media element teardown errors on failed fallback. + } + backgroundSource.revoke(); + if (this.cleanupBackgroundSource === backgroundSource.revoke) { + this.cleanupBackgroundSource = null; + } + return false; + } + + this.backgroundVideoElement = video; + this.lastSyncedBackgroundLoopTimeSec = null; + await this.ensureBackgroundSprite(video, video.videoWidth, video.videoHeight); + return true; + } + private disposeWebcamMediaElement(video: HTMLVideoElement): void { try { video.pause();