ef5855f1f4
Record browser webcam sidecar when native Windows capture is active. Add native webcam sidecar output and DirectShow NV12/YUY2 fallback. Sample exported webcam frames by source timestamp.
388 lines
12 KiB
JavaScript
388 lines
12 KiB
JavaScript
import { spawn, spawnSync } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
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");
|
|
const WITH_MICROPHONE =
|
|
process.env.OPENSCREEN_WGC_TEST_MICROPHONE === "true" ||
|
|
process.argv.includes("--microphone") ||
|
|
process.argv.includes("--mic");
|
|
const WITH_WINDOW =
|
|
process.env.OPENSCREEN_WGC_TEST_WINDOW === "true" || process.argv.includes("--window");
|
|
const WITH_WEBCAM =
|
|
process.env.OPENSCREEN_WGC_TEST_WEBCAM === "true" || process.argv.includes("--webcam");
|
|
const CAPTURE_CURSOR =
|
|
process.env.OPENSCREEN_WGC_TEST_CAPTURE_CURSOR === "true" ||
|
|
process.argv.includes("--capture-cursor");
|
|
|
|
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 = "";
|
|
let stopTimer = null;
|
|
const scheduleStop = () => {
|
|
if (stopTimer) {
|
|
return;
|
|
}
|
|
stopTimer = setTimeout(() => {
|
|
child.stdin.write("stop\n");
|
|
}, DURATION_MS);
|
|
};
|
|
const fallbackTimer = setTimeout(scheduleStop, 15_000);
|
|
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
if (stdout.includes('"recording-started"') || stdout.includes("Recording started")) {
|
|
scheduleStop();
|
|
}
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.once("error", reject);
|
|
child.once("exit", (code) => {
|
|
clearTimeout(fallbackTimer);
|
|
if (stopTimer) {
|
|
clearTimeout(stopTimer);
|
|
}
|
|
resolve({ code, stdout, stderr });
|
|
});
|
|
});
|
|
}
|
|
|
|
function startFixtureWindow() {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn("mspaint.exe", [], {
|
|
stdio: ["ignore", "ignore", "ignore"],
|
|
windowsHide: false,
|
|
});
|
|
|
|
const poll = setInterval(() => {
|
|
const lookup = spawnSync(
|
|
"powershell",
|
|
[
|
|
"-NoProfile",
|
|
"-Command",
|
|
`(Get-Process -Id ${child.pid} -ErrorAction SilentlyContinue).MainWindowHandle`,
|
|
],
|
|
{ encoding: "utf8", windowsHide: true },
|
|
);
|
|
const handle = lookup.stdout
|
|
.trim()
|
|
.split(/\r?\n/)
|
|
.find((line) => /^\d+$/.test(line.trim()));
|
|
if (handle && handle !== "0") {
|
|
clearInterval(poll);
|
|
clearTimeout(timer);
|
|
resolve({ child, sourceId: `window:${handle.trim()}:0` });
|
|
}
|
|
}, 250);
|
|
|
|
const timer = setTimeout(() => {
|
|
clearInterval(poll);
|
|
child.kill();
|
|
reject(new Error("Timed out waiting for fixture window handle"));
|
|
}, 10_000);
|
|
child.once("error", (error) => {
|
|
clearInterval(poll);
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
function normalizeDeviceName(value) {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function scoreDeviceName(candidateName, candidateId, requestedName) {
|
|
const candidate = normalizeDeviceName(candidateName ?? "");
|
|
const id = normalizeDeviceName(candidateId ?? "");
|
|
const requested = normalizeDeviceName(requestedName ?? "");
|
|
if (!requested) return 0;
|
|
if (candidate === requested) return 1000;
|
|
if (candidate.includes(requested) || requested.includes(candidate)) return 900;
|
|
if (id.includes(requested) || requested.includes(id)) return 800;
|
|
return requested
|
|
.split(/\s+/)
|
|
.filter((word) => word.length > 1 && !["camera", "webcam", "video", "input"].includes(word))
|
|
.reduce((score, word) => {
|
|
if (candidate.includes(word)) return score + 100;
|
|
if (id.includes(word)) return score + 50;
|
|
return score;
|
|
}, 0);
|
|
}
|
|
|
|
function resolveDirectShowWebcamClsid(requestedName) {
|
|
if (!requestedName) return "";
|
|
const query = spawnSync(
|
|
"reg.exe",
|
|
["query", "HKCR\\CLSID\\{860BB310-5D01-11D0-BD3B-00A0C911CE86}\\Instance", "/s"],
|
|
{ encoding: "utf8", windowsHide: true },
|
|
);
|
|
if (query.status !== 0) return "";
|
|
const entries = [];
|
|
let current = {};
|
|
for (const rawLine of query.stdout.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (!line) continue;
|
|
if (/^HKEY_/i.test(line)) {
|
|
if (current.friendlyName || current.clsid) entries.push(current);
|
|
current = {};
|
|
continue;
|
|
}
|
|
const match = line.match(/^(\S+)\s+REG_SZ\s+(.+)$/);
|
|
if (!match) continue;
|
|
if (match[1] === "FriendlyName") current.friendlyName = match[2].trim();
|
|
if (match[1] === "CLSID") current.clsid = match[2].trim();
|
|
}
|
|
if (current.friendlyName || current.clsid) entries.push(current);
|
|
|
|
let best = null;
|
|
for (const entry of entries) {
|
|
if (!entry.clsid) continue;
|
|
const score = scoreDeviceName(entry.friendlyName, entry.clsid, requestedName);
|
|
if (!best || score > best.score) {
|
|
best = { ...entry, score };
|
|
}
|
|
}
|
|
return best && best.score > 0 ? best.clsid : "";
|
|
}
|
|
|
|
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_WEBCAM ? "webcam" : WITH_WINDOW ? "window" : WITH_SYSTEM_AUDIO || WITH_MICROPHONE ? "audio" : "video"}-${process.pid}-${Date.now()}-${randomUUID()}.mp4`,
|
|
);
|
|
const webcamOutputPath = WITH_WEBCAM ? outputPath.replace(/\.mp4$/i, "-webcam.mp4") : null;
|
|
|
|
const fixtureWindow = WITH_WINDOW ? await startFixtureWindow() : null;
|
|
|
|
const config = {
|
|
schemaVersion: 2,
|
|
recordingId: Date.now(),
|
|
outputPath,
|
|
sourceType: fixtureWindow ? "window" : "display",
|
|
sourceId: fixtureWindow ? fixtureWindow.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: WITH_MICROPHONE,
|
|
captureCursor: CAPTURE_CURSOR,
|
|
microphoneDeviceId: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_ID ?? "default",
|
|
microphoneDeviceName: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME ?? "",
|
|
microphoneGain: 1.4,
|
|
webcamEnabled: WITH_WEBCAM,
|
|
webcamDeviceId: process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_ID ?? "",
|
|
webcamDeviceName: process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME ?? "",
|
|
webcamDirectShowClsid: resolveDirectShowWebcamClsid(
|
|
process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME ?? "",
|
|
),
|
|
webcamWidth: 640,
|
|
webcamHeight: 360,
|
|
webcamFps: 30,
|
|
outputs: {
|
|
screenPath: outputPath,
|
|
...(webcamOutputPath ? { webcamPath: webcamOutputPath } : {}),
|
|
},
|
|
};
|
|
|
|
let result;
|
|
try {
|
|
result = await runHelper(config);
|
|
} finally {
|
|
if (fixtureWindow) {
|
|
fixtureWindow.child.kill();
|
|
}
|
|
}
|
|
if (result.code !== 0) {
|
|
if (
|
|
WITH_WEBCAM &&
|
|
/No native Windows webcam devices were found|Failed to initialize native webcam/.test(
|
|
result.stderr,
|
|
)
|
|
) {
|
|
console.log("Skipping WGC webcam smoke test: no native Windows webcam device is available.");
|
|
process.exit(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}`);
|
|
}
|
|
if (WITH_WEBCAM && (!fs.existsSync(webcamOutputPath) || fs.statSync(webcamOutputPath).size === 0)) {
|
|
throw new Error(`WGC helper did not produce a webcam video at ${webcamOutputPath}`);
|
|
}
|
|
|
|
const streams = probeStreams(outputPath);
|
|
const webcamStreams =
|
|
webcamOutputPath && fs.existsSync(webcamOutputPath) ? probeStreams(webcamOutputPath) : [];
|
|
const hasVideo = streams.some((stream) => stream.codec_type === "video");
|
|
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
|
|
const webcamFormatLine = result.stdout
|
|
.split(/\r?\n/)
|
|
.find((line) => line.includes('"event":"webcam-format"'));
|
|
const webcamFormat = webcamFormatLine ? JSON.parse(webcamFormatLine) : null;
|
|
const audioFormatLine = result.stdout
|
|
.split(/\r?\n/)
|
|
.find((line) => line.includes('"event":"audio-format"'));
|
|
const audioFormat = audioFormatLine ? JSON.parse(audioFormatLine) : null;
|
|
const cursorCaptureLine = result.stdout
|
|
.split(/\r?\n/)
|
|
.find((line) => line.includes('"event":"cursor-capture"'));
|
|
const cursorCapture = cursorCaptureLine ? JSON.parse(cursorCaptureLine) : null;
|
|
const nativeWebcamDiagnostics = result.stderr
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.includes("Native webcam candidate"));
|
|
const nativeMicrophoneDiagnostics = result.stderr
|
|
.split(/\r?\n/)
|
|
.filter(
|
|
(line) =>
|
|
line.includes("Native microphone candidate") ||
|
|
line.includes("Selected native microphone endpoint"),
|
|
);
|
|
if (!hasVideo) {
|
|
throw new Error(`WGC helper output has no video stream: ${outputPath}`);
|
|
}
|
|
if (WITH_WEBCAM && !webcamStreams.some((stream) => stream.codec_type === "video")) {
|
|
throw new Error(`WGC helper webcam output has no video stream: ${webcamOutputPath}`);
|
|
}
|
|
if (
|
|
(CAPTURE_CURSOR && !cursorCapture) ||
|
|
(cursorCapture &&
|
|
(cursorCapture.requested !== CAPTURE_CURSOR || cursorCapture.applied !== CAPTURE_CURSOR))
|
|
) {
|
|
throw new Error(
|
|
`WGC helper did not apply requested cursor capture mode (${CAPTURE_CURSOR}): ${result.stdout}`,
|
|
);
|
|
}
|
|
if ((WITH_SYSTEM_AUDIO || WITH_MICROPHONE) && !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}\n${result.stdout}\n${result.stderr}`,
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
success: true,
|
|
outputPath,
|
|
webcamOutputPath,
|
|
bytes: fs.statSync(outputPath).size,
|
|
webcamBytes:
|
|
webcamOutputPath && fs.existsSync(webcamOutputPath)
|
|
? fs.statSync(webcamOutputPath).size
|
|
: undefined,
|
|
streams: streams.map((stream) => ({
|
|
index: stream.index,
|
|
codecType: stream.codec_type,
|
|
codecName: stream.codec_name,
|
|
duration: stream.duration,
|
|
})),
|
|
webcamStreams: webcamStreams.map((stream) => ({
|
|
index: stream.index,
|
|
codecType: stream.codec_type,
|
|
codecName: stream.codec_name,
|
|
width: stream.width,
|
|
height: stream.height,
|
|
duration: stream.duration,
|
|
})),
|
|
cursorCapture,
|
|
selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName,
|
|
selectedWebcamDeviceName: webcamFormat?.deviceName,
|
|
nativeMicrophoneDiagnostics,
|
|
nativeWebcamDiagnostics,
|
|
firstFrameLuma: frameLuma,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|