From 4f89a6d38daa8c397ab4b4ade1cc13568abf07e8 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 8 May 2026 22:40:40 +0700 Subject: [PATCH] fix(export): keep large saves off arraybuffer path --- electron/ipc/register/export.ts | 37 ++++++++++++++ src/components/video-editor/VideoEditor.tsx | 40 ++++++++++++++- src/lib/exporter/exportSavePolicy.test.ts | 54 +++++++++++++++++++++ src/lib/exporter/exportSavePolicy.ts | 46 ++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/lib/exporter/exportSavePolicy.test.ts create mode 100644 src/lib/exporter/exportSavePolicy.ts diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index c095cf39..ede2272f 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -51,6 +51,16 @@ function getPartialExportDestinationPath(destinationPath: string) { return path.join(parsed.dir, `.recordly-partial-${parsed.name}-${suffix}${parsed.ext}`); } +const MAX_IN_MEMORY_EXPORT_BYTES = 0x7fffffff; + +function getInMemoryExportTooLargeMessage(byteLength: number) { + if (byteLength <= MAX_IN_MEMORY_EXPORT_BYTES) { + return null; + } + + return "Export is too large for the legacy in-memory save path. Please retry with temp-file streaming enabled."; +} + export async function moveExportedTempFile(tempPath: string, destinationPath: string) { await fs.mkdir(path.dirname(destinationPath), { recursive: true }); try { @@ -700,6 +710,14 @@ export function registerExportHandlers() { "mux-exported-video-audio", async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => { try { + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + error: sizeError, + }; + } + const result = await muxExportedVideoAudioBuffer(videoData, options ?? {}); // Register the muxed output so finalize-exported-video / discard- // exported-temp accept it. Returning a temp path (instead of the @@ -797,6 +815,15 @@ export function registerExportHandlers() { "save-exported-video", async (event, videoData: ArrayBuffer, fileName: string) => { try { + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + message: sizeError, + error: sizeError, + }; + } + // Determine file type from extension const isGif = fileName.toLowerCase().endsWith(".gif"); const filters = isGif @@ -845,6 +872,16 @@ export function registerExportHandlers() { "write-exported-video-to-path", async (_event, videoData: ArrayBuffer, outputPath: string) => { try { + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + message: sizeError, + canceled: false, + error: sizeError, + }; + } + const resolvedPath = path.resolve(outputPath); await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); await fs.writeFile(resolvedPath, Buffer.from(videoData)); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 0145f273..9cc3d413 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -76,6 +76,10 @@ import { VideoExporter, } from "@/lib/exporter"; import { getMp4ExportBitrate, getSourceQualityBitrate } from "@/lib/exporter/exportBitrate"; +import { + canUseInMemoryExportSaveFallback, + describeBlockedInMemoryExportSave, +} from "@/lib/exporter/exportSavePolicy"; import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource"; import { resolveSourceAudioFallbackPaths } from "@/lib/exporter/sourceAudioFallback"; import { @@ -1485,6 +1489,12 @@ export default function VideoEditor() { const saveBlobExport = useCallback( async (blob: Blob, fileName: string, outputPath: string | null = null) => { const extension = fileName.split(".").pop()?.toLowerCase() || "bin"; + const hasExportStreamApi = + typeof window !== "undefined" && + typeof window.electronAPI?.openExportStream === "function" && + typeof window.electronAPI?.writeExportStreamChunk === "function" && + typeof window.electronAPI?.closeExportStream === "function"; + let streamError: unknown = null; try { const tempFilePath = await streamExportBlobToTempFile(blob, extension); @@ -1502,9 +1512,37 @@ export default function VideoEditor() { }; } } catch (error) { - console.warn("[export] Falling back to in-memory blob save", error); + streamError = error; + console.warn("[export] Temp-file blob save failed", error); } + if ( + !canUseInMemoryExportSaveFallback({ + blobSize: blob.size, + extension, + hasExportStreamApi, + }) + ) { + const message = describeBlockedInMemoryExportSave({ + blobSize: blob.size, + extension, + }); + console.error("[export] Refusing in-memory blob save fallback", { + fileName, + blobSize: blob.size, + extension, + hasExportStreamApi, + streamError, + }); + throw new Error(message); + } + + console.warn("[export] Falling back to in-memory blob save", { + fileName, + blobSize: blob.size, + extension, + hasExportStreamApi, + }); const arrayBuffer = await blob.arrayBuffer(); return { saveResult: outputPath diff --git a/src/lib/exporter/exportSavePolicy.test.ts b/src/lib/exporter/exportSavePolicy.test.ts new file mode 100644 index 00000000..fb60c285 --- /dev/null +++ b/src/lib/exporter/exportSavePolicy.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + canUseInMemoryExportSaveFallback, + describeBlockedInMemoryExportSave, + isExportTooLargeForInMemorySave, + MAX_IN_MEMORY_EXPORT_BYTES, + normalizeExportExtension, +} from "./exportSavePolicy"; + +describe("exportSavePolicy", () => { + it("normalizes export extensions before policy checks", () => { + expect(normalizeExportExtension(" MP4 ")).toBe("mp4"); + }); + + it("blocks the legacy in-memory save path above Node's Buffer limit", () => { + expect(isExportTooLargeForInMemorySave(MAX_IN_MEMORY_EXPORT_BYTES + 1)).toBe(true); + expect( + canUseInMemoryExportSaveFallback({ + blobSize: MAX_IN_MEMORY_EXPORT_BYTES + 1, + extension: "gif", + hasExportStreamApi: false, + }), + ).toBe(false); + }); + + it("keeps Electron MP4 exports on the temp-file save path", () => { + expect( + canUseInMemoryExportSaveFallback({ + blobSize: 1024, + extension: "mp4", + hasExportStreamApi: true, + }), + ).toBe(false); + }); + + it("allows small non-MP4 exports to use the legacy save fallback", () => { + expect( + canUseInMemoryExportSaveFallback({ + blobSize: 1024, + extension: "gif", + hasExportStreamApi: false, + }), + ).toBe(true); + }); + + it("explains blocked large saves without mentioning implementation stack traces", () => { + expect( + describeBlockedInMemoryExportSave({ + blobSize: MAX_IN_MEMORY_EXPORT_BYTES + 1, + extension: "mp4", + }), + ).toContain("too large"); + }); +}); diff --git a/src/lib/exporter/exportSavePolicy.ts b/src/lib/exporter/exportSavePolicy.ts new file mode 100644 index 00000000..c48516ed --- /dev/null +++ b/src/lib/exporter/exportSavePolicy.ts @@ -0,0 +1,46 @@ +export const MAX_IN_MEMORY_EXPORT_BYTES = 0x7fffffff; + +export function normalizeExportExtension(extension: string): string { + return extension.trim().toLowerCase(); +} + +export function isExportTooLargeForInMemorySave(byteLength: number): boolean { + return byteLength > MAX_IN_MEMORY_EXPORT_BYTES; +} + +export function canUseInMemoryExportSaveFallback({ + blobSize, + extension, + hasExportStreamApi, +}: { + blobSize: number; + extension: string; + hasExportStreamApi: boolean; +}): boolean { + if (isExportTooLargeForInMemorySave(blobSize)) { + return false; + } + + // In Electron, MP4 exports should stay on the temp-file path. If that path + // failed, silently falling back to ArrayBuffer reintroduces the >2 GiB crash. + if (hasExportStreamApi && normalizeExportExtension(extension) === "mp4") { + return false; + } + + return true; +} + +export function describeBlockedInMemoryExportSave({ + blobSize, + extension, +}: { + blobSize: number; + extension: string; +}): string { + const normalizedExtension = normalizeExportExtension(extension) || "export"; + if (isExportTooLargeForInMemorySave(blobSize)) { + return `The ${normalizedExtension.toUpperCase()} export is too large to save through the legacy in-memory path. Please retry the export so Recordly can save it through the temp-file streaming path.`; + } + + return `The ${normalizedExtension.toUpperCase()} export could not be saved through the temp-file streaming path, and Recordly will not fall back to the legacy in-memory path for MP4 exports. Please retry the export.`; +}