feat: add native Windows recorder helper
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const SOURCE_DIR = path.join(ROOT, "electron", "native", "wgc-capture");
|
||||
const BUILD_DIR = path.join(SOURCE_DIR, "build");
|
||||
const COMPAT_LIB_DIR = path.join(BUILD_DIR, "compat-libs");
|
||||
const BIN_DIR = path.join(ROOT, "electron", "native", "bin", "win32-x64");
|
||||
const CMAKE = process.env.CMAKE_EXE ?? "cmake";
|
||||
|
||||
function findVcVarsAll() {
|
||||
const explicit = process.env.VCVARSALL;
|
||||
if (explicit && fs.existsSync(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const roots = [
|
||||
process.env.VSINSTALLDIR,
|
||||
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community",
|
||||
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional",
|
||||
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise",
|
||||
"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools",
|
||||
"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\Community",
|
||||
];
|
||||
|
||||
for (const root of roots.filter(Boolean)) {
|
||||
const candidate = path.join(root, "VC", "Auxiliary", "Build", "vcvarsall.bat");
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: ROOT,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
...options,
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runInVsEnv(command) {
|
||||
const vcvarsAll = findVcVarsAll();
|
||||
if (!vcvarsAll) {
|
||||
throw new Error(
|
||||
"Could not find Visual Studio vcvarsall.bat. Install Visual Studio Build Tools with C++.",
|
||||
);
|
||||
}
|
||||
|
||||
const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`);
|
||||
fs.writeFileSync(
|
||||
cmdPath,
|
||||
[
|
||||
"@echo off",
|
||||
`call "${vcvarsAll}" x64`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
`if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`,
|
||||
`for %%L in (gdi32.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
`set "LIB=${COMPAT_LIB_DIR};%LIB%"`,
|
||||
command,
|
||||
"exit /b %errorlevel%",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
try {
|
||||
await run("cmd.exe", ["/d", "/c", cmdPath]);
|
||||
} finally {
|
||||
fs.rmSync(cmdPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
console.log("Skipping WGC helper build: Windows-only.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.mkdirSync(BUILD_DIR, { recursive: true });
|
||||
|
||||
await runInVsEnv(
|
||||
`"${CMAKE}" -S "${SOURCE_DIR}" -B "${BUILD_DIR}" -G Ninja -DCMAKE_BUILD_TYPE=Release`,
|
||||
);
|
||||
await runInVsEnv(`"${CMAKE}" --build "${BUILD_DIR}" --config Release`);
|
||||
|
||||
const outputPath = path.join(BUILD_DIR, "wgc-capture.exe");
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
throw new Error(`WGC helper build completed but ${outputPath} was not found.`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
const distributablePath = path.join(BIN_DIR, "wgc-capture.exe");
|
||||
fs.copyFileSync(outputPath, distributablePath);
|
||||
|
||||
console.log(`Built ${outputPath}`);
|
||||
console.log(`Copied ${distributablePath}`);
|
||||
@@ -0,0 +1,167 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const HELPER_PATH =
|
||||
process.env.OPENSCREEN_WGC_CAPTURE_EXE ??
|
||||
path.join(ROOT, "electron", "native", "bin", "win32-x64", "wgc-capture.exe");
|
||||
|
||||
const DURATION_MS = Number(process.env.OPENSCREEN_WGC_TEST_DURATION_MS ?? 5000);
|
||||
const WITH_SYSTEM_AUDIO =
|
||||
process.env.OPENSCREEN_WGC_TEST_SYSTEM_AUDIO === "true" ||
|
||||
process.argv.includes("--system-audio");
|
||||
|
||||
function runHelper(config) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(HELPER_PATH, [JSON.stringify(config)], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
child.stdin.write("stop\n");
|
||||
}, DURATION_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function probeStreams(outputPath) {
|
||||
const ffprobe = spawnSync(
|
||||
"ffprobe",
|
||||
["-v", "error", "-show_streams", "-of", "json", outputPath],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
if (ffprobe.status !== 0) {
|
||||
throw new Error(`ffprobe failed: ${ffprobe.stderr || ffprobe.stdout}`);
|
||||
}
|
||||
return JSON.parse(ffprobe.stdout).streams ?? [];
|
||||
}
|
||||
|
||||
function measureFirstFrameLuma(outputPath) {
|
||||
const ffmpeg = spawnSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
outputPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"gray",
|
||||
"pipe:1",
|
||||
],
|
||||
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
if (ffmpeg.status !== 0) {
|
||||
throw new Error(`ffmpeg frame extraction failed: ${ffmpeg.stderr?.toString() ?? ""}`);
|
||||
}
|
||||
const data = ffmpeg.stdout;
|
||||
if (!data || data.length === 0) {
|
||||
throw new Error(`ffmpeg did not return frame data for ${outputPath}`);
|
||||
}
|
||||
let sum = 0;
|
||||
let max = 0;
|
||||
for (const value of data) {
|
||||
sum += value;
|
||||
if (value > max) {
|
||||
max = value;
|
||||
}
|
||||
}
|
||||
return { average: sum / data.length, max };
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
console.log("Skipping WGC helper smoke test: Windows-only.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(HELPER_PATH)) {
|
||||
throw new Error(`WGC helper not found at ${HELPER_PATH}. Run npm run build:native:win first.`);
|
||||
}
|
||||
|
||||
const outputPath = path.join(
|
||||
os.tmpdir(),
|
||||
`openscreen-wgc-helper-${WITH_SYSTEM_AUDIO ? "audio" : "video"}-${Date.now()}.mp4`,
|
||||
);
|
||||
|
||||
const config = {
|
||||
schemaVersion: 2,
|
||||
recordingId: Date.now(),
|
||||
outputPath,
|
||||
sourceType: "display",
|
||||
sourceId: "screen:0:0",
|
||||
displayId: 0,
|
||||
fps: 30,
|
||||
videoWidth: 1280,
|
||||
videoHeight: 720,
|
||||
displayX: 0,
|
||||
displayY: 0,
|
||||
displayW: 1920,
|
||||
displayH: 1080,
|
||||
hasDisplayBounds: true,
|
||||
captureSystemAudio: WITH_SYSTEM_AUDIO,
|
||||
captureMic: false,
|
||||
webcamEnabled: false,
|
||||
outputs: { screenPath: outputPath },
|
||||
};
|
||||
|
||||
const result = await runHelper(config);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`WGC helper exited with ${result.code}\n${result.stdout}\n${result.stderr}`);
|
||||
}
|
||||
if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
|
||||
throw new Error(`WGC helper did not produce a video at ${outputPath}`);
|
||||
}
|
||||
|
||||
const streams = probeStreams(outputPath);
|
||||
const hasVideo = streams.some((stream) => stream.codec_type === "video");
|
||||
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
|
||||
if (!hasVideo) {
|
||||
throw new Error(`WGC helper output has no video stream: ${outputPath}`);
|
||||
}
|
||||
if (WITH_SYSTEM_AUDIO && !hasAudio) {
|
||||
throw new Error(`WGC helper output has no audio stream: ${outputPath}`);
|
||||
}
|
||||
const frameLuma = measureFirstFrameLuma(outputPath);
|
||||
if (frameLuma.average < 1 && frameLuma.max < 5) {
|
||||
throw new Error(`WGC helper output first frame is black: ${outputPath}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
outputPath,
|
||||
bytes: fs.statSync(outputPath).size,
|
||||
streams: streams.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecType: stream.codec_type,
|
||||
codecName: stream.codec_name,
|
||||
duration: stream.duration,
|
||||
})),
|
||||
firstFrameLuma: frameLuma,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user