mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
Merge main into native GPU export PR
This commit is contained in:
Vendored
+1
-1
@@ -427,7 +427,7 @@ interface Window {
|
||||
},
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
data?: Uint8Array;
|
||||
tempPath?: string;
|
||||
error?: string;
|
||||
metrics?: RendererFfmpegAudioMuxMetrics;
|
||||
}>;
|
||||
|
||||
@@ -2,19 +2,52 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getAppPath: () => process.cwd(),
|
||||
getPath: () => process.env.TEMP ?? process.cwd(),
|
||||
getAppPath: vi.fn(() => process.cwd()),
|
||||
getPath: vi.fn(() => process.env.TEMP ?? process.cwd()),
|
||||
isPackaged: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../ffmpeg/binary", () => ({
|
||||
getFfmpegBinaryPath: () => "ffmpeg",
|
||||
getFfmpegBinaryPath: vi.fn(() => "ffmpeg"),
|
||||
}));
|
||||
|
||||
vi.mock("../state", () => ({
|
||||
cachedNativeVideoEncoder: null,
|
||||
setCachedNativeVideoEncoder: vi.fn(),
|
||||
}));
|
||||
|
||||
const fsMocks = vi.hoisted(() => ({
|
||||
access: vi.fn(async () => {
|
||||
throw new Error("missing");
|
||||
}),
|
||||
writeFile: vi.fn(async () => undefined),
|
||||
readFile: vi.fn(),
|
||||
stat: vi.fn(async () => ({ size: 5_000_000_000 })),
|
||||
unlink: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
default: fsMocks,
|
||||
...fsMocks,
|
||||
}));
|
||||
|
||||
const execFileMock = vi.hoisted(() =>
|
||||
vi.fn((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null) => void) => {
|
||||
cb(null);
|
||||
return { stdout: "", stderr: "" } as unknown;
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: execFileMock,
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildNativeVideoAudioMuxArgs,
|
||||
getNvidiaCudaAudioExportSkipReason,
|
||||
muxExportedVideoAudioBuffer,
|
||||
normalizeNativeStaticLayoutBackground,
|
||||
parseFfmpegDurationSeconds,
|
||||
parseFfmpegFrameRate,
|
||||
@@ -97,6 +130,26 @@ describe("getNvidiaCudaAudioExportSkipReason", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("muxExportedVideoAudioBuffer", () => {
|
||||
it("returns the muxed output path without reading the muxed file into memory", async () => {
|
||||
const videoData = new ArrayBuffer(64);
|
||||
const result = await muxExportedVideoAudioBuffer(videoData, { audioMode: "none" });
|
||||
|
||||
expect(typeof result.outputPath).toBe("string");
|
||||
expect(result.outputPath.length).toBeGreaterThan(0);
|
||||
// The >2 GiB fix relies on stat-only metric collection; readFile must stay unused.
|
||||
expect(fsMocks.readFile).not.toHaveBeenCalled();
|
||||
expect(result.metrics.muxedVideoBytes).toBe(5_000_000_000);
|
||||
});
|
||||
|
||||
it("preserves the input temp path when audioMode='none' (no re-mux)", async () => {
|
||||
const videoData = new ArrayBuffer(32);
|
||||
const result = await muxExportedVideoAudioBuffer(videoData, { audioMode: "none" });
|
||||
|
||||
expect(result.outputPath).toMatch(/recordly-export-video-/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNativeVideoAudioMuxArgs", () => {
|
||||
it("stream-copies source audio and preserves the requested video duration", () => {
|
||||
const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.mp4", "out.mp4", {
|
||||
@@ -172,7 +225,7 @@ describe("parseWindowsGpuExportSummary", () => {
|
||||
});
|
||||
|
||||
describe("parseNvidiaCudaExportSummary", () => {
|
||||
it("parses the pretty JSON summary emitted by the CUDA lab wrapper", () => {
|
||||
it("parses the pretty JSON summary emitted by the CUDA wrapper", () => {
|
||||
const summary = parseNvidiaCudaExportSummary(
|
||||
[
|
||||
"preflight",
|
||||
@@ -198,7 +251,7 @@ describe("parseNvidiaCudaExportSummary", () => {
|
||||
});
|
||||
|
||||
it("returns null when the wrapper output has no JSON object", () => {
|
||||
expect(parseNvidiaCudaExportSummary("native probe failed before summary")).toBeNull();
|
||||
expect(parseNvidiaCudaExportSummary("native helper failed before summary")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2843,6 +2843,8 @@ export async function muxExportedVideoAudioBuffer(
|
||||
`recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
|
||||
);
|
||||
const metrics: NativeVideoAudioMuxMetrics = {};
|
||||
let succeeded = false;
|
||||
let outputPath = tempVideoPath;
|
||||
|
||||
try {
|
||||
const tempVideoWriteStartedAt = getNowMs();
|
||||
@@ -2851,23 +2853,35 @@ export async function muxExportedVideoAudioBuffer(
|
||||
metrics.tempVideoBytes = videoData.byteLength;
|
||||
const finalized = await muxNativeVideoExportAudio(tempVideoPath, options);
|
||||
Object.assign(metrics, finalized.metrics);
|
||||
const muxedVideoReadStartedAt = getNowMs();
|
||||
const muxedData = await fs.readFile(finalized.outputPath);
|
||||
metrics.muxedVideoReadMs = getNowMs() - muxedVideoReadStartedAt;
|
||||
metrics.muxedVideoBytes = muxedData.byteLength;
|
||||
outputPath = finalized.outputPath;
|
||||
// Record byte size via stat instead of reading the whole file into a
|
||||
// Buffer — fs.readFile throws ERR_FS_FILE_TOO_LARGE on >2 GiB outputs.
|
||||
try {
|
||||
const stat = await fs.stat(outputPath);
|
||||
metrics.muxedVideoBytes = stat.size;
|
||||
} catch {
|
||||
// Stat failures are non-fatal; size is purely metric data.
|
||||
}
|
||||
succeeded = true;
|
||||
return {
|
||||
data: new Uint8Array(muxedData),
|
||||
outputPath,
|
||||
metrics,
|
||||
};
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
removeTemporaryExportFile(tempVideoPath),
|
||||
removeTemporaryExportFile(
|
||||
path.join(
|
||||
path.dirname(tempVideoPath),
|
||||
`${path.basename(tempVideoPath, path.extname(tempVideoPath))}-final.mp4`,
|
||||
),
|
||||
),
|
||||
]);
|
||||
// Always remove the unmuxed intermediate when the muxer wrote a separate
|
||||
// file. Only remove the muxed output on failure — on success the caller
|
||||
// owns it and is responsible for moving/deleting it.
|
||||
const cleanupTargets: string[] = [];
|
||||
if (outputPath !== tempVideoPath) {
|
||||
cleanupTargets.push(tempVideoPath);
|
||||
}
|
||||
if (!succeeded) {
|
||||
cleanupTargets.push(outputPath);
|
||||
}
|
||||
if (cleanupTargets.length > 0) {
|
||||
await Promise.allSettled(
|
||||
cleanupTargets.map((target) => removeTemporaryExportFile(target)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,9 +488,14 @@ export function registerExportHandlers() {
|
||||
async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => {
|
||||
try {
|
||||
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
|
||||
// muxed bytes) keeps us off Node's >2 GiB fs.readFile cap and
|
||||
// avoids a redundant copy through the renderer.
|
||||
registerOwnedExportPath(result.outputPath);
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
tempPath: result.outputPath,
|
||||
metrics: result.metrics,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
+1
-1
@@ -368,7 +368,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
) => {
|
||||
return ipcRenderer.invoke("mux-exported-video-audio", videoData, options) as Promise<{
|
||||
success: boolean;
|
||||
data?: Uint8Array;
|
||||
tempPath?: string;
|
||||
error?: string;
|
||||
metrics?: NativeVideoAudioMuxMetrics;
|
||||
}>;
|
||||
|
||||
@@ -2153,17 +2153,18 @@ export class ModernVideoExporter {
|
||||
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
|
||||
}
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
if (!result.success || !result.tempPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || "Failed to mux exported audio with FFmpeg",
|
||||
};
|
||||
}
|
||||
|
||||
const videoBytes = result.data.slice();
|
||||
// Returning a temp path (instead of buffering the muxed bytes back into
|
||||
// the renderer) is what keeps >2 GiB exports off Node's fs.readFile cap.
|
||||
return {
|
||||
success: true,
|
||||
blob: new Blob([videoBytes.buffer], { type: "video/mp4" }),
|
||||
tempFilePath: result.tempPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -981,7 +981,7 @@ export class VideoExporter {
|
||||
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
|
||||
}
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
if (!result.success || !result.tempPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || "Failed to mux exported audio with FFmpeg",
|
||||
@@ -989,11 +989,11 @@ export class VideoExporter {
|
||||
};
|
||||
}
|
||||
|
||||
const blobData = new Uint8Array(result.data.byteLength);
|
||||
blobData.set(result.data);
|
||||
// Returning a temp path (instead of buffering the muxed bytes back into
|
||||
// the renderer) is what keeps >2 GiB exports off Node's fs.readFile cap.
|
||||
return {
|
||||
success: true,
|
||||
blob: new Blob([blobData.buffer], { type: "video/mp4" }),
|
||||
tempFilePath: result.tempPath,
|
||||
metrics: this.buildExportMetrics(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user