Add NVIDIA CUDA export compositor

This commit is contained in:
wiiiii123
2026-05-04 09:59:41 +07:00
parent 9c81006e5c
commit 94bbd0280f
16 changed files with 4950 additions and 25 deletions
+1
View File
@@ -37,6 +37,7 @@ vite.config.d.ts
electron/native/wgc-capture/build/
electron/native/cursor-monitor/build/
electron/native/gpu-export-probe/build/
electron/native/nvidia-cuda-compositor/build/
electron/native/bin/*/whisper-*
electron/native/bin/*/whisper-runtime.json
+132
View File
@@ -14,6 +14,7 @@ vi.mock("../ffmpeg/binary", () => ({
import {
buildNativeVideoAudioMuxArgs,
getNvidiaCudaAudioExportSkipReason,
normalizeNativeStaticLayoutBackground,
parseFfmpegDurationSeconds,
parseFfmpegFrameRate,
@@ -21,8 +22,29 @@ import {
parseNvidiaCudaExportSummary,
parseWindowsGpuExportProgressLine,
parseWindowsGpuExportSummary,
validateNvidiaCudaExportSummary,
} from "./native-video";
function withNvidiaCudaAudioOverride<T>(value: string | undefined, callback: () => T) {
const envName = "RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT";
const originalValue = process.env[envName];
if (value === undefined) {
delete process.env[envName];
} else {
process.env[envName] = value;
}
try {
return callback();
} finally {
if (originalValue === undefined) {
delete process.env[envName];
} else {
process.env[envName] = originalValue;
}
}
}
describe("normalizeNativeStaticLayoutBackground", () => {
it("falls back to a solid background when the configured image file is missing", async () => {
const normalized = await normalizeNativeStaticLayoutBackground({
@@ -46,6 +68,35 @@ describe("normalizeNativeStaticLayoutBackground", () => {
});
});
describe("getNvidiaCudaAudioExportSkipReason", () => {
it("allows video-only CUDA exports by default", () => {
withNvidiaCudaAudioOverride(undefined, () => {
expect(getNvidiaCudaAudioExportSkipReason(undefined)).toBeNull();
expect(getNvidiaCudaAudioExportSkipReason("none")).toBeNull();
});
});
it("guards CUDA for audio exports unless explicitly overridden", () => {
withNvidiaCudaAudioOverride(undefined, () => {
expect(getNvidiaCudaAudioExportSkipReason("copy-source")).toBe(
"audio-mode:copy-source",
);
expect(getNvidiaCudaAudioExportSkipReason("trim-source")).toBe(
"audio-mode:trim-source",
);
expect(getNvidiaCudaAudioExportSkipReason("edited-track")).toBe(
"audio-mode:edited-track",
);
});
});
it("allows CUDA audio exports only for explicit lab overrides", () => {
withNvidiaCudaAudioOverride("1", () => {
expect(getNvidiaCudaAudioExportSkipReason("copy-source")).toBeNull();
});
});
});
describe("buildNativeVideoAudioMuxArgs", () => {
it("stream-copies source audio and preserves the requested video duration", () => {
const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.mp4", "out.mp4", {
@@ -151,6 +202,87 @@ describe("parseNvidiaCudaExportSummary", () => {
});
});
describe("validateNvidiaCudaExportSummary", () => {
it("accepts CUDA output when frames and stream durations match the export target", () => {
const issues = validateNvidiaCudaExportSummary(
{
success: true,
targetFrames: 300,
durationSec: 10,
nativeSummary: { success: true, frames: 300 },
outputVideo: { duration: "9.999900", nb_frames: "300" },
outputAudio: { duration: "10.005000" },
},
{ durationSec: 10, targetFrames: 300 },
);
expect(issues).toEqual([]);
});
it("rejects CUDA output that reports too few frames or a short video stream", () => {
const issues = validateNvidiaCudaExportSummary(
{
success: true,
targetFrames: 300,
durationSec: 10,
nativeSummary: { success: true, frames: 144 },
outputVideo: { duration: "4.799952", nb_frames: "144" },
outputAudio: { duration: "10.005000" },
},
{ durationSec: 10, targetFrames: 300 },
);
expect(issues).toEqual([
"native frames 144 below expected minimum 285",
"output video frames 144 below expected minimum 285",
"output video duration 4.800s differs from expected 10.000s",
]);
});
it("rejects audio CUDA output unless the helper reports timestamp-aligned frame selection", () => {
const issues = validateNvidiaCudaExportSummary(
{
success: true,
targetFrames: 300,
durationSec: 10,
nativeSummary: {
success: true,
frames: 300,
selectionStage: "decoder-policy-mapped-callback",
},
outputVideo: { duration: "9.999900", nb_frames: "300" },
outputAudio: { duration: "10.005000" },
},
{ durationSec: 10, targetFrames: 300, requiresTimelineSync: true },
);
expect(issues).toEqual([
"CUDA timeline mode is not timestamp-aligned for audio export",
]);
});
it("accepts audio CUDA output when the helper reports PTS-aligned selection", () => {
const issues = validateNvidiaCudaExportSummary(
{
success: true,
targetFrames: 300,
durationSec: 10,
nativeSummary: {
success: true,
frames: 300,
sourceTimestampMode: "pts",
selectionStage: "timestamp-mapped-callback",
},
outputVideo: { duration: "9.999900", nb_frames: "300" },
outputAudio: { duration: "10.005000" },
},
{ durationSec: 10, targetFrames: 300, requiresTimelineSync: true },
);
expect(issues).toEqual([]);
});
});
describe("parseWindowsGpuExportProgressLine", () => {
it("parses bounded helper progress lines", () => {
expect(
+204 -10
View File
@@ -14,6 +14,7 @@ import type {
NativeStaticLayoutBackend,
NativeStaticLayoutExportArgsConfig,
NativeVideoAudioMuxMetrics,
NativeVideoExportAudioMode,
NativeVideoExportFinishOptions,
} from "../nativeVideoExport";
import {
@@ -40,6 +41,7 @@ const getNowMs = () => performance.now();
const formatFfmpegSeconds = (milliseconds: number) => (milliseconds / 1000).toFixed(3);
const MISSING_NATIVE_STATIC_BACKGROUND_COLOR = "#ffffff";
const NATIVE_EXPORT_HIGH_PRIORITY = os.constants.priority.PRIORITY_HIGH;
const NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV = "RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT";
export type NativeVideoExportSession = {
ffmpegProcess: ChildProcessByStdio<Writable, null, Readable>;
@@ -186,18 +188,24 @@ export interface NvidiaCudaExportSummary {
bitrateMbps?: number;
durationSec?: number;
targetFrames?: number;
sourcePtsFrames?: number;
sourcePtsSource?: string;
timingsMs?: {
demux?: number;
backgroundConvert?: number;
cursorAtlas?: number;
webcamConvert?: number;
webcamDemux?: number;
sourcePtsProbe?: number;
nativeEncode?: number;
mux?: number;
endToEnd?: number;
};
nativeSummary?: {
success?: boolean;
selectionStage?: string;
sourceTimestampMode?: string;
timelineMode?: string;
frames?: number;
totalMs?: number;
fps?: number;
@@ -332,6 +340,111 @@ export function parseNvidiaCudaExportSummary(stdout: string): NvidiaCudaExportSu
}
}
function getFiniteNumber(value: unknown) {
const numberValue = typeof value === "string" ? Number(value) : value;
return typeof numberValue === "number" && Number.isFinite(numberValue)
? numberValue
: null;
}
function getNvidiaCudaOutputStreamNumber(stream: unknown, property: string) {
if (!stream || typeof stream !== "object") {
return null;
}
return getFiniteNumber((stream as Record<string, unknown>)[property]);
}
export function validateNvidiaCudaExportSummary(
summary: NvidiaCudaExportSummary,
expected: {
durationSec: number;
targetFrames: number;
requiresTimelineSync?: boolean;
},
) {
const issues: string[] = [];
const expectedFrames = Math.max(1, Math.round(expected.targetFrames));
const minimumFrames = Math.max(1, Math.floor(expectedFrames * 0.95));
const expectedDurationSec = Math.max(0, expected.durationSec);
const durationToleranceSec = Math.min(
2,
Math.max(0.5, expectedDurationSec * 0.02),
);
const nativeFrames = getFiniteNumber(summary.nativeSummary?.frames);
const outputVideoFrames = getNvidiaCudaOutputStreamNumber(
summary.outputVideo,
"nb_frames",
);
const outputVideoDurationSec = getNvidiaCudaOutputStreamNumber(
summary.outputVideo,
"duration",
);
const outputAudioDurationSec = getNvidiaCudaOutputStreamNumber(
summary.outputAudio,
"duration",
);
if (!summary.outputVideo) {
issues.push("missing output video probe");
}
if (
expected.requiresTimelineSync &&
!isNvidiaCudaTimestampAlignedSummary(summary)
) {
issues.push(
"CUDA timeline mode is not timestamp-aligned for audio export",
);
}
if (nativeFrames === null) {
issues.push("missing native frame count");
} else if (nativeFrames < minimumFrames) {
issues.push(
`native frames ${nativeFrames} below expected minimum ${minimumFrames}`,
);
}
if (outputVideoFrames !== null && outputVideoFrames < minimumFrames) {
issues.push(
`output video frames ${outputVideoFrames} below expected minimum ${minimumFrames}`,
);
}
if (
outputVideoDurationSec !== null &&
Math.abs(outputVideoDurationSec - expectedDurationSec) > durationToleranceSec
) {
issues.push(
`output video duration ${outputVideoDurationSec.toFixed(
3,
)}s differs from expected ${expectedDurationSec.toFixed(3)}s`,
);
}
if (
outputAudioDurationSec !== null &&
Math.abs(outputAudioDurationSec - expectedDurationSec) > durationToleranceSec
) {
issues.push(
`output audio duration ${outputAudioDurationSec.toFixed(
3,
)}s differs from expected ${expectedDurationSec.toFixed(3)}s`,
);
}
return issues;
}
function isNvidiaCudaTimestampAlignedSummary(summary: NvidiaCudaExportSummary) {
const nativeSummary = summary.nativeSummary;
const timelineFields = [
nativeSummary?.sourceTimestampMode,
nativeSummary?.timelineMode,
nativeSummary?.selectionStage,
];
return timelineFields.some((value) => {
const normalized = String(value ?? "").toLowerCase();
return normalized.includes("pts") || normalized.includes("timestamp");
});
}
function shouldPersistNvidiaCudaExportDiagnostics() {
return (
process.env.RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS === "1" ||
@@ -1048,12 +1161,34 @@ async function resolveExperimentalWindowsGpuExporterPath() {
return null;
}
function isExperimentalNvidiaCudaExportEnabled(options: NativeStaticLayoutExportOptions) {
return Boolean(
process.platform === "win32" &&
process.env.RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT === "1" &&
options.experimentalWindowsGpuCompositor,
);
export function getNvidiaCudaAudioExportSkipReason(
audioMode: NativeVideoExportAudioMode | undefined,
) {
const resolvedAudioMode = audioMode ?? "none";
if (resolvedAudioMode === "none") {
return null;
}
if (process.env[NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV] === "1") {
return null;
}
return `audio-mode:${resolvedAudioMode}`;
}
function getExperimentalNvidiaCudaExportSkipReason(
options: NativeStaticLayoutExportOptions,
) {
if (process.platform !== "win32") {
return "not-windows";
}
if (process.env.RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT !== "1") {
return "env-disabled";
}
if (!options.experimentalWindowsGpuCompositor) {
return "windows-gpu-compositor-disabled";
}
return getNvidiaCudaAudioExportSkipReason(options.audioOptions?.audioMode);
}
async function resolveExperimentalNvidiaCudaExportScriptPath() {
@@ -1067,6 +1202,28 @@ async function resolveExperimentalNvidiaCudaExportScriptPath() {
candidates.push(configuredPath);
}
candidates.push(
path.join(
process.cwd(),
"electron",
"native",
"nvidia-cuda-compositor",
"run-mp4-pipeline.mjs",
),
path.join(
app.getAppPath(),
"electron",
"native",
"nvidia-cuda-compositor",
"run-mp4-pipeline.mjs",
),
path.join(
process.resourcesPath,
"app.asar.unpacked",
"electron",
"native",
"nvidia-cuda-compositor",
"run-mp4-pipeline.mjs",
),
path.join(process.cwd(), ".tmp", "nvdec-nvenc-probe", "run-mp4-pipeline.mjs"),
path.join(app.getAppPath(), ".tmp", "nvdec-nvenc-probe", "run-mp4-pipeline.mjs"),
);
@@ -1664,6 +1821,7 @@ async function runExperimentalNvidiaCudaStaticLayoutExport(
let stdout = "";
let stderr = "";
let stderrLineBuffer = "";
let lastProgressPercentage = 0;
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
@@ -1693,8 +1851,13 @@ async function runExperimentalNvidiaCudaStaticLayoutExport(
: elapsedMs > 0 && progress.currentFrame > 0
? (progress.currentFrame * 1000) / elapsedMs
: undefined;
lastProgressPercentage = Math.max(
lastProgressPercentage,
progress.percentage,
);
onProgress?.({
...progress,
percentage: lastProgressPercentage,
sessionId: options.sessionId,
backend: "nvidia-cuda-compositor",
elapsedMs,
@@ -2030,7 +2193,23 @@ export async function exportNativeStaticLayoutVideo(
}
}
let experimentalNvidiaCudaOptions = experimentalGpuOptions;
let shouldTryNvidiaCuda = isExperimentalNvidiaCudaExportEnabled(options);
const nvidiaCudaSkipReason =
getExperimentalNvidiaCudaExportSkipReason(options);
let shouldTryNvidiaCuda = nvidiaCudaSkipReason === null;
if (
!shouldTryNvidiaCuda &&
process.env.RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT === "1" &&
nvidiaCudaSkipReason !== "env-disabled"
) {
console.warn(
"[native-static-layout-export] Skipping NVIDIA CUDA compositor; falling back to Windows GPU compositor",
{
reason: nvidiaCudaSkipReason,
audioMode: options.audioOptions?.audioMode ?? "none",
overrideEnv: NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV,
},
);
}
if (shouldTryNvidiaCuda && options.cursorTelemetry?.length) {
const cursorTelemetryPath = await prepareNvidiaCudaCursorTelemetry(
options,
@@ -2064,6 +2243,21 @@ export async function exportNativeStaticLayoutVideo(
session,
onProgress,
);
const cudaValidationIssues = validateNvidiaCudaExportSummary(
cudaResult.summary,
{
durationSec: options.durationSec,
targetFrames: Math.ceil(options.durationSec * options.frameRate),
requiresTimelineSync:
(experimentalNvidiaCudaOptions.audioOptions?.audioMode ??
"none") !== "none",
},
);
if (cudaValidationIssues.length > 0) {
throw new Error(
`Experimental NVIDIA CUDA compositor produced an invalid output: ${cudaValidationIssues.join("; ")}`,
);
}
console.info(
"[native-static-layout-export] NVIDIA CUDA compositor completed",
{
@@ -2092,9 +2286,9 @@ export async function exportNativeStaticLayoutVideo(
webcamOverlay: cudaResult.summary.nativeSummary?.webcamOverlay,
cursorAtlas: cudaResult.summary.nativeSummary?.cursorAtlas,
zoomOverlay: cudaResult.summary.nativeSummary?.zoomOverlay,
zoomSamples: cudaResult.summary.nativeSummary?.zoomSamples,
},
);
zoomSamples: cudaResult.summary.nativeSummary?.zoomSamples,
},
);
const outputStat = await fs.stat(videoOnlyPath);
metrics.chunkCount = 1;
metrics.chunkDurationSec = options.durationSec;
+13 -1
View File
@@ -20,8 +20,8 @@ import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/mon
import {
clamp,
pauseCursorCapture,
resumeCursorCapture,
resetCursorCaptureClock,
resumeCursorCapture,
sampleCursorPoint,
snapshotCursorTelemetryForPersistence,
startCursorSampling,
@@ -1082,6 +1082,18 @@ export function registerRecordingHandlers(
try {
return await finalizeStoredVideo(videoPath);
} catch {
try {
await validateRecordedVideo(videoPath);
return {
success: false,
path: videoPath,
message: "Failed to mux native Windows recording",
error: String(error),
};
} catch {
// The fallback path is not safely playable; surface the original mux error.
}
return {
success: false,
message: "Failed to mux native Windows recording",
@@ -23,6 +23,13 @@
"sourceDir": "electron/native/gpu-export-probe",
"sourceFingerprint": "75bf080c4a5cbbcb1d42fb088a1545130c613e42d9e7681cc23f9151d1c8072b",
"updatedAt": "2026-05-03T15:39:47.938Z"
},
"recordly-nvidia-cuda-compositor": {
"binaryName": "recordly-nvidia-cuda-compositor.exe",
"binarySha256": "f274039c46445a3b8e57c9faa6307238d6facc04d5c88f7bb84c43563feda649",
"sourceDir": "electron/native/nvidia-cuda-compositor",
"sourceFingerprint": "bc6f657ff3226f8c3bcdc89354de4118d0ef0f3ac1b5fde6d496bda9b77b65c6",
"updatedAt": "2026-05-04T02:54:12.432Z"
}
}
}
@@ -0,0 +1,47 @@
cmake_minimum_required(VERSION 3.24)
project(recordly_nvidia_cuda_compositor LANGUAGES CXX CUDA)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CUDA_STANDARD 17)
set(CMAKE_CUDA_STANDARD_REQUIRED ON)
set(RECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT
"${CMAKE_CURRENT_LIST_DIR}/../../../.tmp/video-sdk-samples"
CACHE PATH
"Path to the NVIDIA Video Codec SDK samples checkout"
)
set(NVIDIA_SAMPLES_DIR "${RECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT}/Samples")
set(NVCODEC_DIR "${NVIDIA_SAMPLES_DIR}/NvCodec")
if(NOT EXISTS "${NVCODEC_DIR}")
message(FATAL_ERROR "NVIDIA Video Codec SDK NvCodec directory not found: ${NVCODEC_DIR}. Set RECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT.")
endif()
add_executable(recordly-nvidia-cuda-compositor
src/main.cu
"${NVCODEC_DIR}/NvDecoder/NvDecoder.cpp"
"${NVCODEC_DIR}/NvEncoder/NvEncoder.cpp"
"${NVCODEC_DIR}/NvEncoder/NvEncoderCuda.cpp"
)
target_include_directories(recordly-nvidia-cuda-compositor PRIVATE
"${NVIDIA_SAMPLES_DIR}"
"${NVCODEC_DIR}"
)
target_compile_definitions(recordly-nvidia-cuda-compositor PRIVATE
NOMINMAX
WIN32_LEAN_AND_MEAN
)
find_package(CUDAToolkit REQUIRED)
target_link_libraries(recordly-nvidia-cuda-compositor PRIVATE
CUDA::cuda_driver
"${NVCODEC_DIR}/Lib/x64/nvcuvid.lib"
)
set_target_properties(recordly-nvidia-cuda-compositor PROPERTIES
CUDA_SEPARABLE_COMPILATION OFF
)
@@ -0,0 +1,144 @@
const { app, BrowserWindow, ipcMain } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const drawHeight = 256;
const padding = 2;
const cursorTypes = [
"arrow",
"text",
"pointer",
"crosshair",
"open-hand",
"closed-hand",
"resize-ew",
"resize-ns",
"not-allowed",
];
const tahoeAssets = {
arrow: ["pointer-1__14-6.svg", 0.14, 0.06],
text: ["ibeam-1__50-44.svg", 0.5, 0.44],
pointer: ["pointinghand-1__40-10.svg", 0.4, 0.1],
crosshair: ["crosshair-1__50-50.svg", 0.5, 0.5],
"open-hand": ["openhand-1__55-57.svg", 0.55, 0.57],
"closed-hand": ["closedhand-1__50-46.svg", 0.5, 0.46],
"resize-ew": ["resizeeastwest-1__50-50.svg", 0.5, 0.5],
"resize-ns": ["resizenorthsouth-1__50-49.svg", 0.5, 0.49],
"not-allowed": ["notallowed-1__23-0.svg", 0.23, 0],
};
function arg(name, fallback = "") {
const index = process.argv.indexOf(name);
return index >= 0 && index + 1 < process.argv.length ? process.argv[index + 1] : fallback;
}
const repoRoot = arg("--repo-root");
const atlasRgbaPath = arg("--output-rgba");
const atlasMetadataPath = arg("--output-metadata");
if (!repoRoot || !atlasRgbaPath || !atlasMetadataPath) {
console.error("Usage: electron render-tahoe-cursor-atlas.cjs --repo-root <repo> --output-rgba <raw> --output-metadata <tsv>");
process.exit(1);
}
const assets = cursorTypes.map((type, index) => {
const [fileName, anchorX, anchorY] = tahoeAssets[type];
return {
type,
index,
filePath: path.join(repoRoot, "src", "assets", "cursors", "tahoe", fileName),
anchorX,
anchorY,
};
});
app.disableHardwareAcceleration();
app.whenReady().then(async () => {
const window = new BrowserWindow({
show: false,
width: 1,
height: 1,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
backgroundThrottling: false,
},
});
ipcMain.once("atlas-ready", (_event, result) => {
console.log(JSON.stringify(result));
app.quit();
});
const html = `
<!doctype html>
<meta charset="utf-8">
<script>
const { ipcRenderer } = require("electron");
const fs = require("node:fs");
const assets = ${JSON.stringify(assets)};
const atlasRgbaPath = ${JSON.stringify(atlasRgbaPath)};
const atlasMetadataPath = ${JSON.stringify(atlasMetadataPath)};
const drawHeight = ${drawHeight};
const padding = ${padding};
function loadImage(asset) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve({ asset, image });
image.onerror = () => reject(new Error("Failed to load cursor SVG: " + asset.filePath));
const svg = fs.readFileSync(asset.filePath, "utf8");
image.src = "data:image/svg+xml;base64," + Buffer.from(svg, "utf8").toString("base64");
});
}
(async () => {
const loaded = await Promise.all(assets.map(loadImage));
const packed = loaded.map(({ asset, image }) => {
const aspectRatio = image.naturalHeight > 0 ? image.naturalWidth / image.naturalHeight : 1;
return {
asset,
image,
width: Math.max(1, Math.round(drawHeight * aspectRatio)),
height: drawHeight,
aspectRatio,
};
});
const atlasWidth = packed.reduce((sum, item) => sum + item.width + padding, padding);
const atlasHeight = drawHeight + padding * 2;
const canvas = document.createElement("canvas");
canvas.width = atlasWidth;
canvas.height = atlasHeight;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, atlasWidth, atlasHeight);
let x = padding;
const rows = [];
for (const item of packed) {
const y = padding;
ctx.drawImage(item.image, x, y, item.width, item.height);
rows.push([
item.asset.index,
x,
y,
item.width,
item.height,
item.asset.anchorX,
item.asset.anchorY,
item.aspectRatio,
].join("\\t"));
x += item.width + padding;
}
const imageData = ctx.getImageData(0, 0, atlasWidth, atlasHeight).data;
fs.writeFileSync(atlasRgbaPath, Buffer.from(imageData.buffer));
fs.writeFileSync(atlasMetadataPath, rows.join("\\n") + "\\n");
ipcRenderer.send("atlas-ready", { width: atlasWidth, height: atlasHeight, entries: rows.length });
})().catch((error) => {
ipcRenderer.send("atlas-ready", { error: error.message });
});
</script>`;
await window.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html));
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -25,8 +25,9 @@
"rebuild:native": "node ./node_modules/@electron/rebuild/lib/cli.js --force --only uiohook-napi",
"build:native-helpers": "node scripts/build-native-helpers.mjs",
"build:whisper-runtime": "node scripts/build-whisper-runtime.mjs",
"build:platform-native-helpers": "npm run build:native-helpers && npm run build:windows-capture && npm run build:windows-gpu-export && npm run build:cursor-monitor && npm run build:whisper-runtime",
"build:platform-native-helpers": "npm run build:native-helpers && npm run build:windows-capture && npm run build:windows-gpu-export && npm run build:nvidia-cuda-compositor && npm run build:cursor-monitor && npm run build:whisper-runtime",
"build:windows-gpu-export": "node scripts/build-windows-gpu-export.mjs",
"build:nvidia-cuda-compositor": "node scripts/build-nvidia-cuda-compositor.mjs",
"build:windows-capture": "node scripts/build-windows-capture.mjs",
"build:cursor-monitor": "node scripts/build-cursor-monitor.mjs",
"build:mac": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && electron-builder --mac",
+193
View File
@@ -0,0 +1,193 @@
import { execSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import {
formatNativeHelperManifestWarning,
updateNativeHelperManifest,
verifyNativeHelperManifest,
} from "./native-helper-manifest.mjs";
const projectRoot = process.cwd();
const sourceDir = path.join(projectRoot, "electron", "native", "nvidia-cuda-compositor");
const buildDir = path.join(sourceDir, "build");
const bundledDir = path.join(
projectRoot,
"electron",
"native",
"bin",
process.arch === "arm64" ? "win32-arm64" : "win32-x64",
);
const bundledExePath = path.join(bundledDir, "recordly-nvidia-cuda-compositor.exe");
const helperId = "recordly-nvidia-cuda-compositor";
const generatorArch = process.arch === "arm64" ? "ARM64" : "x64";
const videoCodecSdkRoot =
process.env.RECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT?.trim() ||
path.join(projectRoot, ".tmp", "video-sdk-samples");
if (process.platform !== "win32") {
console.log("[build-nvidia-cuda-compositor] Skipping NVIDIA CUDA compositor build.");
process.exit(0);
}
if (!existsSync(path.join(sourceDir, "CMakeLists.txt"))) {
console.error("[build-nvidia-cuda-compositor] CMakeLists.txt not found at", sourceDir);
process.exit(1);
}
function fallbackToBundledHelperOrExit(reason) {
if (existsSync(bundledExePath)) {
const verification = verifyNativeHelperManifest({
projectRoot,
helperId,
sourceDir,
binaryPath: bundledExePath,
binaryName: "recordly-nvidia-cuda-compositor.exe",
});
if (!verification.ok) {
console.warn(
formatNativeHelperManifestWarning("build-nvidia-cuda-compositor", verification),
);
}
console.log(`[build-nvidia-cuda-compositor] ${reason}`);
console.log(`[build-nvidia-cuda-compositor] Using bundled helper: ${bundledExePath}`);
process.exit(0);
}
console.error(`[build-nvidia-cuda-compositor] ${reason}`);
console.error(
"[build-nvidia-cuda-compositor] No bundled helper is available; install CUDA Toolkit + NVIDIA Video Codec SDK or provide a staged helper.",
);
process.exit(1);
}
function findCmake() {
try {
execSync("cmake --version", { stdio: "pipe" });
return "cmake";
} catch {
// Continue probing common Windows install locations.
}
const standaloneCmakePaths = [
path.join("C:", "Program Files", "CMake", "bin", "cmake.exe"),
path.join("C:", "Program Files (x86)", "CMake", "bin", "cmake.exe"),
];
for (const cmakePath of standaloneCmakePaths) {
if (existsSync(cmakePath)) {
return `"${cmakePath}"`;
}
}
const vsRoots = [
path.join("C:", "Program Files", "Microsoft Visual Studio"),
path.join("C:", "Program Files (x86)", "Microsoft Visual Studio"),
];
const vsEditions = ["Preview", "Community", "Professional", "Enterprise", "BuildTools"];
const vsVersions = ["2022", "2019"];
for (const root of vsRoots) {
for (const version of vsVersions) {
for (const edition of vsEditions) {
const cmakePath = path.join(
root,
version,
edition,
"Common7",
"IDE",
"CommonExtensions",
"Microsoft",
"CMake",
"CMake",
"bin",
"cmake.exe",
);
if (existsSync(cmakePath)) {
return `"${cmakePath}"`;
}
}
}
}
return null;
}
if (!existsSync(path.join(videoCodecSdkRoot, "Samples", "NvCodec"))) {
fallbackToBundledHelperOrExit(
`NVIDIA Video Codec SDK samples not found at ${videoCodecSdkRoot}. Set RECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT to build from source.`,
);
}
const cmake = findCmake();
if (!cmake) {
fallbackToBundledHelperOrExit(
"CMake not found. Install Visual Studio with C++ CMake tools or standalone CMake.",
);
}
mkdirSync(buildDir, { recursive: true });
function clearCmakeCache() {
rmSync(path.join(buildDir, "CMakeCache.txt"), { force: true });
rmSync(path.join(buildDir, "CMakeFiles"), { recursive: true, force: true });
}
console.log("[build-nvidia-cuda-compositor] Configuring CMake...");
try {
clearCmakeCache();
execSync(
`${cmake} .. -G "Visual Studio 17 2022" -A ${generatorArch} -DRECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT="${videoCodecSdkRoot}"`,
{
cwd: buildDir,
stdio: "inherit",
timeout: 120000,
},
);
} catch {
console.log("[build-nvidia-cuda-compositor] VS 2022 generator not found, trying VS 2019...");
try {
clearCmakeCache();
execSync(
`${cmake} .. -G "Visual Studio 16 2019" -A ${generatorArch} -DRECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT="${videoCodecSdkRoot}"`,
{
cwd: buildDir,
stdio: "inherit",
timeout: 120000,
},
);
} catch (error) {
fallbackToBundledHelperOrExit(
`CMake configure failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
console.log("[build-nvidia-cuda-compositor] Building NVIDIA CUDA compositor...");
try {
execSync(`${cmake} --build . --config Release`, {
cwd: buildDir,
stdio: "inherit",
timeout: 300000,
});
} catch (error) {
fallbackToBundledHelperOrExit(
`Build failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
const exePath = path.join(buildDir, "Release", "recordly-nvidia-cuda-compositor.exe");
if (!existsSync(exePath)) {
console.error("[build-nvidia-cuda-compositor] Expected exe not found at", exePath);
process.exit(1);
}
mkdirSync(bundledDir, { recursive: true });
copyFileSync(exePath, bundledExePath);
console.log(`[build-nvidia-cuda-compositor] Staged bundled helper: ${bundledExePath}`);
const manifestPath = updateNativeHelperManifest({
projectRoot,
helperId,
sourceDir,
binaryPath: bundledExePath,
binaryName: "recordly-nvidia-cuda-compositor.exe",
});
console.log(`[build-nvidia-cuda-compositor] Updated helper manifest: ${manifestPath}`);
+80
View File
@@ -0,0 +1,80 @@
param(
[string]$AppPath,
[string]$CudaScriptPath,
[switch]$CloseExisting,
[switch]$NoDiagnostics,
[switch]$AllowCudaAudio
)
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
if (-not $AppPath) {
$packagedAppPath = Join-Path $repoRoot "release\win-unpacked\Recordly.exe"
$installedAppPath = Join-Path $env:LOCALAPPDATA "Programs\recordly\Recordly.exe"
if (Test-Path $packagedAppPath) {
$AppPath = $packagedAppPath
} elseif (Test-Path $installedAppPath) {
$AppPath = $installedAppPath
} else {
throw "Recordly.exe was not found. Build the Windows package first or pass -AppPath."
}
}
if (-not $CudaScriptPath) {
$CudaScriptPath = Join-Path $repoRoot "electron\native\nvidia-cuda-compositor\run-mp4-pipeline.mjs"
}
if (-not (Test-Path $AppPath)) {
throw "Recordly app not found: $AppPath"
}
if (-not (Test-Path $CudaScriptPath)) {
throw "NVIDIA CUDA/NVENC wrapper script not found: $CudaScriptPath"
}
$existingRecordly = @(Get-Process -Name "Recordly" -ErrorAction SilentlyContinue)
if ($existingRecordly.Count -gt 0) {
if (-not $CloseExisting) {
Write-Host "Recordly is already running. Close it first, or rerun with -CloseExisting so the CUDA env is inherited by the new app process."
$existingRecordly | Select-Object Id, ProcessName, Path | Format-Table -AutoSize
exit 2
}
$existingRecordly | Stop-Process -Force
Start-Sleep -Milliseconds 500
}
$env:RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT = "1"
$env:RECORDLY_NVIDIA_CUDA_EXPORT_SCRIPT = (Resolve-Path $CudaScriptPath).Path
$env:RECORDLY_NVIDIA_CUDA_EXPORT_HIGH_PRIORITY = "1"
$env:RECORDLY_NVIDIA_CUDA_SAMPLE_GPU = "1"
if ($AllowCudaAudio) {
$env:RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT = "1"
} else {
Remove-Item Env:\RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT -ErrorAction SilentlyContinue
}
if ($NoDiagnostics) {
Remove-Item Env:\RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS -ErrorAction SilentlyContinue
} else {
$env:RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS = "1"
}
$resolvedAppPath = (Resolve-Path $AppPath).Path
$appDirectory = Split-Path $resolvedAppPath -Parent
if ($AllowCudaAudio) {
$cudaAudioMode = "candidate only; app still requires timestamp-aligned CUDA output"
} else {
$cudaAudioMode = "guarded; audio exports fall back to Windows D3D11"
}
Write-Host "Launching Recordly with guarded NVIDIA CUDA/NVENC auto export enabled:"
Write-Host " App: $resolvedAppPath"
Write-Host " CUDA wrapper: $env:RECORDLY_NVIDIA_CUDA_EXPORT_SCRIPT"
Write-Host " CUDA audio exports: $cudaAudioMode"
Write-Host " Diagnostics: $($env:RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS -eq '1')"
Start-Process -FilePath $resolvedAppPath -WorkingDirectory $appDirectory
+5
View File
@@ -171,6 +171,11 @@ function getExpectedNativeHelperFiles(archTag) {
label: "Windows GPU export helper",
executable: true,
},
{
name: "recordly-nvidia-cuda-compositor.exe",
label: "NVIDIA CUDA compositor helper",
executable: true,
},
{ name: "helpers-manifest.json", label: "Windows helper manifest" },
{ name: "whisper-cli.exe", label: "Whisper CLI runtime", executable: true },
{ name: "whisper-runtime.json", label: "Whisper runtime manifest" },
+5 -12
View File
@@ -731,16 +731,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
await window.electronAPI.muxNativeWindowsRecording(pauseSegments);
if (!muxResult?.success || !muxResult.path) {
void logNativeCaptureDiagnostics("mux-native-windows-recording");
if (!muxResult?.path) {
const failureMessage = await buildNativeCaptureFailureMessage(
"mux-native-windows-recording",
muxResult?.message ||
"Failed to finalize the Windows recording, so the editor was not opened.",
);
await notifyRecordingFinalizationFailure(failureMessage);
return;
}
const fallbackPath = muxResult?.path ?? finalPath;
const warningMessage =
muxResult?.error ||
muxResult?.message ||
@@ -749,8 +740,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
`${warningMessage}. Recording was saved, but audio playback or export may be incomplete.`,
{ id: SOURCE_AUDIO_MUX_TOAST_ID, duration: 10000 },
);
finalPath = fallbackPath;
} else {
finalPath = muxResult.path;
}
finalPath = muxResult.path;
}
await storeMicrophoneSidecar(
@@ -1186,7 +1179,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
maxFrameRate: TARGET_FRAME_RATE,
},
},
} as any);
} as unknown as MediaStreamConstraints);
}
};
+7 -1
View File
@@ -1601,8 +1601,14 @@ export class ModernVideoExporter {
return;
}
const nativeCurrentFrame = Math.floor(progress.currentFrame);
const nativeTotalFrames = Math.max(1, Math.floor(progress.totalFrames));
const progressPercentFrame = Number.isFinite(progress.percentage)
? Math.floor((nativeTotalFrames * progress.percentage) / 100)
: 0;
const nativeCurrentFrame = Math.max(
Math.floor(progress.currentFrame),
progressPercentFrame,
);
const nativeFramesComplete = nativeCurrentFrame >= nativeTotalFrames;
const maxExtractingFrame = Math.max(
0,