fix(export): keep large exports off in-memory save path

Merge PR #453
This commit is contained in:
Phạm Thị Minh Hồng
2026-05-08 23:00:28 +07:00
committed by GitHub
4 changed files with 176 additions and 1 deletions
+37
View File
@@ -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));
+39 -1
View File
@@ -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
+54
View File
@@ -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");
});
});
+46
View File
@@ -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.`;
}