mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 15:33:31 +00:00
248 lines
8.1 KiB
TypeScript
248 lines
8.1 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { app } from "electron";
|
|
import { getFfprobeBinaryPath } from "../../ffmpeg/binary";
|
|
import {
|
|
buildNativeStaticLayoutChunks,
|
|
type NativeStaticLayoutExportArgsConfig,
|
|
} from "../../nativeVideoExport";
|
|
import { muxNativeVideoExportAudio } from "./audio";
|
|
import { tryNativeStaticGpuExport } from "./gpuAttempt";
|
|
import {
|
|
hasNativeStaticLayoutSourceCrop,
|
|
hasNativeStaticLayoutTimeline,
|
|
normalizeNativeStaticLayoutBackground,
|
|
shouldUsePrecompositedStaticLayout,
|
|
} from "./layout";
|
|
import { validateNativeVideoOutputFile } from "./probe";
|
|
import { nativeStaticLayoutExportSessions } from "./sessions";
|
|
import { prepareNativeStaticLayoutSourceInput } from "./sourceProxy";
|
|
import { renderNativeStaticFfmpeg } from "./staticFfmpeg";
|
|
import { removeTemporaryExportFile } from "./stream";
|
|
import { prepareNativeStaticLayoutTimelineMap } from "./timeline";
|
|
import {
|
|
type NativeStaticLayoutExportMetrics,
|
|
type NativeStaticLayoutExportOptions,
|
|
type NativeStaticLayoutExportProgress,
|
|
type NativeStaticLayoutExportSession,
|
|
} from "./types";
|
|
|
|
export async function exportNativeStaticLayoutVideo(
|
|
ffmpegPath: string,
|
|
options: NativeStaticLayoutExportOptions,
|
|
onProgress?: (progress: NativeStaticLayoutExportProgress) => void,
|
|
onOutputFile?: (filePath: string) => void,
|
|
) {
|
|
if (options.width % 2 !== 0 || options.height % 2 !== 0) {
|
|
throw new Error("Native static layout export requires even output dimensions");
|
|
}
|
|
if (!Number.isFinite(options.durationSec) || options.durationSec <= 0) {
|
|
throw new Error("Native static layout export requires a positive duration");
|
|
}
|
|
const sessionId =
|
|
options.sessionId ??
|
|
`recordly-static-layout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
if (typeof sessionId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(sessionId)) {
|
|
throw new Error("Native static layout export requires a safe session ID");
|
|
}
|
|
options = await normalizeNativeStaticLayoutBackground(options);
|
|
if (
|
|
options.webcamInputPath &&
|
|
!(options.experimentalWindowsGpuCompositor && process.platform === "win32")
|
|
) {
|
|
throw new Error("Native webcam overlay requires the Windows GPU compositor");
|
|
}
|
|
if (
|
|
options.zoomTelemetry?.length &&
|
|
!(options.experimentalWindowsGpuCompositor && process.platform === "win32")
|
|
) {
|
|
throw new Error("Native zoom telemetry requires the Windows GPU compositor");
|
|
}
|
|
if (
|
|
options.cursorTelemetry?.length &&
|
|
!(options.experimentalWindowsGpuCompositor && process.platform === "win32")
|
|
) {
|
|
throw new Error("Native cursor telemetry requires the Windows GPU compositor");
|
|
}
|
|
|
|
const chunkDurationSec = Math.max(1, Math.min(300, options.chunkDurationSec ?? 120));
|
|
const chunks = buildNativeStaticLayoutChunks(options.durationSec, chunkDurationSec);
|
|
if (chunks.length === 0) {
|
|
throw new Error("Native static layout export produced no chunks");
|
|
}
|
|
|
|
const session: NativeStaticLayoutExportSession = {
|
|
terminating: false,
|
|
currentProcess: null,
|
|
};
|
|
const chunkDirectory = path.join(app.getPath("temp"), sessionId);
|
|
const concatListPath = path.join(chunkDirectory, "chunks.txt");
|
|
const videoOnlyPath = path.join(app.getPath("temp"), `${sessionId}.mp4`);
|
|
onOutputFile?.(videoOnlyPath);
|
|
const ffprobePath = getFfprobeBinaryPath();
|
|
let outputPathToKeep: string | null = null;
|
|
let videoOutputValidated = false;
|
|
const metrics: NativeStaticLayoutExportMetrics = {
|
|
chunkCount: chunks.length,
|
|
chunkDurationSec,
|
|
chunkExecMs: 0,
|
|
fallbackChunkCount: 0,
|
|
chunks: [],
|
|
};
|
|
const validateRenderedVideoOutput = async () => {
|
|
await validateNativeVideoOutputFile(ffprobePath, videoOnlyPath, {
|
|
durationSec: options.durationSec,
|
|
targetFrames: Math.ceil(options.durationSec * options.frameRate),
|
|
});
|
|
videoOutputValidated = true;
|
|
};
|
|
|
|
try {
|
|
nativeStaticLayoutExportSessions.set(sessionId, session);
|
|
await fs.mkdir(chunkDirectory, { recursive: true });
|
|
const sourceInput = await prepareNativeStaticLayoutSourceInput(
|
|
ffmpegPath,
|
|
options,
|
|
path.join(chunkDirectory, "source-proxy.mp4"),
|
|
session,
|
|
);
|
|
if (sourceInput.elapsedMs > 0) {
|
|
metrics.staticAssetExecMs = (metrics.staticAssetExecMs ?? 0) + sourceInput.elapsedMs;
|
|
}
|
|
if (sourceInput.inputPath !== options.inputPath) {
|
|
console.info("[native-static-layout-export] Prepared H.264 source proxy", {
|
|
sourceCodec: sourceInput.sourceCodec,
|
|
proxyCodec: sourceInput.proxyCodec,
|
|
elapsedMs: sourceInput.elapsedMs,
|
|
});
|
|
options = {
|
|
...options,
|
|
inputPath: sourceInput.inputPath,
|
|
};
|
|
}
|
|
const timelineMapPath = await prepareNativeStaticLayoutTimelineMap(
|
|
options,
|
|
path.join(chunkDirectory, "timeline-map.csv"),
|
|
);
|
|
if (timelineMapPath) {
|
|
if (!(options.experimentalWindowsGpuCompositor && process.platform === "win32")) {
|
|
throw new Error("Native timeline-map export requires the Windows GPU compositor");
|
|
}
|
|
options = {
|
|
...options,
|
|
timelineMapPath,
|
|
};
|
|
}
|
|
const fullConfig: NativeStaticLayoutExportArgsConfig = {
|
|
inputPath: options.inputPath,
|
|
outputPath: videoOnlyPath,
|
|
width: options.width,
|
|
height: options.height,
|
|
frameRate: options.frameRate,
|
|
bitrate: options.bitrate,
|
|
encodingMode: options.encodingMode,
|
|
contentWidth: options.contentWidth,
|
|
contentHeight: options.contentHeight,
|
|
offsetX: options.offsetX,
|
|
offsetY: options.offsetY,
|
|
sourceCropX: options.sourceCropX,
|
|
sourceCropY: options.sourceCropY,
|
|
sourceCropWidth: options.sourceCropWidth,
|
|
sourceCropHeight: options.sourceCropHeight,
|
|
backgroundColor: options.backgroundColor,
|
|
backgroundImagePath: options.backgroundImagePath,
|
|
backgroundBlurPx: options.backgroundBlurPx,
|
|
borderRadius: options.borderRadius,
|
|
shadowIntensity: options.shadowIntensity,
|
|
durationSec: options.durationSec,
|
|
};
|
|
const usePrecompositedLayout = shouldUsePrecompositedStaticLayout(options);
|
|
|
|
const { didRenderVideo, didMuxAudioInline } = await tryNativeStaticGpuExport({
|
|
ffmpegPath,
|
|
options,
|
|
session,
|
|
chunkDirectory,
|
|
videoOnlyPath,
|
|
metrics,
|
|
onProgress,
|
|
validateRenderedVideoOutput,
|
|
});
|
|
|
|
if (!didRenderVideo && hasNativeStaticLayoutTimeline(options)) {
|
|
throw new Error("Native timeline-map export requires a GPU compositor backend");
|
|
}
|
|
if (!didRenderVideo && hasNativeStaticLayoutSourceCrop(options)) {
|
|
throw new Error("Native crop export requires a GPU compositor backend");
|
|
}
|
|
|
|
if (!didRenderVideo) {
|
|
await renderNativeStaticFfmpeg({
|
|
ffmpegPath,
|
|
options,
|
|
session,
|
|
chunkDirectory,
|
|
videoOnlyPath,
|
|
concatListPath,
|
|
metrics,
|
|
fullConfig,
|
|
chunks,
|
|
usePrecompositedLayout,
|
|
});
|
|
}
|
|
|
|
const videoOnlyStat = await fs.stat(videoOnlyPath);
|
|
metrics.videoOnlyBytes = videoOnlyStat.size;
|
|
if (!videoOutputValidated) {
|
|
await validateRenderedVideoOutput();
|
|
}
|
|
if (didMuxAudioInline) {
|
|
outputPathToKeep = videoOnlyPath;
|
|
return {
|
|
outputPath: videoOnlyPath,
|
|
metrics,
|
|
};
|
|
}
|
|
const audioMuxProgressStart = 97.25;
|
|
const audioMuxProgressEnd = 99;
|
|
const progressTotalFrames = Math.max(1, Math.ceil(options.durationSec * options.frameRate));
|
|
const finalized = await muxNativeVideoExportAudio(
|
|
videoOnlyPath,
|
|
options.audioOptions ?? {},
|
|
(progress) => {
|
|
const ratio = Math.max(0, Math.min(1, progress.ratio));
|
|
const percentage =
|
|
audioMuxProgressStart + (audioMuxProgressEnd - audioMuxProgressStart) * ratio;
|
|
const progressPayload: NativeStaticLayoutExportProgress = {
|
|
sessionId,
|
|
stage: "finalizing",
|
|
currentFrame: progressTotalFrames,
|
|
totalFrames: progressTotalFrames,
|
|
percentage,
|
|
};
|
|
const backend = metrics.chunks[0]?.backend;
|
|
if (backend) {
|
|
progressPayload.backend = backend;
|
|
}
|
|
onProgress?.(progressPayload);
|
|
},
|
|
session,
|
|
);
|
|
Object.assign(metrics, finalized.metrics);
|
|
outputPathToKeep = finalized.outputPath;
|
|
return {
|
|
outputPath: finalized.outputPath,
|
|
metrics,
|
|
};
|
|
} catch (error) {
|
|
await removeTemporaryExportFile(videoOnlyPath);
|
|
throw error;
|
|
} finally {
|
|
nativeStaticLayoutExportSessions.delete(sessionId);
|
|
await fs.rm(chunkDirectory, { force: true, recursive: true }).catch(() => undefined);
|
|
if (outputPathToKeep !== videoOnlyPath) {
|
|
await removeTemporaryExportFile(videoOnlyPath);
|
|
}
|
|
}
|
|
}
|