523 lines
17 KiB
JavaScript
523 lines
17 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { readFile, stat } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
const REPOSITORY_ROOT = path.resolve(path.dirname(SCRIPT_PATH), '..');
|
|
const FIXTURE_RELATIVE_PATH = 'tests/fixtures/generated/tone.wav';
|
|
const FIXTURE_PATH = path.join(REPOSITORY_ROOT, FIXTURE_RELATIVE_PATH);
|
|
const EXPECTED_FIXTURE_BYTES = 32_102;
|
|
const EXPECTED_FIXTURE_SHA256 =
|
|
'0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c';
|
|
const DEFAULT_MODE_TIMEOUT_MILLISECONDS = 60_000;
|
|
const RESULT_PREFIX = 'OPUS_RESULT ';
|
|
const EVENT_PREFIX = 'OPUS_EVENT ';
|
|
const MODES = Object.freeze(['single-thread', 'multithread']);
|
|
|
|
const argumentsSet = new Set(process.argv.slice(2));
|
|
const option = (name) =>
|
|
process.argv
|
|
.slice(2)
|
|
.find((argument) => argument.startsWith(`--${name}=`))
|
|
?.slice(name.length + 3);
|
|
|
|
function boundedInteger(value, fallback, minimum, maximum) {
|
|
const parsed = Number(value);
|
|
return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum
|
|
? parsed
|
|
: fallback;
|
|
}
|
|
|
|
function serializeError(error) {
|
|
if (error instanceof Error) {
|
|
return {
|
|
name: error.name,
|
|
message: error.message,
|
|
...(error.stack ? { stack: error.stack.split('\n').slice(0, 8) } : {}),
|
|
};
|
|
}
|
|
return { name: 'Error', message: String(error) };
|
|
}
|
|
|
|
function emit(prefix, value) {
|
|
process.stdout.write(`${prefix}${JSON.stringify(value)}\n`);
|
|
}
|
|
|
|
function parsePrefixedJsonLines(text, prefix) {
|
|
return text
|
|
.split(/\r?\n/u)
|
|
.filter((line) => line.startsWith(prefix))
|
|
.flatMap((line) => {
|
|
try {
|
|
return [JSON.parse(line.slice(prefix.length))];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
|
|
function appendBounded(current, chunk, maximumLength = 32_768) {
|
|
return `${current}${chunk}`.slice(-maximumLength);
|
|
}
|
|
|
|
function terminateProcessGroup(child) {
|
|
if (!child.pid) return;
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
child.kill('SIGKILL');
|
|
} else {
|
|
process.kill(-child.pid, 'SIGKILL');
|
|
}
|
|
} catch {
|
|
child.kill('SIGKILL');
|
|
}
|
|
}
|
|
|
|
async function runIsolatedMode(mode, origin, timeoutMilliseconds) {
|
|
const startedAt = performance.now();
|
|
const child = spawn(
|
|
process.execPath,
|
|
[SCRIPT_PATH, '--child', `--mode=${mode}`, `--origin=${origin}`],
|
|
{
|
|
cwd: REPOSITORY_ROOT,
|
|
detached: process.platform !== 'win32',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
}
|
|
);
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let timedOut = false;
|
|
|
|
child.stdout.setEncoding('utf8');
|
|
child.stderr.setEncoding('utf8');
|
|
child.stdout.on('data', (chunk) => {
|
|
stdout = appendBounded(stdout, chunk);
|
|
for (const event of parsePrefixedJsonLines(chunk, EVENT_PREFIX)) {
|
|
if (event.stage && event.stage !== 'core-log') {
|
|
process.stdout.write(`[opus:${mode}] ${event.stage}\n`);
|
|
}
|
|
}
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr = appendBounded(stderr, chunk);
|
|
});
|
|
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
terminateProcessGroup(child);
|
|
}, timeoutMilliseconds);
|
|
|
|
const { code, signal } = await new Promise((resolve) => {
|
|
child.once('close', (exitCode, exitSignal) => {
|
|
resolve({ code: exitCode, signal: exitSignal });
|
|
});
|
|
});
|
|
clearTimeout(timeout);
|
|
|
|
const elapsedMilliseconds = Math.round(performance.now() - startedAt);
|
|
const events = parsePrefixedJsonLines(stdout, EVENT_PREFIX);
|
|
const reported = parsePrefixedJsonLines(stdout, RESULT_PREFIX).at(-1);
|
|
if (reported) {
|
|
return {
|
|
...reported,
|
|
elapsedMilliseconds,
|
|
process: { exitCode: code, signal },
|
|
};
|
|
}
|
|
|
|
const coreReady = events.find((event) => event.stage === 'core-ready');
|
|
const lastStage = [...events]
|
|
.reverse()
|
|
.find((event) => event.stage && event.stage !== 'core-log');
|
|
const relevantLogs = events
|
|
.filter((event) => event.stage === 'core-log')
|
|
.slice(-16)
|
|
.map((event) => event.message);
|
|
return {
|
|
requestedMode: mode,
|
|
status: 'fail',
|
|
failure: timedOut ? 'timeout' : 'child-exited-without-result',
|
|
message: timedOut
|
|
? `The isolated browser did not finish within ${String(timeoutMilliseconds)} ms.`
|
|
: 'The isolated browser exited without a structured result.',
|
|
...(coreReady?.actualMode ? { actualMode: coreReady.actualMode } : {}),
|
|
...(coreReady?.ffmpegVersion
|
|
? { ffmpegVersion: coreReady.ffmpegVersion }
|
|
: {}),
|
|
...(coreReady?.capabilities
|
|
? { capabilities: coreReady.capabilities }
|
|
: {}),
|
|
...(lastStage?.stage ? { lastStage: lastStage.stage } : {}),
|
|
...(relevantLogs.length > 0 ? { relevantLogs } : {}),
|
|
...(stderr.trim() ? { stderr: stderr.trim().split('\n').slice(-12) } : {}),
|
|
elapsedMilliseconds,
|
|
process: { exitCode: code, signal },
|
|
};
|
|
}
|
|
|
|
async function fixtureIdentity() {
|
|
const [bytes, fixtureStat] = await Promise.all([
|
|
readFile(FIXTURE_PATH),
|
|
stat(FIXTURE_PATH),
|
|
]);
|
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
if (
|
|
fixtureStat.size !== EXPECTED_FIXTURE_BYTES ||
|
|
sha256 !== EXPECTED_FIXTURE_SHA256
|
|
) {
|
|
throw new Error(
|
|
`Opus fixture identity mismatch: expected ${String(EXPECTED_FIXTURE_BYTES)} bytes/${EXPECTED_FIXTURE_SHA256}, received ${String(fixtureStat.size)} bytes/${sha256}.`
|
|
);
|
|
}
|
|
return {
|
|
path: FIXTURE_RELATIVE_PATH,
|
|
bytes: fixtureStat.size,
|
|
sha256,
|
|
};
|
|
}
|
|
|
|
async function runMain() {
|
|
const timeoutMilliseconds = boundedInteger(
|
|
option('timeout-ms') ?? process.env.AV_OPUS_REGRESSION_TIMEOUT_MS,
|
|
DEFAULT_MODE_TIMEOUT_MILLISECONDS,
|
|
10_000,
|
|
10 * 60_000
|
|
);
|
|
const [fixture, packageText, coreLockText] = await Promise.all([
|
|
fixtureIdentity(),
|
|
readFile(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
|
|
readFile(
|
|
path.join(REPOSITORY_ROOT, 'scripts', 'reviewed-ffmpeg-core-lock.json'),
|
|
'utf8'
|
|
),
|
|
]);
|
|
const packageManifest = JSON.parse(packageText);
|
|
const coreLock = JSON.parse(coreLockText);
|
|
const { createServer } = await import('vite');
|
|
const server = await createServer({
|
|
configFile: path.join(REPOSITORY_ROOT, 'vite.config.ts'),
|
|
logLevel: 'warn',
|
|
server: {
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
strictPort: false,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await server.listen();
|
|
const address = server.httpServer?.address();
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('Vite did not expose a local TCP address.');
|
|
}
|
|
const origin = `http://127.0.0.1:${String(address.port)}`;
|
|
const modes = [];
|
|
for (const mode of MODES) {
|
|
modes.push(await runIsolatedMode(mode, origin, timeoutMilliseconds));
|
|
}
|
|
const report = {
|
|
schemaVersion: 2,
|
|
recordedAt: new Date().toISOString(),
|
|
browser: 'chromium',
|
|
wrapperVersion: packageManifest.dependencies['@ffmpeg/ffmpeg'],
|
|
coreVersion: coreLock.coreVersion,
|
|
coreBuildId: coreLock.buildId,
|
|
coreProfile: coreLock.profile,
|
|
fixture,
|
|
perModeTimeoutMilliseconds: timeoutMilliseconds,
|
|
modes,
|
|
safe: modes.every((result) => result.status === 'pass'),
|
|
};
|
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
if (argumentsSet.has('--require-pass') && !report.safe) {
|
|
process.exitCode = 1;
|
|
}
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
}
|
|
|
|
async function runBrowserMode(mode, origin) {
|
|
const { chromium } = await import('@playwright/test');
|
|
const browser = await chromium.launch({ headless: true });
|
|
try {
|
|
const page = await browser.newPage();
|
|
await page.exposeFunction('__reportOpusEvent', (event) => {
|
|
emit(EVENT_PREFIX, event);
|
|
});
|
|
await page.goto(origin, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 30_000,
|
|
});
|
|
return await page.evaluate(
|
|
async ({ fixtureUrl, requestedMode }) => {
|
|
const report = async (event) => {
|
|
await globalThis.__reportOpusEvent(event);
|
|
};
|
|
const replacePaths = (plan, context) => {
|
|
const replacements = new Map();
|
|
plan.inputs.forEach((input, index) => {
|
|
const actual = context.inputPaths[index];
|
|
if (actual) replacements.set(input.path, actual);
|
|
});
|
|
plan.outputs.forEach((output, index) => {
|
|
const actual = context.outputPaths[index];
|
|
if (actual) replacements.set(output.path, actual);
|
|
});
|
|
plan.temporaryFiles.forEach((temporary, index) => {
|
|
const actual = context.temporaryPaths[index];
|
|
if (actual) replacements.set(temporary.path, actual);
|
|
});
|
|
return plan.args.map((argument) =>
|
|
[...replacements].reduce(
|
|
(resolved, [virtualPath, actualPath]) =>
|
|
resolved.replaceAll(virtualPath, actualPath),
|
|
argument
|
|
)
|
|
);
|
|
};
|
|
const errorShape = (error) =>
|
|
error instanceof Error
|
|
? {
|
|
name: error.name,
|
|
message: error.message,
|
|
...(error.stack
|
|
? { stack: error.stack.split('\n').slice(0, 8) }
|
|
: {}),
|
|
}
|
|
: { name: 'Error', message: String(error) };
|
|
let manager;
|
|
const relevantLogs = [];
|
|
try {
|
|
await report({ stage: 'module-import' });
|
|
const [{ EngineManager }, { buildConvertPlan }, { AUDIO_PRESETS }] =
|
|
await Promise.all([
|
|
import('/src/ffmpeg/EngineManager.ts'),
|
|
import('/src/commands/convert.ts'),
|
|
import('/src/presets/audio-presets.ts'),
|
|
]);
|
|
manager = new EngineManager();
|
|
manager.setPreference(
|
|
requestedMode === 'single-thread'
|
|
? 'force-single-thread'
|
|
: 'prefer-multithread'
|
|
);
|
|
manager.onLog((event) => {
|
|
for (const line of event.message.split(/\r?\n/u)) {
|
|
if (
|
|
relevantLogs.length < 32 &&
|
|
/libopus|Output #|Stream mapping|Aborted\(\)|(?:^|\s)(?:size|progress)=|error|failed/iu.test(
|
|
line
|
|
)
|
|
) {
|
|
const message = line.slice(0, 600);
|
|
relevantLogs.push(message);
|
|
void report({ stage: 'core-log', message });
|
|
}
|
|
}
|
|
});
|
|
|
|
await report({ stage: 'fixture-fetch' });
|
|
const fixtureResponse = await fetch(fixtureUrl);
|
|
if (!fixtureResponse.ok) {
|
|
throw new Error(
|
|
`Fixture request failed with HTTP ${String(fixtureResponse.status)}.`
|
|
);
|
|
}
|
|
const fixtureBytes = new Uint8Array(
|
|
await fixtureResponse.arrayBuffer()
|
|
);
|
|
const sourceFile = new File([fixtureBytes], 'tone.wav', {
|
|
type: 'audio/wav',
|
|
});
|
|
|
|
await report({ stage: 'core-initializing' });
|
|
await manager.initialize();
|
|
const ready = manager.state;
|
|
if (ready.status !== 'ready') {
|
|
throw new Error('The FFmpeg engine did not reach ready state.');
|
|
}
|
|
const capabilities = {
|
|
opusMuxer: ready.capabilities.muxers.has('opus'),
|
|
libopusEncoder: ready.capabilities.encoders.has('libopus'),
|
|
};
|
|
await report({
|
|
stage: 'core-ready',
|
|
requestedMode,
|
|
actualMode: ready.mode,
|
|
ffmpegVersion: ready.capabilities.versionText,
|
|
capabilities,
|
|
});
|
|
if (ready.mode !== requestedMode) {
|
|
throw new Error(
|
|
`Requested ${requestedMode}, but the engine selected ${ready.mode}.`
|
|
);
|
|
}
|
|
if (!capabilities.opusMuxer || !capabilities.libopusEncoder) {
|
|
throw new Error(
|
|
'The pinned core does not advertise the Opus muxer and libopus encoder.'
|
|
);
|
|
}
|
|
|
|
const preset = AUDIO_PRESETS.find(
|
|
(candidate) => candidate.id === 'audio-opus'
|
|
);
|
|
if (!preset) {
|
|
throw new Error('The built-in audio-opus preset is missing.');
|
|
}
|
|
const plan = buildConvertPlan({
|
|
jobId: `opus-regression-${requestedMode}`,
|
|
source: {
|
|
id: 'tone',
|
|
sourceIndex: 0,
|
|
fileName: 'tone.wav',
|
|
extension: 'wav',
|
|
},
|
|
preset,
|
|
streamSelection: { audio: [0] },
|
|
expectedDurationSeconds: 1,
|
|
timeoutMs: 30_000,
|
|
});
|
|
await report({
|
|
stage: 'encode-started',
|
|
presetId: preset.id,
|
|
requirements: plan.requiredCapabilities,
|
|
});
|
|
let commandArguments = [];
|
|
const completed = await manager.runJob({
|
|
id: `opus-regression-${requestedMode}`,
|
|
operation: 'Opus regression',
|
|
coreWorkload: plan.operation,
|
|
inputs: [sourceFile],
|
|
outputs: plan.outputs.map((output) => ({
|
|
name: output.fileName,
|
|
mimeType: output.mediaType,
|
|
})),
|
|
expectedDurationSeconds: plan.expectedDurationSeconds,
|
|
timeoutMilliseconds: plan.timeoutMs,
|
|
buildArguments: (context) => {
|
|
commandArguments = replacePaths(plan, context);
|
|
void report({
|
|
stage: 'encode-command',
|
|
arguments: commandArguments,
|
|
});
|
|
return commandArguments;
|
|
},
|
|
});
|
|
const output = completed.outputs[0];
|
|
if (!output || output.bytes.byteLength === 0) {
|
|
throw new Error(
|
|
'The Opus encode did not produce non-empty output.'
|
|
);
|
|
}
|
|
await report({
|
|
stage: 'encode-finished',
|
|
outputBytes: output.bytes.byteLength,
|
|
});
|
|
|
|
const outputFile = new File([output.bytes], output.name, {
|
|
type: output.mimeType,
|
|
});
|
|
await report({ stage: 'reprobe-started' });
|
|
const probe = await manager.probe(outputFile, 15_000);
|
|
const probeJson = probe.json;
|
|
const streams = Array.isArray(probeJson?.streams)
|
|
? probeJson.streams
|
|
: [];
|
|
const opusStream = streams.find(
|
|
(stream) =>
|
|
stream?.codec_type === 'audio' && stream?.codec_name === 'opus'
|
|
);
|
|
const durationSeconds = Number(
|
|
probeJson?.format?.duration ?? opusStream?.duration
|
|
);
|
|
if (!opusStream || !(durationSeconds > 0)) {
|
|
throw new Error(
|
|
'The generated file did not re-probe as positive-duration Opus audio.'
|
|
);
|
|
}
|
|
const digest = new Uint8Array(
|
|
await crypto.subtle.digest('SHA-256', output.bytes)
|
|
);
|
|
const outputSha256 = [...digest]
|
|
.map((value) => value.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
await report({ stage: 'reprobe-finished', durationSeconds });
|
|
return {
|
|
requestedMode,
|
|
actualMode: ready.mode,
|
|
status: 'pass',
|
|
ffmpegVersion: ready.capabilities.versionText,
|
|
capabilities,
|
|
presetId: preset.id,
|
|
commandArguments,
|
|
output: {
|
|
name: output.name,
|
|
mimeType: output.mimeType,
|
|
bytes: output.bytes.byteLength,
|
|
sha256: outputSha256,
|
|
codecName: opusStream.codec_name,
|
|
codecType: opusStream.codec_type,
|
|
durationSeconds,
|
|
},
|
|
relevantLogs,
|
|
};
|
|
} catch (error) {
|
|
const state = manager?.state;
|
|
return {
|
|
requestedMode,
|
|
...(state?.mode ? { actualMode: state.mode } : {}),
|
|
status: 'fail',
|
|
failure: 'browser-operation-failed',
|
|
error: errorShape(error),
|
|
relevantLogs,
|
|
};
|
|
} finally {
|
|
manager?.dispose();
|
|
}
|
|
},
|
|
{
|
|
fixtureUrl: `${origin}/${FIXTURE_RELATIVE_PATH}`,
|
|
requestedMode: mode,
|
|
}
|
|
);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
async function runChild() {
|
|
const mode = option('mode');
|
|
const origin = option('origin');
|
|
if (!MODES.includes(mode) || !origin) {
|
|
throw new Error('Child mode requires valid --mode and --origin options.');
|
|
}
|
|
emit(EVENT_PREFIX, { stage: 'browser-launching', requestedMode: mode });
|
|
try {
|
|
const result = await runBrowserMode(mode, origin);
|
|
emit(RESULT_PREFIX, result);
|
|
} catch (error) {
|
|
emit(RESULT_PREFIX, {
|
|
requestedMode: mode,
|
|
status: 'fail',
|
|
failure: 'browser-process-failed',
|
|
error: serializeError(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (argumentsSet.has('--child')) {
|
|
await runChild();
|
|
} else {
|
|
await runMain();
|
|
}
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`Opus regression harness failed: ${JSON.stringify(serializeError(error))}\n`
|
|
);
|
|
process.exitCode = 2;
|
|
}
|