assertLockedTargetFailure compared the rename paths from the product publish-lock diagnostic against the expected output file with path.resolve only. On runners whose temporary directory surfaces as a Windows 8.3 short path the smoke passes a short-path output file while the product logs canonical long paths, so a correct atomic publish lock diagnostic was rejected and the cutter media matrix failed. sameResolvedPath now canonicalizes both sides through the existing resolveCanonicalPath helper with a resolve fallback, covered by a ShortPath contract test.
1077 lines
51 KiB
JavaScript
1077 lines
51 KiB
JavaScript
const { _electron: electron } = require('playwright');
|
|
const nodeCrypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { spawn, spawnSync } = require('node:child_process');
|
|
const { requireFileCapability } = require('./file-capability-contract');
|
|
const {
|
|
cleanupE2eEnvironment,
|
|
createE2eEnvironment,
|
|
getElectronLaunchOptions,
|
|
verifyE2eIsolation
|
|
} = require('./e2e-test-environment');
|
|
|
|
const fixtureDuration = 3;
|
|
const exportTrimStart = 0.25;
|
|
const exportTrimEnd = 2.75;
|
|
const expectedExportDuration = exportTrimEnd - exportTrimStart;
|
|
const projectRoot = path.resolve(__dirname, '..');
|
|
const offlineProxy = 'http://127.0.0.1:1';
|
|
const cutterMatrixTimeouts = Object.freeze({
|
|
prepareSource: 90000,
|
|
exportSource: 180000,
|
|
appClose: 15000,
|
|
appProcessExit: 5000,
|
|
diagnostics: 30000
|
|
});
|
|
const markerFrequencies = Object.freeze({
|
|
initial: 220,
|
|
start: 440,
|
|
end: 1760
|
|
});
|
|
const markerTimes = Object.freeze({
|
|
initialEnd: 0.2,
|
|
startEnd: 0.55,
|
|
endStart: 2.55,
|
|
sourceStartSample: 0.3,
|
|
sourceEndSample: 2.65,
|
|
outputStartSample: 0.05,
|
|
outputEndSample: expectedExportDuration - 0.15
|
|
});
|
|
const markerAudioSampleRate = 48000;
|
|
|
|
function resolveCanonicalPath(candidatePath) {
|
|
let existingPath = path.resolve(candidatePath);
|
|
const missingSegments = [];
|
|
while (true) {
|
|
try {
|
|
return path.join(fs.realpathSync.native(existingPath), ...missingSegments.reverse());
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
const parentPath = path.dirname(existingPath);
|
|
if (parentPath === existingPath) return path.resolve(candidatePath);
|
|
missingSegments.push(path.basename(existingPath));
|
|
existingPath = parentPath;
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertPathInside(targetPath, parentPath, label) {
|
|
const resolvedTarget = resolveCanonicalPath(targetPath);
|
|
const resolvedParent = resolveCanonicalPath(parentPath);
|
|
const relative = path.relative(resolvedParent, resolvedTarget);
|
|
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
throw new Error(`${label} is outside the owned managed-tool directory: ${resolvedTarget}`);
|
|
}
|
|
}
|
|
|
|
function isDigitCharacter(character) {
|
|
return character >= '0' && character <= '9';
|
|
}
|
|
|
|
function isBuildSuffixCharacter(character) {
|
|
return isDigitCharacter(character)
|
|
|| (character >= 'A' && character <= 'Z')
|
|
|| (character >= 'a' && character <= 'z')
|
|
|| character === '.'
|
|
|| character === '_'
|
|
|| character === '-'
|
|
|| character === '+';
|
|
}
|
|
|
|
function parseVersionToken(versionToken) {
|
|
if (!versionToken) return null;
|
|
const delimiterIndexes = [versionToken.indexOf('-'), versionToken.indexOf('+')].filter((index) => index >= 0);
|
|
const delimiterIndex = delimiterIndexes.length > 0 ? Math.min(...delimiterIndexes) : -1;
|
|
const numericVersion = delimiterIndex >= 0 ? versionToken.slice(0, delimiterIndex) : versionToken;
|
|
const suffix = delimiterIndex >= 0 ? versionToken.slice(delimiterIndex + 1) : null;
|
|
const delimiter = delimiterIndex >= 0 ? versionToken[delimiterIndex] : null;
|
|
const numericSegments = numericVersion.split('.');
|
|
if (numericSegments.length < 2 || numericSegments.some((segment) => segment.length === 0 || [...segment].some((character) => !isDigitCharacter(character)))) return null;
|
|
if (suffix !== null && (suffix.length === 0 || [...suffix].some((character) => !isBuildSuffixCharacter(character)))) return null;
|
|
return { numericVersion, delimiter, suffix };
|
|
}
|
|
|
|
function assertPinnedVersion(output, version, label) {
|
|
const executable = String(label).toLowerCase().includes('streamlink')
|
|
? 'streamlink'
|
|
: String(label).toLowerCase().includes('ffprobe')
|
|
? 'ffprobe'
|
|
: 'ffmpeg';
|
|
const firstLine = String(output).split(/\r?\n/, 1)[0].trim();
|
|
const tokens = firstLine.split(/\s+/);
|
|
const versionToken = executable === 'streamlink' ? tokens[1] : tokens[2];
|
|
const hasExpectedFormat = executable === 'streamlink' || tokens[1]?.toLowerCase() === 'version';
|
|
const parsedVersion = parseVersionToken(versionToken);
|
|
if (tokens[0]?.toLowerCase() !== executable || !hasExpectedFormat || !parsedVersion) {
|
|
throw new Error(`${label} does not expose a parseable version: ${output}`);
|
|
}
|
|
if (parsedVersion.numericVersion !== String(version)) {
|
|
throw new Error(`${label} version output does not match pinned ${version}: ${output}`);
|
|
}
|
|
const expectedGyanSuffix = executable === 'streamlink' ? null : 'essentials_build-www.gyan.dev';
|
|
if (parsedVersion.suffix !== null && (parsedVersion.delimiter !== '-' || parsedVersion.suffix !== expectedGyanSuffix)) {
|
|
throw new Error(`${label} does not expose a parseable version: ${output}`);
|
|
}
|
|
}
|
|
|
|
async function withOperationTimeout(operation, label, timeoutMs) {
|
|
let timeout;
|
|
try {
|
|
return await Promise.race([
|
|
Promise.resolve().then(operation),
|
|
new Promise((_, reject) => {
|
|
timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
})
|
|
]);
|
|
} finally {
|
|
if (timeout) clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function runBinary(binary, args, timeout = 120000, execute = spawnSync) {
|
|
const result = execute(binary, args, {
|
|
windowsHide: true,
|
|
stdio: 'pipe',
|
|
encoding: 'utf8',
|
|
timeout
|
|
});
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) throw new Error(result.stderr.slice(0, 2000) || `${binary} exited with ${result.status}`);
|
|
return result.stdout;
|
|
}
|
|
|
|
function runBinaryBuffer(binary, args, timeout = 120000, execute = spawnSync) {
|
|
const result = execute(binary, args, {
|
|
windowsHide: true,
|
|
stdio: 'pipe',
|
|
encoding: null,
|
|
timeout
|
|
});
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) throw new Error(Buffer.from(result.stderr || '').toString('utf8').slice(0, 2000) || `${binary} exited with ${result.status}`);
|
|
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout || '');
|
|
}
|
|
|
|
function runVersionCheck(executablePath, args, label) {
|
|
const output = runBinary(executablePath, args, 60000);
|
|
if (!output.trim()) throw new Error(`${label} version check produced no output`);
|
|
return output.split(/\r?\n/, 1)[0];
|
|
}
|
|
|
|
function loadBuiltToolArtifacts() {
|
|
const toolsPath = path.join(projectRoot, 'dist', 'tools.js');
|
|
const manifestPath = path.join(projectRoot, 'dist', 'main', 'domain', 'tool-manifest.js');
|
|
if (!fs.existsSync(toolsPath)) throw new Error('Build output is missing; run npm run build first');
|
|
if (!fs.existsSync(manifestPath)) throw new Error('Built tool manifest is missing; run npm run build first');
|
|
return {
|
|
tools: require(toolsPath),
|
|
manifest: require(manifestPath).APPLICATION_TOOL_MANIFEST
|
|
};
|
|
}
|
|
|
|
function assertVerifiedManagedStatus(status, expectedVersion, label) {
|
|
if (!status?.verified || status.state !== 'verified' || status.version !== expectedVersion) {
|
|
throw new Error(`${label} is not verified at pinned ${expectedVersion}: ${JSON.stringify(status)}`);
|
|
}
|
|
}
|
|
|
|
async function provisionManagedCutterTools(environment, options = {}) {
|
|
const streamlinkDirectory = path.join(environment.appDataDir, 'tools', 'streamlink');
|
|
const ffmpegDirectory = path.join(environment.appDataDir, 'tools', 'ffmpeg');
|
|
const temporaryDirectory = path.join(environment.rootDir, 'managed-tools-temp');
|
|
for (const directory of [streamlinkDirectory, ffmpegDirectory, temporaryDirectory]) {
|
|
assertPathInside(directory, environment.rootDir, 'Managed-tool directory');
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
}
|
|
const loadArtifacts = options.loadBuiltArtifacts || loadBuiltToolArtifacts;
|
|
const checkVersion = options.runVersionCheck || runVersionCheck;
|
|
const { tools, manifest } = loadArtifacts();
|
|
tools.initToolDirs(streamlinkDirectory, ffmpegDirectory, () => temporaryDirectory);
|
|
const repair = await tools.repairManagedTools();
|
|
if (!repair.success) throw new Error(`Pinned product-tool provisioning failed: ${JSON.stringify(repair.statuses)}`);
|
|
assertVerifiedManagedStatus(repair.statuses.streamlink, manifest.streamlink.version, 'Streamlink');
|
|
assertVerifiedManagedStatus(repair.statuses.ffmpeg, manifest.ffmpeg.version, 'FFmpeg');
|
|
const paths = {
|
|
streamlink: fs.realpathSync.native(tools.getStreamlinkPath()),
|
|
ffmpeg: fs.realpathSync.native(tools.getFFmpegPath()),
|
|
ffprobe: fs.realpathSync.native(tools.getFFprobePath())
|
|
};
|
|
assertPathInside(paths.streamlink, streamlinkDirectory, 'Streamlink path');
|
|
assertPathInside(paths.ffmpeg, ffmpegDirectory, 'FFmpeg path');
|
|
assertPathInside(paths.ffprobe, ffmpegDirectory, 'FFprobe path');
|
|
const versions = {
|
|
streamlink: checkVersion(paths.streamlink, ['--version'], 'Streamlink'),
|
|
ffmpeg: checkVersion(paths.ffmpeg, ['-version'], 'FFmpeg'),
|
|
ffprobe: checkVersion(paths.ffprobe, ['-version'], 'FFprobe')
|
|
};
|
|
assertPinnedVersion(versions.streamlink, manifest.streamlink.version, 'Streamlink');
|
|
assertPinnedVersion(versions.ffmpeg, manifest.ffmpeg.version, 'FFmpeg');
|
|
assertPinnedVersion(versions.ffprobe, manifest.ffmpeg.version, 'FFprobe');
|
|
return { manifest, paths, statuses: repair.statuses, versions };
|
|
}
|
|
|
|
function createManagedMediaRuntime(paths, execute = spawnSync) {
|
|
if (!path.isAbsolute(paths.ffmpeg) || !path.isAbsolute(paths.ffprobe)) {
|
|
throw new Error('Managed media runtime requires absolute product-tool paths');
|
|
}
|
|
return {
|
|
ffmpeg: (args, timeout) => runBinary(paths.ffmpeg, args, timeout, execute),
|
|
ffmpegBuffer: (args, timeout) => runBinaryBuffer(paths.ffmpeg, args, timeout, execute),
|
|
ffprobe: (args, timeout) => runBinary(paths.ffprobe, args, timeout, execute)
|
|
};
|
|
}
|
|
|
|
function setEnvironmentValue(environmentVariables, expectedKey, value, snapshots) {
|
|
const key = Object.keys(environmentVariables).find((candidate) => candidate.toLowerCase() === expectedKey.toLowerCase()) || expectedKey;
|
|
snapshots.push({ key, existed: Object.prototype.hasOwnProperty.call(environmentVariables, key), value: environmentVariables[key] });
|
|
environmentVariables[key] = value;
|
|
}
|
|
|
|
function getEnvironmentValue(environmentVariables, expectedKey) {
|
|
const key = Object.keys(environmentVariables).find((candidate) => candidate.toLowerCase() === expectedKey.toLowerCase());
|
|
return key ? environmentVariables[key] : undefined;
|
|
}
|
|
|
|
function activateOfflineRunnerEnvironment(environment, environmentVariables = process.env) {
|
|
const directories = {
|
|
path: path.join(environment.rootDir, 'offline-path'),
|
|
localAppData: path.join(environment.rootDir, 'localappdata'),
|
|
roamingAppData: path.join(environment.rootDir, 'roamingappdata'),
|
|
temp: path.join(environment.rootDir, 'runtime-temp')
|
|
};
|
|
for (const directory of Object.values(directories)) {
|
|
assertPathInside(directory, environment.rootDir, 'Offline runner directory');
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
}
|
|
const snapshots = [];
|
|
const values = {
|
|
PATH: directories.path,
|
|
HTTP_PROXY: offlineProxy,
|
|
HTTPS_PROXY: offlineProxy,
|
|
ALL_PROXY: offlineProxy,
|
|
NO_PROXY: '',
|
|
http_proxy: offlineProxy,
|
|
https_proxy: offlineProxy,
|
|
all_proxy: offlineProxy,
|
|
no_proxy: '',
|
|
LOCALAPPDATA: directories.localAppData,
|
|
APPDATA: directories.roamingAppData,
|
|
TEMP: directories.temp,
|
|
TMP: directories.temp,
|
|
PROGRAMDATA: environment.programDataDir || path.dirname(environment.appDataDir)
|
|
};
|
|
for (const [key, value] of Object.entries(values)) setEnvironmentValue(environmentVariables, key, value, snapshots);
|
|
let restored = false;
|
|
return () => {
|
|
if (restored) return;
|
|
restored = true;
|
|
for (const snapshot of snapshots.reverse()) {
|
|
if (snapshot.existed) environmentVariables[snapshot.key] = snapshot.value;
|
|
else delete environmentVariables[snapshot.key];
|
|
}
|
|
};
|
|
}
|
|
|
|
function resolvePowerShellExecutable() {
|
|
const windowsDirectory = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
|
|
const executable = path.join(windowsDirectory, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
if (!fs.existsSync(executable)) throw new Error(`PowerShell is missing: ${executable}`);
|
|
return fs.realpathSync.native(executable);
|
|
}
|
|
|
|
function probeMedia(runtime, filePath) {
|
|
const args = ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams'];
|
|
args.push(filePath);
|
|
return JSON.parse(runtime.ffprobe(args));
|
|
}
|
|
|
|
function probeVideoTimestamps(runtime, filePath) {
|
|
const output = runtime.ffprobe([
|
|
'-v', 'error', '-print_format', 'json', '-select_streams', 'v:0',
|
|
'-show_frames', '-show_entries', 'frame=best_effort_timestamp_time', filePath
|
|
]);
|
|
const frames = JSON.parse(output).frames || [];
|
|
return frames.map((frame) => Number(frame.best_effort_timestamp_time)).filter(Number.isFinite);
|
|
}
|
|
|
|
function assertVideoCadence(runtime, definition, filePath) {
|
|
const timestamps = probeVideoTimestamps(runtime, filePath);
|
|
assertCondition(timestamps.length > 1, `${definition.name} output has fewer than two decoded video frames`);
|
|
assertCondition(timestamps.every((timestamp, index) => index === 0 || timestamp > timestamps[index - 1]), `${definition.name} output frame timestamps are not strictly monotone`);
|
|
const deltas = timestamps.slice(1).map((timestamp, index) => timestamp - timestamps[index]);
|
|
const expectedDelta = 1 / definition.expectedOutputRealFps;
|
|
const cadenceTolerance = Math.max(0.0015, expectedDelta * 0.08);
|
|
if (definition.variableFrameRate) {
|
|
const cadenceMultiples = deltas.map((delta) => Math.round(delta / expectedDelta));
|
|
assertCondition(new Set(cadenceMultiples).size >= 2, `${definition.name} output lost its variable frame cadence`);
|
|
assertCondition(deltas.every((delta, index) => cadenceMultiples[index] >= 1 && cadenceMultiples[index] <= 2 && Math.abs(delta - cadenceMultiples[index] * expectedDelta) <= cadenceTolerance), `${definition.name} output cadence contains unexpected VFR deltas: ${JSON.stringify([...new Set(deltas.map((delta) => Number(delta.toFixed(6))))])}`);
|
|
} else {
|
|
assertCondition(deltas.every((delta) => Math.abs(delta - expectedDelta) <= cadenceTolerance), `${definition.name} output cadence contains unexpected CFR deltas: ${JSON.stringify([...new Set(deltas.map((delta) => Number(delta.toFixed(6))))])}`);
|
|
}
|
|
return {
|
|
frames: timestamps.length,
|
|
start: timestamps[0],
|
|
end: timestamps.at(-1) + expectedDelta,
|
|
distinctDeltas: [...new Set(deltas.map((delta) => Number(delta.toFixed(6))))]
|
|
};
|
|
}
|
|
|
|
function probePacketTimeline(runtime, filePath, streamSelector) {
|
|
const output = runtime.ffprobe([
|
|
'-v', 'error', '-print_format', 'json', '-select_streams', streamSelector,
|
|
'-show_packets', '-show_entries', 'packet=pts_time,duration_time', filePath
|
|
]);
|
|
const packets = (JSON.parse(output).packets || []).map((packet) => ({
|
|
start: Number(packet.pts_time),
|
|
duration: Number(packet.duration_time)
|
|
})).filter((packet) => Number.isFinite(packet.start));
|
|
assertCondition(packets.length > 0, `${path.basename(filePath)} has no ${streamSelector} packets`);
|
|
return {
|
|
start: packets[0].start,
|
|
end: Math.max(...packets.map((packet) => packet.start + (Number.isFinite(packet.duration) ? packet.duration : 0))),
|
|
packets: packets.length
|
|
};
|
|
}
|
|
|
|
function decodeMedia(runtime, filePath) {
|
|
runtime.ffmpeg(['-v', 'error', '-i', filePath, '-map', '0:v:0', '-map', '0:a:0?', '-f', 'null', '-']);
|
|
}
|
|
|
|
function sha256(filePath) {
|
|
return nodeCrypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
}
|
|
|
|
function mediaDuration(probe) {
|
|
return Number(probe.format?.duration || probe.streams?.find((stream) => stream.codec_type === 'video')?.duration || 0);
|
|
}
|
|
|
|
function parseRate(value) {
|
|
if (typeof value !== 'string') return 0;
|
|
const [numerator, denominator = '1'] = value.split('/').map(Number);
|
|
return Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0 ? numerator / denominator : 0;
|
|
}
|
|
|
|
function assertCondition(condition, message) {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
function assertNoExportArtifacts(directory, outputName) {
|
|
const outputExtension = path.extname(outputName);
|
|
const outputStem = path.basename(outputName, outputExtension);
|
|
const artifacts = fs.readdirSync(directory).filter((name) =>
|
|
name.includes(`${outputStem}.`) && (name.includes('.tvm-edit') || name.includes('.tvm-backup'))
|
|
);
|
|
assertCondition(artifacts.length === 0, `Temporary export artifacts remain for ${outputName}: ${JSON.stringify(artifacts)}`);
|
|
}
|
|
|
|
function createVideoMarkerSource(rate) {
|
|
return [
|
|
`color=c=blue:size=320x180:rate=${rate}:duration=${fixtureDuration}`,
|
|
`drawbox=color=red:t=fill:enable='lt(t,${markerTimes.initialEnd})'`,
|
|
`drawbox=color=lime:t=fill:enable='between(t,${markerTimes.initialEnd},${markerTimes.startEnd})'`,
|
|
`drawbox=color=magenta:t=fill:enable='between(t,2.2,${markerTimes.endStart})'`,
|
|
`drawbox=color=cyan:t=fill:enable='gte(t,${markerTimes.endStart})'`
|
|
].join(',');
|
|
}
|
|
|
|
function createAudioMarkerSource(middleFrequency) {
|
|
const expression = [
|
|
`if(lt(t\\,${markerTimes.initialEnd})\\,sin(2*PI*${markerFrequencies.initial}*t)`,
|
|
`if(lt(t\\,${markerTimes.startEnd})\\,sin(2*PI*${markerFrequencies.start}*t)`,
|
|
`if(lt(t\\,${markerTimes.endStart})\\,sin(2*PI*${middleFrequency}*t)`,
|
|
`sin(2*PI*${markerFrequencies.end}*t))))`
|
|
].join('\\,');
|
|
return `aevalsrc=${expression}:s=${markerAudioSampleRate}:d=${fixtureDuration}`;
|
|
}
|
|
|
|
function sampleVideoRgb(runtime, filePath, time) {
|
|
const output = runtime.ffmpegBuffer([
|
|
'-v', 'error', '-i', filePath, '-ss', String(time),
|
|
'-frames:v', '1', '-vf', 'scale=1:1:flags=area,format=rgb24',
|
|
'-f', 'rawvideo', 'pipe:1'
|
|
]);
|
|
assertCondition(output.length >= 3, `${path.basename(filePath)} produced no decoded RGB marker`);
|
|
return [output[0], output[1], output[2]];
|
|
}
|
|
|
|
function estimatePcmFrequency(buffer, sampleRate = markerAudioSampleRate) {
|
|
if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer.length % 2 !== 0) return 0;
|
|
const crossings = [];
|
|
let state = 0;
|
|
for (let index = 0; index < buffer.length / 2; index += 1) {
|
|
const sample = buffer.readInt16LE(index * 2);
|
|
if (sample <= -500) state = -1;
|
|
else if (sample >= 500 && state === -1) {
|
|
crossings.push(index);
|
|
state = 1;
|
|
}
|
|
}
|
|
if (crossings.length < 2) return 0;
|
|
return sampleRate * (crossings.length - 1) / (crossings.at(-1) - crossings[0]);
|
|
}
|
|
|
|
function sampleAudioFrequency(runtime, filePath, time) {
|
|
const output = runtime.ffmpegBuffer([
|
|
'-v', 'error', '-i', filePath, '-ss', String(time), '-t', '0.1',
|
|
'-map', '0:a:0', '-ac', '1', '-ar', String(markerAudioSampleRate),
|
|
'-f', 's16le', 'pipe:1'
|
|
]);
|
|
return estimatePcmFrequency(output, markerAudioSampleRate);
|
|
}
|
|
|
|
function classifyVideoMarker(rgb) {
|
|
const [red, green, blue] = rgb;
|
|
if (green > red + 35 && green > blue + 35) return 'green';
|
|
if (green > red + 35 && blue > red + 35 && Math.abs(green - blue) <= 80) return 'cyan';
|
|
if (red > green + 35 && red > blue + 35) return 'red';
|
|
if (red > green + 35 && blue > green + 35) return 'magenta';
|
|
return 'unknown';
|
|
}
|
|
|
|
function assertTrimBoundaryMarkers(observation, label) {
|
|
const startVideoMarker = classifyVideoMarker(observation.startVideoRgb);
|
|
const endVideoMarker = classifyVideoMarker(observation.endVideoRgb);
|
|
assertCondition(startVideoMarker === 'green', `${label} start video marker is ${startVideoMarker}: ${JSON.stringify(observation.startVideoRgb)}`);
|
|
assertCondition(endVideoMarker === 'cyan', `${label} end video marker is ${endVideoMarker}: ${JSON.stringify(observation.endVideoRgb)}`);
|
|
assertCondition(Math.abs(observation.startAudioFrequency - markerFrequencies.start) <= markerFrequencies.start * 0.12, `${label} start audio marker is ${observation.startAudioFrequency}`);
|
|
assertCondition(Math.abs(observation.endAudioFrequency - markerFrequencies.end) <= markerFrequencies.end * 0.12, `${label} end audio marker is ${observation.endAudioFrequency}`);
|
|
}
|
|
|
|
function observeTrimBoundaryMarkers(runtime, filePath, startTime, endTime) {
|
|
return {
|
|
startVideoRgb: sampleVideoRgb(runtime, filePath, startTime),
|
|
endVideoRgb: sampleVideoRgb(runtime, filePath, endTime),
|
|
startAudioFrequency: sampleAudioFrequency(runtime, filePath, startTime),
|
|
endAudioFrequency: sampleAudioFrequency(runtime, filePath, endTime)
|
|
};
|
|
}
|
|
|
|
function createFixture(environment, runtime, definition) {
|
|
const filePath = path.join(environment.mediaDir, definition.fileName);
|
|
runtime.ffmpeg(definition.ffmpegArgs(filePath));
|
|
const probe = probeMedia(runtime, filePath);
|
|
const video = probe.streams.find((stream) => stream.codec_type === 'video');
|
|
const audio = probe.streams.filter((stream) => stream.codec_type === 'audio');
|
|
assertCondition(video?.codec_name === definition.sourceVideoCodec, `${definition.name} source codec is ${video?.codec_name}`);
|
|
assertCondition(audio.length === definition.audioFrequencies.length, `${definition.name} source has ${audio.length} audio streams`);
|
|
assertCondition(JSON.stringify(audio.map((stream) => stream.channels)) === JSON.stringify(definition.sourceAudioChannels), `${definition.name} source audio channels are ${JSON.stringify(audio.map((stream) => stream.channels))}`);
|
|
assertCondition(Math.abs(mediaDuration(probe) - fixtureDuration) <= 0.25, `${definition.name} source duration is ${mediaDuration(probe)}`);
|
|
const averageRate = parseRate(video.avg_frame_rate);
|
|
const realRate = parseRate(video.r_frame_rate);
|
|
assertCondition(Math.abs(averageRate - definition.sourceFps) <= definition.sourceFpsTolerance, `${definition.name} source FPS is ${averageRate}`);
|
|
assertCondition(Math.abs(realRate - definition.sourceRealFps) <= definition.sourceFpsTolerance, `${definition.name} source real FPS is ${realRate}`);
|
|
if (definition.variableFrameRate) {
|
|
assertCondition(Math.abs(averageRate - realRate) / Math.max(averageRate, realRate) > 0.005, `${definition.name} source rates do not satisfy production VFR detection: ${averageRate}/${realRate}`);
|
|
const timestamps = probeVideoTimestamps(runtime, filePath);
|
|
const deltas = timestamps.slice(1).map((timestamp, index) => Number((timestamp - timestamps[index]).toFixed(6)));
|
|
assertCondition(new Set(deltas).size >= 2, `${definition.name} fixture is not variable frame rate: ${JSON.stringify([...new Set(deltas)])}`);
|
|
}
|
|
const trimMarkers = observeTrimBoundaryMarkers(runtime, filePath, markerTimes.sourceStartSample, markerTimes.sourceEndSample);
|
|
assertTrimBoundaryMarkers(trimMarkers, `${definition.name} source`);
|
|
return { filePath, probe, hash: sha256(filePath), trimMarkers };
|
|
}
|
|
|
|
function createMatrixDefinitions() {
|
|
const createInputs = (rate, audioFrequencies) => [
|
|
'-hide_banner', '-loglevel', 'error',
|
|
'-f', 'lavfi', '-i', createVideoMarkerSource(rate),
|
|
...audioFrequencies.flatMap((frequency) => ['-f', 'lavfi', '-i', createAudioMarkerSource(frequency)])
|
|
];
|
|
return [
|
|
{
|
|
id: 'av1-multi',
|
|
name: 'MKV AV1 29.97 multi-audio',
|
|
fileName: 'matrix-av1-2997-multi.mkv',
|
|
outputName: 'matrix-av1-2997-multi-export.mp4',
|
|
profile: 'quality',
|
|
audioStreamIndex: 1,
|
|
audioFrequencies: [440, 880],
|
|
sourceAudioChannels: [1, 2],
|
|
expectedAudioChannels: 2,
|
|
sourceVideoCodec: 'av1',
|
|
sourceFps: 30000 / 1001,
|
|
sourceRealFps: 30000 / 1001,
|
|
sourceFpsTolerance: 0.01,
|
|
expectedOutputRealFps: 30000 / 1001,
|
|
outputFpsTolerance: 0.02,
|
|
variableFrameRate: false,
|
|
ffmpegArgs: (filePath) => [
|
|
...createInputs('30000/1001', [440, 880]),
|
|
'-map', '0:v:0', '-map', '1:a:0', '-map', '2:a:0',
|
|
'-t', String(fixtureDuration), '-c:v', 'libaom-av1', '-cpu-used', '8', '-crf', '42', '-b:v', '0', '-pix_fmt', 'yuv420p',
|
|
'-c:a', 'aac', '-ac:a:0', '1', '-ac:a:1', '2', '-metadata:s:a:0', 'language=eng', '-metadata:s:a:1', 'language=deu', '-shortest', '-y', filePath
|
|
]
|
|
},
|
|
{
|
|
id: 'hevc-ts',
|
|
name: 'MPEG-TS HEVC 59.94',
|
|
fileName: 'matrix-hevc-5994.ts',
|
|
outputName: 'matrix-hevc-5994-export.mp4',
|
|
profile: 'fast',
|
|
audioStreamIndex: 0,
|
|
audioFrequencies: [660],
|
|
sourceAudioChannels: [1],
|
|
expectedAudioChannels: 1,
|
|
sourceVideoCodec: 'hevc',
|
|
sourceFps: 60000 / 1001,
|
|
sourceRealFps: 60000 / 1001,
|
|
sourceFpsTolerance: 0.01,
|
|
expectedOutputRealFps: 60000 / 1001,
|
|
outputFpsTolerance: 0.02,
|
|
variableFrameRate: false,
|
|
ffmpegArgs: (filePath) => [
|
|
...createInputs('60000/1001', [660]),
|
|
'-t', String(fixtureDuration), '-c:v', 'libx265', '-preset', 'ultrafast', '-x265-params', 'log-level=error', '-crf', '35', '-pix_fmt', 'yuv420p',
|
|
'-c:a', 'aac', '-ac', '1', '-f', 'mpegts', '-shortest', '-y', filePath
|
|
]
|
|
},
|
|
{
|
|
id: 'avi',
|
|
name: 'AVI MPEG-4',
|
|
fileName: 'matrix-mpeg4.avi',
|
|
outputName: 'matrix-mpeg4-export.mkv',
|
|
profile: 'archive',
|
|
audioStreamIndex: 0,
|
|
audioFrequencies: [330],
|
|
sourceAudioChannels: [1],
|
|
expectedAudioChannels: 1,
|
|
sourceVideoCodec: 'mpeg4',
|
|
sourceFps: 25,
|
|
sourceRealFps: 25,
|
|
sourceFpsTolerance: 0.01,
|
|
expectedOutputRealFps: 25,
|
|
outputFpsTolerance: 0.01,
|
|
variableFrameRate: false,
|
|
ffmpegArgs: (filePath) => [
|
|
...createInputs('25', [330]),
|
|
'-t', String(fixtureDuration), '-c:v', 'mpeg4', '-q:v', '4', '-pix_fmt', 'yuv420p',
|
|
'-c:a', 'libmp3lame', '-ac', '1', '-shortest', '-y', filePath
|
|
]
|
|
},
|
|
{
|
|
id: 'vfr',
|
|
name: 'MP4 H.264 VFR',
|
|
fileName: 'matrix-vfr.mp4',
|
|
outputName: 'matrix-vfr-export.mp4',
|
|
profile: 'balanced',
|
|
audioStreamIndex: 0,
|
|
audioFrequencies: [550],
|
|
sourceAudioChannels: [1],
|
|
expectedAudioChannels: 1,
|
|
sourceVideoCodec: 'h264',
|
|
sourceFps: 50,
|
|
sourceRealFps: 60,
|
|
sourceFpsTolerance: 1,
|
|
expectedOutputRealFps: 60,
|
|
outputFpsTolerance: 3,
|
|
variableFrameRate: true,
|
|
ffmpegArgs: (filePath) => [
|
|
...createInputs('60', [550]),
|
|
'-vf', "select='if(lt(t,1),not(mod(n,2)),1)'", '-fps_mode', 'vfr',
|
|
'-t', String(fixtureDuration), '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '32', '-pix_fmt', 'yuv420p',
|
|
'-c:a', 'aac', '-ac', '1', '-shortest', '-y', filePath
|
|
]
|
|
}
|
|
];
|
|
}
|
|
|
|
async function createCutterCapability(win, filePath) {
|
|
const inputId = `cutter-matrix-${nodeCrypto.randomUUID()}`;
|
|
await win.evaluate((id) => {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.id = id;
|
|
document.body.appendChild(input);
|
|
}, inputId);
|
|
await win.locator(`#${inputId}`).setInputFiles(filePath);
|
|
const capability = await win.evaluate(async (id) => {
|
|
const input = document.getElementById(id);
|
|
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
|
const selection = file ? await window.api.selectDroppedVideo(file) : null;
|
|
input?.remove();
|
|
return selection;
|
|
}, inputId);
|
|
return requireFileCapability(capability);
|
|
}
|
|
|
|
async function prepareSource(win, filePath, options = {}) {
|
|
const timeoutMs = options.timeoutMs ?? cutterMatrixTimeouts.prepareSource;
|
|
const createCapability = options.createCapability || createCutterCapability;
|
|
return await withOperationTimeout(async () => {
|
|
const capability = await createCapability(win, filePath);
|
|
const media = await win.evaluate((selection) => window.api.prepareVideoEditorMedia(selection.token), capability);
|
|
return { capability, media };
|
|
}, 'prepareSource', timeoutMs);
|
|
}
|
|
|
|
async function exportSource(win, definition, source, timeoutMs = cutterMatrixTimeouts.exportSource) {
|
|
return await withOperationTimeout(() => win.evaluate(({ inputCapability, outputName, trimStart, trimEnd, profile, audioStreamIndex }) => window.api.exportVideoEdit({
|
|
inputCapability,
|
|
outputName,
|
|
trimStart,
|
|
trimEnd,
|
|
cuts: [],
|
|
profile,
|
|
encoder: 'software',
|
|
audioStreamIndex
|
|
}), {
|
|
inputCapability: source.capability.token,
|
|
outputName: definition.outputName,
|
|
trimStart: exportTrimStart,
|
|
trimEnd: exportTrimEnd,
|
|
profile: definition.profile,
|
|
audioStreamIndex: definition.audioStreamIndex
|
|
}), 'exportSource', timeoutMs);
|
|
}
|
|
|
|
function analyzeExport(environment, runtime, definition, source) {
|
|
const outputFile = path.join(environment.mediaDir, definition.outputName);
|
|
assertCondition(fs.existsSync(outputFile), `${definition.name} output was not created`);
|
|
const probe = probeMedia(runtime, outputFile);
|
|
const videoStreams = probe.streams.filter((stream) => stream.codec_type === 'video');
|
|
const audioStreams = probe.streams.filter((stream) => stream.codec_type === 'audio');
|
|
assertCondition(videoStreams.length === 1, `${definition.name} output has ${videoStreams.length} video streams`);
|
|
assertCondition(audioStreams.length === 1, `${definition.name} output has ${audioStreams.length} audio streams`);
|
|
assertCondition(videoStreams[0].codec_name === (definition.profile === 'archive' ? 'ffv1' : 'h264'), `${definition.name} output video codec is ${videoStreams[0].codec_name}`);
|
|
assertCondition(audioStreams[0].codec_name === (definition.profile === 'archive' ? 'flac' : 'aac'), `${definition.name} output audio codec is ${audioStreams[0].codec_name}`);
|
|
assertCondition(audioStreams[0].channels === definition.expectedAudioChannels, `${definition.name} selected audio stream has ${audioStreams[0].channels} channels instead of ${definition.expectedAudioChannels}`);
|
|
const duration = mediaDuration(probe);
|
|
assertCondition(Math.abs(duration - expectedExportDuration) <= 0.16, `${definition.name} output duration is ${duration}`);
|
|
const realRate = parseRate(videoStreams[0].r_frame_rate);
|
|
assertCondition(Math.abs(realRate - definition.expectedOutputRealFps) <= definition.outputFpsTolerance, `${definition.name} output real FPS is ${realRate}`);
|
|
const videoCadence = assertVideoCadence(runtime, definition, outputFile);
|
|
const audioTimeline = probePacketTimeline(runtime, outputFile, 'a:0');
|
|
assertCondition(Math.abs(videoCadence.start - audioTimeline.start) <= 0.08, `${definition.name} A/V start differs by ${Math.abs(videoCadence.start - audioTimeline.start)}`);
|
|
assertCondition(Math.abs(videoCadence.end - audioTimeline.end) <= 0.12, `${definition.name} A/V end differs by ${Math.abs(videoCadence.end - audioTimeline.end)}`);
|
|
assertCondition(videoStreams[0].width === 320 && videoStreams[0].height === 180, `${definition.name} output dimensions are ${videoStreams[0].width}x${videoStreams[0].height}`);
|
|
decodeMedia(runtime, outputFile);
|
|
const trimMarkers = observeTrimBoundaryMarkers(runtime, outputFile, markerTimes.outputStartSample, markerTimes.outputEndSample);
|
|
assertTrimBoundaryMarkers(trimMarkers, `${definition.name} output`);
|
|
assertCondition(sha256(source.filePath) === source.hash, `${definition.name} source changed during export`);
|
|
assertNoExportArtifacts(environment.mediaDir, definition.outputName);
|
|
return {
|
|
outputName: definition.outputName,
|
|
duration,
|
|
videoCodec: videoStreams[0].codec_name,
|
|
audioCodec: audioStreams[0].codec_name,
|
|
videoRate: videoStreams[0].avg_frame_rate,
|
|
realVideoRate: videoStreams[0].r_frame_rate,
|
|
audioChannels: audioStreams[0].channels,
|
|
videoCadence,
|
|
audioTimeline,
|
|
trimMarkers
|
|
};
|
|
}
|
|
|
|
function canonicalComparablePath(candidatePath) {
|
|
try {
|
|
return resolveCanonicalPath(candidatePath);
|
|
} catch {
|
|
return path.resolve(candidatePath);
|
|
}
|
|
}
|
|
|
|
function sameResolvedPath(left, right) {
|
|
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
|
const resolvedLeft = canonicalComparablePath(left);
|
|
const resolvedRight = canonicalComparablePath(right);
|
|
return process.platform === 'win32'
|
|
? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
|
|
: resolvedLeft === resolvedRight;
|
|
}
|
|
|
|
function assertManagedExecutionDiagnostics(options) {
|
|
const {
|
|
diagnostics,
|
|
expectedPaths,
|
|
streamlinkDirectory,
|
|
ffmpegDirectory,
|
|
electronPath,
|
|
expectedElectronPath,
|
|
previousDiagnostics = null,
|
|
requiredTools = [],
|
|
label
|
|
} = options;
|
|
assertCondition(diagnostics !== null && typeof diagnostics === 'object', `${label} managed execution diagnostics are unavailable`);
|
|
const tools = [
|
|
{ key: 'ffmpeg', name: 'FFmpeg', expectedPath: expectedPaths.ffmpeg, directory: ffmpegDirectory },
|
|
{ key: 'ffprobe', name: 'FFprobe', expectedPath: expectedPaths.ffprobe, directory: ffmpegDirectory },
|
|
{ key: 'streamlink', name: 'Streamlink', expectedPath: expectedPaths.streamlink, directory: streamlinkDirectory }
|
|
];
|
|
for (const tool of tools) {
|
|
const record = diagnostics[tool.key];
|
|
assertCondition(record !== null && typeof record === 'object' && Number.isInteger(record.count) && record.count >= 0, `${label} ${tool.name} execution record is invalid: ${JSON.stringify(record)}`);
|
|
if (record.count === 0) {
|
|
assertCondition(record.path === null, `${label} ${tool.name} reported a path without an execution: ${record.path}`);
|
|
} else {
|
|
assertCondition(sameResolvedPath(record.path, tool.expectedPath), `${label} did not execute the provisioned ${tool.name} path: ${record.path}`);
|
|
assertPathInside(record.path, tool.directory, `${label} ${tool.name} path`);
|
|
}
|
|
if (previousDiagnostics) {
|
|
const previousRecord = previousDiagnostics[tool.key];
|
|
assertCondition(previousRecord !== null && typeof previousRecord === 'object' && Number.isInteger(previousRecord.count) && previousRecord.count >= 0, `${label} previous ${tool.name} execution record is invalid: ${JSON.stringify(previousRecord)}`);
|
|
assertCondition(record.count >= previousRecord.count, `${label} ${tool.name} execution count regressed from ${previousRecord.count} to ${record.count}`);
|
|
if (requiredTools.includes(tool.key)) assertCondition(record.count > previousRecord.count, `${label} did not record a new ${tool.name} execution`);
|
|
}
|
|
}
|
|
assertCondition(sameResolvedPath(electronPath, expectedElectronPath), `${label} Electron PATH escaped isolation: ${electronPath}`);
|
|
}
|
|
|
|
async function readManagedExecutionDiagnostics(win, timeoutMs = cutterMatrixTimeouts.diagnostics) {
|
|
return await withOperationTimeout(
|
|
() => win.evaluate(() => window.api.getManagedToolExecutionDiagnostics()),
|
|
'managed tool execution diagnostics',
|
|
timeoutMs
|
|
);
|
|
}
|
|
|
|
async function readDebugLog(win, timeoutMs = cutterMatrixTimeouts.diagnostics) {
|
|
return await withOperationTimeout(
|
|
() => win.evaluate(() => window.api.getDebugLog(1000)),
|
|
'debug log read',
|
|
timeoutMs
|
|
);
|
|
}
|
|
|
|
function assertLockedTargetFailure({ result, debugBefore, debugAfter, outputFile, runtimeIssues }) {
|
|
assertCondition(!result?.rejected, `Locked target export must resolve through the product IPC: ${result?.rejected}`);
|
|
assertCondition(result?.success === false, `Locked target export unexpectedly succeeded: ${JSON.stringify(result)}`);
|
|
assertCondition(result.cancelled !== true, `Locked target export must not be reported as cancelled: ${JSON.stringify(result)}`);
|
|
assertCondition(runtimeIssues.length === 0, `Locked target export produced runtime issues: ${runtimeIssues.join(' | ')}`);
|
|
const delta = debugAfter.startsWith(debugBefore) ? debugAfter.slice(debugBefore.length) : debugAfter;
|
|
const hasMatchingFailure = delta.split(/\r?\n/).some((line) => {
|
|
if (!/video-editor-export-failed/.test(line)
|
|
|| !/(?:EPERM|EBUSY|EACCES|operation not permitted|being used by another process|cannot access)/i.test(line)) return false;
|
|
const paths = extractRenamePaths(line);
|
|
if (!paths) return false;
|
|
const outputDirectory = path.dirname(outputFile);
|
|
const stagingPublish = sameResolvedPath(paths.destination, outputFile)
|
|
&& sameResolvedPath(path.dirname(paths.source), outputDirectory)
|
|
&& path.basename(paths.source).toLowerCase().includes('.tvm-edit');
|
|
const backupPublish = sameResolvedPath(paths.source, outputFile)
|
|
&& sameResolvedPath(path.dirname(paths.destination), outputDirectory)
|
|
&& path.basename(paths.destination).startsWith(`${path.basename(outputFile)}.`)
|
|
&& path.basename(paths.destination).toLowerCase().endsWith('.tvm-backup');
|
|
return stagingPublish || backupPublish;
|
|
});
|
|
assertCondition(hasMatchingFailure, `Locked target export did not emit an atomic publish lock diagnostic: ${delta}`);
|
|
}
|
|
|
|
function extractRenamePaths(line) {
|
|
const renameIndex = line.toLowerCase().lastIndexOf('rename ');
|
|
if (renameIndex < 0) return null;
|
|
const remainder = line.slice(renameIndex + 7).trim();
|
|
const sourceQuote = remainder[0];
|
|
if (sourceQuote !== "'" && sourceQuote !== '"') return null;
|
|
const sourceEnd = remainder.indexOf(sourceQuote, 1);
|
|
if (sourceEnd < 1) return null;
|
|
const afterSource = remainder.slice(sourceEnd + 1).trim();
|
|
if (!afterSource.startsWith('->')) return null;
|
|
const destinationPart = afterSource.slice(2).trim();
|
|
const destinationQuote = destinationPart[0];
|
|
if (destinationQuote !== "'" && destinationQuote !== '"') return null;
|
|
const destinationEnd = destinationPart.indexOf(destinationQuote, 1);
|
|
if (destinationEnd < 1 || destinationPart.slice(destinationEnd + 1).trim()) return null;
|
|
return {
|
|
source: remainder.slice(1, sourceEnd),
|
|
destination: destinationPart.slice(1, destinationEnd)
|
|
};
|
|
}
|
|
|
|
async function stopLockProcess(lockProcess, timeoutMs = 5000) {
|
|
const processExit = new Promise((resolve) => lockProcess.once('exit', resolve));
|
|
lockProcess.kill();
|
|
await withOperationTimeout(() => processExit, 'locked target helper exit', timeoutMs);
|
|
}
|
|
|
|
async function runLockedTargetCase(win, environment, powerShellExecutable, definition, source, runtimeIssues) {
|
|
const outputFile = path.join(environment.mediaDir, definition.outputName);
|
|
const sentinel = Buffer.from(`locked-target-${nodeCrypto.randomUUID()}`);
|
|
fs.writeFileSync(outputFile, sentinel);
|
|
let lockProcess = null;
|
|
const originalDirectoryMode = fs.statSync(environment.mediaDir).mode & 0o777;
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
const lockScript = "$stream=[IO.File]::Open($env:TWITCH_VOD_MANAGER_LOCK_TARGET,[IO.FileMode]::Open,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None);[Console]::Out.WriteLine('ready');[Console]::Out.Flush();while($true){Start-Sleep -Milliseconds 250}";
|
|
lockProcess = spawn(powerShellExecutable, ['-NoProfile', '-Command', lockScript], {
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, TWITCH_VOD_MANAGER_LOCK_TARGET: outputFile }
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => reject(new Error('Locked target helper did not start')), 5000);
|
|
lockProcess.stdout.once('data', () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
lockProcess.once('exit', (code) => reject(new Error(`Locked target helper exited with ${code}`)));
|
|
});
|
|
} else {
|
|
fs.chmodSync(environment.mediaDir, 0o500);
|
|
}
|
|
const debugBefore = await readDebugLog(win);
|
|
const result = await exportSource(win, definition, source);
|
|
const debugAfter = await readDebugLog(win);
|
|
assertLockedTargetFailure({ result, debugBefore, debugAfter, outputFile, runtimeIssues });
|
|
if (lockProcess) {
|
|
await stopLockProcess(lockProcess);
|
|
lockProcess = null;
|
|
}
|
|
assertCondition(fs.readFileSync(outputFile).equals(sentinel), 'Locked target contents changed after failed export');
|
|
assertNoExportArtifacts(environment.mediaDir, definition.outputName);
|
|
return result;
|
|
} finally {
|
|
if (lockProcess) await stopLockProcess(lockProcess).catch(() => {});
|
|
if (process.platform !== 'win32' && fs.existsSync(environment.mediaDir)) fs.chmodSync(environment.mediaDir, originalDirectoryMode);
|
|
}
|
|
}
|
|
|
|
async function runCorruptSourceCase(win, environment) {
|
|
const corruptFile = path.join(environment.mediaDir, 'matrix-corrupt.mkv');
|
|
fs.writeFileSync(corruptFile, nodeCrypto.randomBytes(2048));
|
|
const corruptHash = sha256(corruptFile);
|
|
const outputName = 'matrix-corrupt-export.mp4';
|
|
const target = path.join(environment.mediaDir, outputName);
|
|
const sentinel = Buffer.from(`corrupt-target-${nodeCrypto.randomUUID()}`);
|
|
fs.writeFileSync(target, sentinel);
|
|
const prepared = await prepareSource(win, corruptFile);
|
|
assertCondition(prepared.media === null, `Corrupt source unexpectedly prepared: ${JSON.stringify(prepared.media)}`);
|
|
const result = await exportSource(win, { outputName, profile: 'balanced', audioStreamIndex: 0 }, prepared);
|
|
assertCondition(!result.success, `Corrupt source export unexpectedly succeeded: ${JSON.stringify(result)}`);
|
|
assertCondition(sha256(corruptFile) === corruptHash, 'Corrupt source changed during failed export');
|
|
assertCondition(fs.readFileSync(target).equals(sentinel), 'Existing corrupt-source target changed after failed export');
|
|
assertNoExportArtifacts(environment.mediaDir, outputName);
|
|
return result;
|
|
}
|
|
|
|
async function terminateElectronProcess(app, timeoutMs) {
|
|
const child = app.process?.();
|
|
if (!child || child.exitCode !== null) return;
|
|
await new Promise((resolve, reject) => {
|
|
let timeout;
|
|
const cleanup = () => {
|
|
if (timeout) clearTimeout(timeout);
|
|
child.removeListener('exit', onExit);
|
|
child.removeListener('error', onError);
|
|
};
|
|
const settle = (error) => {
|
|
cleanup();
|
|
if (error) reject(error);
|
|
else resolve();
|
|
};
|
|
const onExit = () => settle();
|
|
const onError = (error) => settle(error);
|
|
child.once('exit', onExit);
|
|
child.once('error', onError);
|
|
timeout = setTimeout(() => settle(new Error(`Electron process exit timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
try {
|
|
child.kill();
|
|
if (child.exitCode !== null) settle();
|
|
} catch (error) {
|
|
settle(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function closeElectronApp(app, closeTimeoutMs = cutterMatrixTimeouts.appClose, processExitTimeoutMs = cutterMatrixTimeouts.appProcessExit) {
|
|
try {
|
|
return await withOperationTimeout(() => app.close(), 'app.close', closeTimeoutMs);
|
|
} catch (error) {
|
|
await terminateElectronProcess(app, processExitTimeoutMs);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function runCutterMatrixLifecycle(options) {
|
|
const environment = options.createEnvironment();
|
|
let app = null;
|
|
let restoreEnvironment = () => {};
|
|
const closeApp = async () => {
|
|
const activeApp = app;
|
|
app = null;
|
|
if (activeApp) await options.closeApp(activeApp);
|
|
};
|
|
try {
|
|
return await options.execute({
|
|
environment,
|
|
closeApp,
|
|
setApp: (value) => { app = value; },
|
|
setRestoreEnvironment: (value) => { restoreEnvironment = value; }
|
|
});
|
|
} finally {
|
|
try {
|
|
await closeApp();
|
|
} finally {
|
|
try {
|
|
restoreEnvironment();
|
|
} finally {
|
|
options.cleanupEnvironment(environment);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function executeCutterMatrix({ environment, closeApp, setApp, setRestoreEnvironment }) {
|
|
assertCondition(process.platform === 'win32', 'Cutter media matrix requires the Windows product toolchain');
|
|
let app;
|
|
const provisionedTools = await provisionManagedCutterTools(environment);
|
|
const runtime = createManagedMediaRuntime(provisionedTools.paths);
|
|
const powerShellExecutable = resolvePowerShellExecutable();
|
|
setRestoreEnvironment(activateOfflineRunnerEnvironment(environment));
|
|
const definitions = createMatrixDefinitions();
|
|
const caseArgument = process.argv.find((argument) => argument.startsWith('--case='));
|
|
const requestedCases = caseArgument ? new Set(caseArgument.slice('--case='.length).split(',').filter(Boolean)) : null;
|
|
const knownCases = new Set([...definitions.map((definition) => definition.id), 'errors']);
|
|
if (requestedCases) {
|
|
const unknownCases = [...requestedCases].filter((id) => !knownCases.has(id));
|
|
assertCondition(unknownCases.length === 0, `Unknown matrix cases: ${unknownCases.join(', ')}`);
|
|
}
|
|
const selectedDefinitions = requestedCases ? definitions.filter((definition) => requestedCases.has(definition.id)) : definitions;
|
|
const includeErrors = !requestedCases || requestedCases.has('errors');
|
|
const fixtureDefinitions = [...selectedDefinitions];
|
|
if (includeErrors && !fixtureDefinitions.some((definition) => definition.id === definitions[0].id)) fixtureDefinitions.push(definitions[0]);
|
|
assertCondition(selectedDefinitions.length > 0 || includeErrors, 'No cutter matrix cases selected');
|
|
const fixtures = fixtureDefinitions.map((definition) => ({ definition, source: createFixture(environment, runtime, definition) }));
|
|
if (process.argv.includes('--fixtures-only')) {
|
|
console.log(JSON.stringify({
|
|
fixtures: fixtures.map((fixture) => ({
|
|
case: fixture.definition.name,
|
|
fileName: path.basename(fixture.source.filePath),
|
|
duration: mediaDuration(fixture.source.probe),
|
|
videoRate: fixture.source.probe.streams.find((stream) => stream.codec_type === 'video')?.avg_frame_rate,
|
|
realVideoRate: fixture.source.probe.streams.find((stream) => stream.codec_type === 'video')?.r_frame_rate,
|
|
audioChannels: fixture.source.probe.streams.filter((stream) => stream.codec_type === 'audio').map((stream) => stream.channels)
|
|
}))
|
|
}, null, 2));
|
|
return;
|
|
}
|
|
const launchOptions = getElectronLaunchOptions(environment);
|
|
launchOptions.env = {
|
|
...launchOptions.env,
|
|
TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT: environment.mediaDir
|
|
};
|
|
app = await electron.launch(launchOptions);
|
|
setApp(app);
|
|
const appProcessId = app.process().pid;
|
|
const win = await app.firstWindow();
|
|
const runtimeIssues = [];
|
|
win.on('pageerror', (error) => runtimeIssues.push(`pageerror: ${String(error)}`));
|
|
win.on('console', (message) => {
|
|
if (message.type() === 'error') runtimeIssues.push(`console.error: ${message.text()}`);
|
|
});
|
|
await verifyE2eIsolation(app, win, environment);
|
|
const productToolStatuses = await win.evaluate(() => window.api.getManagedToolStatus());
|
|
assertVerifiedManagedStatus(productToolStatuses.streamlink, provisionedTools.manifest.streamlink.version, 'Electron Streamlink');
|
|
assertVerifiedManagedStatus(productToolStatuses.ffmpeg, provisionedTools.manifest.ffmpeg.version, 'Electron FFmpeg');
|
|
const expectedElectronPath = path.join(environment.rootDir, 'offline-path');
|
|
const electronPath = getEnvironmentValue(launchOptions.env, 'PATH');
|
|
const streamlinkDirectory = path.join(environment.appDataDir, 'tools', 'streamlink');
|
|
const ffmpegDirectory = path.join(environment.appDataDir, 'tools', 'ffmpeg');
|
|
const verifyManagedExecution = async (label, previousDiagnostics = null, requiredTools = []) => {
|
|
const diagnostics = await readManagedExecutionDiagnostics(win);
|
|
assertManagedExecutionDiagnostics({
|
|
diagnostics,
|
|
expectedPaths: provisionedTools.paths,
|
|
streamlinkDirectory,
|
|
ffmpegDirectory,
|
|
electronPath,
|
|
expectedElectronPath,
|
|
previousDiagnostics,
|
|
requiredTools,
|
|
label
|
|
});
|
|
return diagnostics;
|
|
};
|
|
let executionDiagnostics = await verifyManagedExecution('Cutter startup');
|
|
const matrix = [];
|
|
for (const entry of fixtures.filter((fixture) => selectedDefinitions.includes(fixture.definition))) {
|
|
const prepared = await prepareSource(win, entry.source.filePath);
|
|
assertCondition(prepared.media !== null, `${entry.definition.name} source was rejected during production media preparation`);
|
|
assertCondition(prepared.media.info.videoCodec === entry.definition.sourceVideoCodec, `${entry.definition.name} production probe reported ${prepared.media.info.videoCodec}`);
|
|
assertCondition(prepared.media.info.audioStreams.length === entry.definition.audioFrequencies.length, `${entry.definition.name} production probe found ${prepared.media.info.audioStreams.length} audio streams`);
|
|
if (entry.definition.variableFrameRate) assertCondition(prepared.media.info.variableFrameRate, `${entry.definition.name} production probe did not report VFR`);
|
|
const prepareDiagnostics = await verifyManagedExecution(`${entry.definition.name} after prepare`, executionDiagnostics, ['ffprobe']);
|
|
executionDiagnostics = prepareDiagnostics;
|
|
const result = await exportSource(win, entry.definition, prepared);
|
|
assertCondition(result.success, `${entry.definition.name} production export failed: ${JSON.stringify(result)}`);
|
|
const exportDiagnostics = await verifyManagedExecution(`${entry.definition.name} after export`, executionDiagnostics, ['ffmpeg', 'ffprobe']);
|
|
executionDiagnostics = exportDiagnostics;
|
|
matrix.push({
|
|
case: entry.definition.name,
|
|
prepared: {
|
|
duration: prepared.media.info.duration,
|
|
fps: prepared.media.info.fps,
|
|
variableFrameRate: prepared.media.info.variableFrameRate,
|
|
audioStreams: prepared.media.info.audioStreams
|
|
},
|
|
result,
|
|
output: analyzeExport(environment, runtime, entry.definition, entry.source),
|
|
executionDiagnostics: { prepare: prepareDiagnostics, export: exportDiagnostics }
|
|
});
|
|
}
|
|
let lockedTarget = null;
|
|
let corruptSource = null;
|
|
if (includeErrors) {
|
|
const lockedDefinition = { ...definitions[0], outputName: 'matrix-locked-existing.mp4' };
|
|
const lockedSource = fixtures.find((fixture) => fixture.definition.id === definitions[0].id).source;
|
|
const lockedPrepared = await prepareSource(win, lockedSource.filePath);
|
|
assertCondition(lockedPrepared.media !== null, 'Locked target source could not be prepared');
|
|
executionDiagnostics = await verifyManagedExecution('Locked target after prepare', executionDiagnostics, ['ffprobe']);
|
|
lockedTarget = await runLockedTargetCase(win, environment, powerShellExecutable, lockedDefinition, { ...lockedSource, capability: lockedPrepared.capability }, runtimeIssues);
|
|
executionDiagnostics = await verifyManagedExecution('Locked target after export', executionDiagnostics, ['ffmpeg', 'ffprobe']);
|
|
corruptSource = await runCorruptSourceCase(win, environment);
|
|
await verifyManagedExecution('Corrupt source after export', executionDiagnostics, ['ffprobe']);
|
|
}
|
|
assertCondition(runtimeIssues.length === 0, `Cutter matrix runtime issues occurred: ${runtimeIssues.join(' | ')}`);
|
|
await closeApp();
|
|
const cutterTempPrefixes = ['media', 'waveform', 'preview'].map((kind) => `tvm-editor-${kind}-${appProcessId}-`);
|
|
const cutterTempDirectoriesAfterShutdown = fs.readdirSync(os.tmpdir()).filter((name) => cutterTempPrefixes.some((prefix) => name.startsWith(prefix)));
|
|
assertCondition(cutterTempDirectoriesAfterShutdown.length === 0, `Cutter matrix left temporary directories after shutdown: ${JSON.stringify(cutterTempDirectoriesAfterShutdown)}`);
|
|
console.log(JSON.stringify({
|
|
toolchain: {
|
|
statuses: provisionedTools.statuses,
|
|
versions: provisionedTools.versions,
|
|
paths: provisionedTools.paths
|
|
},
|
|
matrix,
|
|
lockedTarget,
|
|
corruptSource,
|
|
runtimeIssues,
|
|
cutterTempDirectoriesAfterShutdown
|
|
}, null, 2));
|
|
}
|
|
|
|
async function run() {
|
|
return await runCutterMatrixLifecycle({
|
|
createEnvironment: () => createE2eEnvironment('cutter-media-matrix', { language: 'en', theme: 'twitch' }),
|
|
cleanupEnvironment: cleanupE2eEnvironment,
|
|
closeApp: closeElectronApp,
|
|
execute: executeCutterMatrix
|
|
});
|
|
}
|
|
|
|
if (require.main === module) {
|
|
run().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
activateOfflineRunnerEnvironment,
|
|
assertLockedTargetFailure,
|
|
assertManagedExecutionDiagnostics,
|
|
assertPinnedVersion,
|
|
assertTrimBoundaryMarkers,
|
|
closeElectronApp,
|
|
createManagedMediaRuntime,
|
|
estimatePcmFrequency,
|
|
exportSource,
|
|
prepareSource,
|
|
provisionManagedCutterTools,
|
|
runCutterMatrixLifecycle,
|
|
sampleVideoRgb
|
|
};
|