mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 15:55:35 +00:00
466 lines
12 KiB
TypeScript
466 lines
12 KiB
TypeScript
import type { ChildProcessByStdio } from "node:child_process";
|
|
import { execFile, spawn } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import type { Readable, Writable } from "node:stream";
|
|
import { app } from "electron";
|
|
import type { WebContents } from "electron";
|
|
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
|
|
import { buildTrimmedSourceAudioFilter, getEditedAudioExtension, getNativeVideoInputByteSize, getPreferredNativeVideoEncoders, buildNativeVideoExportArgs, parseAvailableFfmpegEncoders } from "../nativeVideoExport";
|
|
import type { NativeExportEncodingMode, NativeVideoExportFinishOptions } from "../nativeVideoExport";
|
|
import { cachedNativeVideoEncoder, setCachedNativeVideoEncoder } from "../state";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
export type NativeVideoExportSession = {
|
|
ffmpegProcess: ChildProcessByStdio<Writable, null, Readable>;
|
|
outputPath: string;
|
|
inputByteSize: number;
|
|
inputMode: "rawvideo" | "h264-stream";
|
|
maxQueuedWriteBytes: number;
|
|
stderrOutput: string;
|
|
encoderName: string;
|
|
processError: Error | null;
|
|
stdinError: Error | null;
|
|
terminating: boolean;
|
|
writeSequence: Promise<void>;
|
|
completionPromise: Promise<void>;
|
|
sender: WebContents | null;
|
|
pendingWriteRequestIds: Set<number>;
|
|
};
|
|
|
|
export const nativeVideoExportSessions = new Map<string, NativeVideoExportSession>();
|
|
|
|
export function cleanupNativeVideoExportSessions() {
|
|
for (const [sessionId, session] of nativeVideoExportSessions) {
|
|
session.terminating = true;
|
|
try {
|
|
if (!session.ffmpegProcess.stdin.destroyed) {
|
|
session.ffmpegProcess.stdin.destroy();
|
|
}
|
|
} catch {
|
|
/* stream may already be closed */
|
|
}
|
|
try {
|
|
session.ffmpegProcess.kill("SIGKILL");
|
|
} catch {
|
|
/* process may already be exited */
|
|
}
|
|
nativeVideoExportSessions.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
export function getNativeVideoExportMaxQueuedWriteBytes(inputByteSize: number) {
|
|
if (inputByteSize === 0) return 8 * 1024 * 1024;
|
|
return Math.min(64 * 1024 * 1024, Math.max(16 * 1024 * 1024, inputByteSize * 4));
|
|
}
|
|
|
|
export function isHardwareAcceleratedVideoEncoder(encoderName: string) {
|
|
return /(videotoolbox|nvenc|qsv|amf|mf)/i.test(encoderName);
|
|
}
|
|
|
|
export async function removeTemporaryExportFile(filePath: string | null | undefined) {
|
|
if (!filePath) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await fs.rm(filePath, { force: true });
|
|
} catch {
|
|
// Ignore cleanup failures for temp export artifacts.
|
|
}
|
|
}
|
|
|
|
export function getNativeVideoExportSessionError(session: NativeVideoExportSession, fallback: string) {
|
|
return (
|
|
session.stdinError?.message ||
|
|
session.processError?.message ||
|
|
session.stderrOutput.trim() ||
|
|
fallback
|
|
);
|
|
}
|
|
|
|
export function sendNativeVideoExportWriteFrameResult(
|
|
sender: WebContents | null | undefined,
|
|
sessionId: string,
|
|
requestId: number,
|
|
result: { success: boolean; error?: string },
|
|
) {
|
|
if (!sender || sender.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
sender.send("native-video-export-write-frame-result", {
|
|
sessionId,
|
|
requestId,
|
|
...result,
|
|
});
|
|
}
|
|
|
|
export function settleNativeVideoExportWriteFrameRequest(
|
|
sessionId: string,
|
|
session: NativeVideoExportSession,
|
|
requestId: number,
|
|
result: { success: boolean; error?: string },
|
|
) {
|
|
session.pendingWriteRequestIds.delete(requestId);
|
|
sendNativeVideoExportWriteFrameResult(session.sender, sessionId, requestId, result);
|
|
}
|
|
|
|
export function flushNativeVideoExportPendingWriteRequests(
|
|
sessionId: string,
|
|
session: NativeVideoExportSession,
|
|
error: string,
|
|
) {
|
|
for (const requestId of session.pendingWriteRequestIds) {
|
|
sendNativeVideoExportWriteFrameResult(session.sender, sessionId, requestId, {
|
|
success: false,
|
|
error,
|
|
});
|
|
}
|
|
|
|
session.pendingWriteRequestIds.clear();
|
|
}
|
|
|
|
export function isIgnorableNativeVideoExportStreamError(error: Error | null | undefined): boolean {
|
|
if (!error) {
|
|
return false;
|
|
}
|
|
|
|
const errno = error as NodeJS.ErrnoException;
|
|
return (
|
|
errno.code === "EPIPE" ||
|
|
errno.code === "ERR_STREAM_DESTROYED" ||
|
|
/broken pipe|stream destroyed|eof/i.test(error.message)
|
|
);
|
|
}
|
|
|
|
export async function waitForNativeVideoExportDrain(session: NativeVideoExportSession) {
|
|
if (
|
|
session.stdinError ||
|
|
session.processError ||
|
|
session.ffmpegProcess.stdin.destroyed ||
|
|
session.ffmpegProcess.stdin.writableEnded ||
|
|
!session.ffmpegProcess.stdin.writable ||
|
|
session.ffmpegProcess.stdin.writableLength <= 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
cleanup();
|
|
reject(
|
|
new Error("Timed out while waiting for native export writer backpressure to clear"),
|
|
);
|
|
}, 15000);
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timeout);
|
|
session.ffmpegProcess.stdin.off("drain", handleDrain);
|
|
session.ffmpegProcess.stdin.off("error", handleError);
|
|
session.ffmpegProcess.off("close", handleClose);
|
|
};
|
|
|
|
const handleDrain = () => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
|
|
const handleError = (error: Error) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
cleanup();
|
|
reject(
|
|
new Error(
|
|
getNativeVideoExportSessionError(
|
|
session,
|
|
"Native video export writer closed before draining",
|
|
),
|
|
),
|
|
);
|
|
};
|
|
|
|
session.ffmpegProcess.stdin.once("drain", handleDrain);
|
|
session.ffmpegProcess.stdin.once("error", handleError);
|
|
session.ffmpegProcess.once("close", handleClose);
|
|
});
|
|
}
|
|
|
|
export function getNativeVideoExportFrameLength(frameData: Uint8Array | ArrayBuffer) {
|
|
return frameData.byteLength;
|
|
}
|
|
|
|
export async function writeNativeVideoExportFrame(
|
|
session: NativeVideoExportSession,
|
|
frameData: Uint8Array | ArrayBuffer,
|
|
) {
|
|
if (session.inputMode !== "h264-stream" && getNativeVideoExportFrameLength(frameData) !== session.inputByteSize) {
|
|
throw new Error(
|
|
`Native video export expected ${session.inputByteSize} bytes per frame but received ${getNativeVideoExportFrameLength(frameData)}`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
session.stdinError ||
|
|
session.processError ||
|
|
session.ffmpegProcess.stdin.destroyed ||
|
|
session.ffmpegProcess.stdin.writableEnded ||
|
|
!session.ffmpegProcess.stdin.writable
|
|
) {
|
|
throw new Error(
|
|
getNativeVideoExportSessionError(
|
|
session,
|
|
"Native video export encoder is not accepting frames",
|
|
),
|
|
);
|
|
}
|
|
|
|
const frameBuffer =
|
|
frameData instanceof ArrayBuffer
|
|
? Buffer.from(frameData)
|
|
: Buffer.from(frameData.buffer, frameData.byteOffset, frameData.byteLength);
|
|
|
|
try {
|
|
session.ffmpegProcess.stdin.write(frameBuffer);
|
|
} catch (error) {
|
|
session.stdinError = error instanceof Error ? error : new Error(String(error));
|
|
throw session.stdinError;
|
|
}
|
|
|
|
if (session.ffmpegProcess.stdin.writableLength >= session.maxQueuedWriteBytes) {
|
|
try {
|
|
await waitForNativeVideoExportDrain(session);
|
|
} catch (error) {
|
|
session.stdinError = error instanceof Error ? error : new Error(String(error));
|
|
throw session.stdinError;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function enqueueNativeVideoExportFrameWrite(
|
|
session: NativeVideoExportSession,
|
|
frameData: Uint8Array | ArrayBuffer,
|
|
) {
|
|
const writePromise = session.writeSequence.then(async () => {
|
|
if (session.terminating) {
|
|
throw new Error("Native video export session was cancelled");
|
|
}
|
|
|
|
await writeNativeVideoExportFrame(session, frameData);
|
|
});
|
|
|
|
session.writeSequence = writePromise.catch(() => undefined);
|
|
await writePromise;
|
|
}
|
|
|
|
export async function getAvailableNativeVideoEncoders(ffmpegPath: string) {
|
|
const { stdout } = await execFileAsync(ffmpegPath, ["-hide_banner", "-encoders"], {
|
|
timeout: 15000,
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
});
|
|
|
|
return parseAvailableFfmpegEncoders(stdout);
|
|
}
|
|
|
|
export async function probeNativeVideoEncoder(
|
|
ffmpegPath: string,
|
|
encoderName: string,
|
|
encodingMode: NativeExportEncodingMode,
|
|
) {
|
|
const outputPath = path.join(
|
|
app.getPath("temp"),
|
|
`recordly-export-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
|
|
);
|
|
const args = buildNativeVideoExportArgs(
|
|
encoderName,
|
|
{
|
|
width: 64,
|
|
height: 64,
|
|
frameRate: 1,
|
|
bitrate: 1_500_000,
|
|
encodingMode,
|
|
},
|
|
outputPath,
|
|
);
|
|
|
|
return new Promise<boolean>((resolve) => {
|
|
const process = spawn(ffmpegPath, args, {
|
|
stdio: ["pipe", "ignore", "pipe"],
|
|
});
|
|
let stderrOutput = "";
|
|
const timeout = setTimeout(() => {
|
|
try {
|
|
process.kill("SIGKILL");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
resolve(false);
|
|
}, 15000);
|
|
|
|
process.stderr.on("data", (chunk: Buffer) => {
|
|
stderrOutput += chunk.toString();
|
|
});
|
|
|
|
process.on("close", (code) => {
|
|
clearTimeout(timeout);
|
|
void removeTemporaryExportFile(outputPath);
|
|
if (code !== 0 && stderrOutput.trim().length > 0) {
|
|
console.warn(
|
|
`[native-export] Encoder probe failed for ${encoderName}:`,
|
|
stderrOutput.trim(),
|
|
);
|
|
}
|
|
resolve(code === 0);
|
|
});
|
|
|
|
process.stdin.end(Buffer.alloc(getNativeVideoInputByteSize(64, 64), 0));
|
|
});
|
|
}
|
|
|
|
export async function resolveNativeVideoEncoder(
|
|
ffmpegPath: string,
|
|
encodingMode: NativeExportEncodingMode,
|
|
) {
|
|
if (
|
|
cachedNativeVideoEncoder?.ffmpegPath === ffmpegPath &&
|
|
cachedNativeVideoEncoder?.encodingMode === encodingMode
|
|
) {
|
|
return cachedNativeVideoEncoder.encoderName;
|
|
}
|
|
|
|
const availableEncoders = await getAvailableNativeVideoEncoders(ffmpegPath);
|
|
const candidates = [
|
|
...new Set([...getPreferredNativeVideoEncoders(process.platform), "libx264"]),
|
|
];
|
|
|
|
for (const encoderName of candidates) {
|
|
if (!availableEncoders.has(encoderName)) {
|
|
continue;
|
|
}
|
|
|
|
if (await probeNativeVideoEncoder(ffmpegPath, encoderName, encodingMode)) {
|
|
setCachedNativeVideoEncoder({ ffmpegPath, encodingMode, encoderName });
|
|
return encoderName;
|
|
}
|
|
}
|
|
|
|
throw new Error("No usable FFmpeg encoder was available for native export");
|
|
}
|
|
|
|
export async function muxNativeVideoExportAudio(
|
|
videoPath: string,
|
|
options: NativeVideoExportFinishOptions,
|
|
) {
|
|
const audioMode = options.audioMode ?? "none";
|
|
if (audioMode === "none") {
|
|
return videoPath;
|
|
}
|
|
|
|
const ffmpegPath = getFfmpegBinaryPath();
|
|
const tempArtifacts: string[] = [];
|
|
let audioInputPath = options.audioSourcePath ?? null;
|
|
|
|
if (audioMode === "edited-track") {
|
|
if (!options.editedAudioData) {
|
|
throw new Error("Edited audio data is missing for native export");
|
|
}
|
|
|
|
const extension = getEditedAudioExtension(options.editedAudioMimeType);
|
|
audioInputPath = path.join(
|
|
app.getPath("temp"),
|
|
`recordly-export-audio-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${extension}`,
|
|
);
|
|
await fs.writeFile(audioInputPath, Buffer.from(options.editedAudioData));
|
|
tempArtifacts.push(audioInputPath);
|
|
}
|
|
|
|
if (!audioInputPath) {
|
|
return videoPath;
|
|
}
|
|
|
|
const outputPath = path.join(
|
|
path.dirname(videoPath),
|
|
`${path.basename(videoPath, path.extname(videoPath))}-final.mp4`,
|
|
);
|
|
|
|
const args = [
|
|
"-y",
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-i",
|
|
videoPath,
|
|
"-i",
|
|
audioInputPath,
|
|
];
|
|
|
|
if (audioMode === "trim-source") {
|
|
const filter = buildTrimmedSourceAudioFilter(options.trimSegments ?? []);
|
|
if (filter) {
|
|
args.push("-filter_complex", filter, "-map", "0:v:0", "-map", "[aout]");
|
|
} else {
|
|
args.push("-map", "0:v:0", "-map", "1:a:0");
|
|
}
|
|
} else {
|
|
args.push("-map", "0:v:0", "-map", "1:a:0");
|
|
}
|
|
|
|
args.push(
|
|
"-c:v",
|
|
"copy",
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"192k",
|
|
"-shortest",
|
|
"-movflags",
|
|
"+faststart",
|
|
outputPath,
|
|
);
|
|
|
|
try {
|
|
await execFileAsync(ffmpegPath, args, {
|
|
timeout: 15 * 60 * 1000,
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
});
|
|
await removeTemporaryExportFile(videoPath);
|
|
return outputPath;
|
|
} finally {
|
|
await Promise.allSettled(
|
|
tempArtifacts.map((artifactPath) => removeTemporaryExportFile(artifactPath)),
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function muxExportedVideoAudioBuffer(
|
|
videoData: ArrayBuffer,
|
|
options: NativeVideoExportFinishOptions,
|
|
) {
|
|
const tempVideoPath = path.join(
|
|
app.getPath("temp"),
|
|
`recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
|
|
);
|
|
|
|
try {
|
|
await fs.writeFile(tempVideoPath, Buffer.from(videoData));
|
|
const finalizedPath = await muxNativeVideoExportAudio(tempVideoPath, options);
|
|
const muxedData = await fs.readFile(finalizedPath);
|
|
return new Uint8Array(muxedData);
|
|
} finally {
|
|
await Promise.allSettled([
|
|
removeTemporaryExportFile(tempVideoPath),
|
|
removeTemporaryExportFile(
|
|
path.join(
|
|
path.dirname(tempVideoPath),
|
|
`${path.basename(tempVideoPath, path.extname(tempVideoPath))}-final.mp4`,
|
|
),
|
|
),
|
|
]);
|
|
}
|
|
}
|