Files
av-tools/tests/integration/command-runtime-native.test.ts

1274 lines
35 KiB
TypeScript

import { spawnSync } from 'node:child_process';
import {
copyFileSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, extname, join, resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
buildAudioExtractionPlan,
buildChapterMuxPlan,
buildContactSheetPlan,
buildConvertPlan,
buildFadePlan,
buildFastConcatPlan,
buildLoudnessAnalysisPlan,
buildLoudnessApplyPlan,
buildMetadataEditPlan,
buildNormalizedConcatPlan,
buildPeakAnalysisPlan,
buildPeakNormalizationFilter,
buildPeakNormalizationPlan,
buildPreviewProxyPlan,
buildRemuxPlan,
buildSingleThumbnailPlan,
buildSplitPlan,
buildStaticWaveformPlan,
buildStreamRemovalPlan,
buildSubtitleBurnPlan,
buildSubtitleConversionPlan,
buildSubtitleExtractionPlan,
buildSubtitleMuxPlan,
buildThumbnailSeriesPlan,
buildTimelineExportPlan,
buildTransformPlan,
buildTrimPlan,
buildWaveformAnalysisPlan,
createConcatListEntries,
parseLoudnessMeasurement,
parsePeakMeasurement,
resolveThumbnailTimes,
streamMapArguments,
type ConcatSource,
type FFmpegCommandPlan,
type SourceReference,
} from '../../src/commands';
import { findBuiltInPreset } from '../../src/presets';
const FIXTURES = resolve(process.cwd(), 'tests/fixtures/generated');
const NATIVE_FFMPEG_AVAILABLE =
spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0 &&
spawnSync('ffprobe', ['-version'], { stdio: 'ignore' }).status === 0;
const NATIVE_SUBTITLES_FILTER_AVAILABLE =
NATIVE_FFMPEG_AVAILABLE &&
/\bsubtitles\b/u.test(
spawnSync('ffmpeg', ['-hide_banner', '-filters'], {
encoding: 'utf8',
}).stdout ?? ''
);
const describeNative = NATIVE_FFMPEG_AVAILABLE ? describe : describe.skip;
const itNativeSubtitles = NATIVE_SUBTITLES_FILTER_AVAILABLE ? it : it.skip;
interface NativeExecution {
readonly outputPaths: readonly string[];
readonly stdout: string;
readonly stderr: string;
}
let suiteDirectory = '';
let executionSequence = 0;
beforeAll(() => {
suiteDirectory = mkdtempSync(join(tmpdir(), 'av-tools-command-runtime-'));
});
afterAll(() => {
if (suiteDirectory) {
rmSync(suiteDirectory, { recursive: true, force: true });
}
});
function fixture(name: string): string {
const path = join(FIXTURES, name);
if (!existsSync(path)) {
throw new Error(`Missing generated fixture: ${name}`);
}
return path;
}
function source(
id: string,
sourceIndex: number,
fileName: string
): SourceReference {
return { id, sourceIndex, fileName };
}
function runNative(
plan: FFmpegCommandPlan,
sourceFiles: Readonly<Record<string, string>>,
logLevel: 'warning' | 'info' = 'warning'
): NativeExecution {
executionSequence += 1;
const directory = join(
suiteDirectory,
`${String(executionSequence).padStart(3, '0')}-${plan.operation}`
);
mkdirSync(directory, { recursive: true });
const replacements = new Map<string, string>();
plan.inputs.forEach((input, index) => {
const sourcePath = sourceFiles[input.id];
if (!sourcePath) {
throw new Error(`No fixture was supplied for planned input ${input.id}`);
}
const extension = extname(sourcePath) || '.bin';
const inputPath = join(
directory,
`input-${String(index).padStart(3, '0')}${extension}`
);
copyFileSync(sourcePath, inputPath);
replacements.set(input.path, inputPath);
});
const outputPaths = plan.outputs.map((output, index) => {
const outputPath = join(
directory,
`${String(index).padStart(3, '0')}-${output.fileName}`
);
replacements.set(output.path, outputPath);
return outputPath;
});
const temporaryPaths = plan.temporaryFiles.map((temporary, index) => {
const extension = extname(temporary.path) || '.tmp';
const temporaryPath = join(
directory,
`temporary-${String(index).padStart(3, '0')}${extension}`
);
replacements.set(temporary.path, temporaryPath);
return temporaryPath;
});
const substitute = (value: string): string => {
let result = value;
for (const [virtualPath, nativePath] of [...replacements.entries()].sort(
([left], [right]) => right.length - left.length
)) {
result = result.replaceAll(virtualPath, nativePath);
}
return result;
};
plan.temporaryFiles.forEach((temporary, index) => {
const path = temporaryPaths[index];
if (!path) {
throw new Error(`Missing native temporary path ${index}`);
}
if (typeof temporary.content === 'string') {
writeFileSync(path, substitute(temporary.content), 'utf8');
} else {
writeFileSync(path, temporary.content);
}
});
const execution = spawnSync(
'ffmpeg',
[
'-hide_banner',
'-nostdin',
'-y',
'-loglevel',
logLevel,
...plan.args.map(substitute),
],
{
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024,
timeout: 60_000,
}
);
const stdout = execution.stdout ?? '';
const stderr = execution.stderr ?? '';
if (execution.status !== 0) {
throw new Error(
[
`Native FFmpeg rejected ${plan.operation} (status ${String(execution.status)}, signal ${String(execution.signal)}).`,
`argv: ${plan.args.map(substitute).join(' ')}`,
stderr,
stdout,
].join('\n')
);
}
for (const path of outputPaths) {
expect(statSync(path).size, basename(path)).toBeGreaterThan(0);
}
return { outputPaths, stdout, stderr };
}
function nativeCommand(args: readonly string[], outputPath: string): void {
const execution = spawnSync(
'ffmpeg',
['-hide_banner', '-nostdin', '-y', '-loglevel', 'error', ...args],
{
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
timeout: 60_000,
}
);
if (execution.status !== 0) {
throw new Error(execution.stderr || `Could not create ${outputPath}`);
}
expect(statSync(outputPath).size).toBeGreaterThan(0);
}
function probe(path: string): {
readonly streams?: readonly {
readonly index?: number;
readonly codec_name?: string;
readonly codec_type?: string;
readonly width?: number;
readonly height?: number;
readonly tags?: Readonly<Record<string, string>>;
readonly disposition?: Readonly<Record<string, number>>;
}[];
readonly chapters?: readonly {
readonly start_time?: string;
readonly end_time?: string;
readonly tags?: Readonly<Record<string, string>>;
}[];
readonly format?: {
readonly duration?: string;
readonly tags?: Readonly<Record<string, string>>;
};
} {
const result = spawnSync(
'ffprobe',
[
'-v',
'error',
'-show_streams',
'-show_chapters',
'-show_format',
'-of',
'json',
path,
],
{ encoding: 'utf8', timeout: 20_000 }
);
if (result.status !== 0) {
throw new Error(result.stderr || `Could not probe ${path}`);
}
return JSON.parse(result.stdout) as ReturnType<typeof probe>;
}
function preset(id: string) {
const result = findBuiltInPreset(id);
if (!result) {
throw new Error(`Missing built-in preset ${id}`);
}
return result;
}
function concatSource(
id: string,
sourceIndex: number,
fileName: string,
width: number,
height: number,
hasAudio = true
): ConcatSource {
return {
id,
sourceIndex,
fileName,
durationSeconds: 1,
streams: [
{
kind: 'video',
codec: 'h264',
width,
height,
pixelFormat: 'yuv420p',
timeBase: '1/10240',
},
...(hasAudio
? [
{
kind: 'audio' as const,
codec: 'aac',
sampleRate: 16_000,
channelLayout: 'mono',
timeBase: '1/16000',
},
]
: []),
],
};
}
describe('command-plan runtime hardening', () => {
it('serializes validated ffconcat in/out directives without source names', () => {
expect(
createConcatListEntries([
{
path: '/input/job-safe/source-0.mp4',
inPointSeconds: 0.25,
outPointSeconds: 0.75,
},
])
).toBe(
"file '/input/job-safe/source-0.mp4'\ninpoint 0.25\noutpoint 0.75\n"
);
expect(() =>
createConcatListEntries([
{
path: '/input/job-safe/source-0.mp4',
inPointSeconds: 1,
outPointSeconds: 0.5,
},
])
).toThrow(/outpoint/iu);
});
it('bounds and uniquely names split outputs and gives fast segments independent inputs', () => {
const media = source('media', 0, 'pattern-av.mp4');
const plan = buildSplitPlan({
jobId: 'split-hardening',
source: media,
durationSeconds: 2,
definition: {
type: 'clips',
clips: [
{ startSeconds: 0, endSeconds: 0.8, label: 'same' },
{ startSeconds: 1, endSeconds: 1.8, label: 'same' },
],
},
mode: 'fast',
targetExtension: 'mp4',
});
expect(plan.outputs.map((output) => output.fileName)).toEqual([
'pattern-av-split-001-same.mp4',
'pattern-av-split-002-same.mp4',
]);
expect(plan.args.filter((argument) => argument === '-i')).toHaveLength(2);
expect(() =>
buildSplitPlan({
jobId: 'too-many-splits',
source: media,
durationSeconds: 100,
definition: {
type: 'equal-duration',
segmentDurationSeconds: 0.1,
},
mode: 'fast',
targetExtension: 'mp4',
})
).toThrow(/at most 255/iu);
});
it('rejects container mismatches, ambiguous stream groups, and unsafe transform values', () => {
expect(() =>
buildMetadataEditPlan({
jobId: 'muxer-mismatch',
source: source('media', 0, 'pattern-av.mp4'),
edits: {},
targetExtension: 'mkv',
targetMuxer: 'mp4',
})
).toThrow(/does not match/iu);
expect(() => streamMapArguments({ video: [0], audio: [0] })).toThrow(
/more than one stream group/iu
);
expect(() =>
buildTransformPlan({
jobId: 'fractional-resize',
source: source('media', 0, 'pattern-av.mp4'),
preset: preset('mp4-h264-balanced'),
settings: {
resize: {
sourceWidth: 160,
sourceHeight: 90,
width: 80.5,
mode: 'fit',
},
},
})
).toThrow(/integers/iu);
expect(() =>
buildTransformPlan({
jobId: 'filter-injection',
source: source('media', 0, 'pattern-av.mp4'),
preset: preset('mp4-h264-balanced'),
settings: {
resize: {
sourceWidth: 160,
sourceHeight: 90,
width: 80,
height: 80,
mode: 'contain',
paddingColor: 'black,scale=1:1',
},
},
})
).toThrow(/padding colour/iu);
});
it('requires explicit subtitle loss and bounds generated image counts', () => {
const sources = [
{
...concatSource('first', 0, 'compatible-a.mp4', 128, 72),
streams: [
...concatSource('first', 0, 'compatible-a.mp4', 128, 72).streams,
{ kind: 'subtitle' as const, codec: 'mov_text', timeBase: '1/1000' },
],
},
concatSource('second', 1, 'compatible-b.mp4', 128, 72),
];
const options = {
jobId: 'subtitle-concat-policy',
sources,
targetExtension: 'mp4',
targetMuxer: 'mp4',
preset: preset('mp4-h264-balanced'),
width: 128,
height: 72,
frameRate: 10,
missingAudioPolicy: 'reject' as const,
};
expect(() => buildNormalizedConcatPlan(options)).toThrow(
/cannot retain subtitle/iu
);
expect(
buildNormalizedConcatPlan({
...options,
subtitlePolicy: 'drop-all',
}).diagnostics
).toContainEqual(expect.objectContaining({ code: 'dropped-subtitles' }));
expect(() =>
resolveThumbnailTimes({
durationSeconds: 1000,
intervalSeconds: 1,
})
).toThrow(/at most 255/iu);
});
it('requires finite measured peaks and validates subtitle stream indexes', () => {
expect(() => parsePeakMeasurement('max_volume: -inf dB')).toThrow(
/silent audio/iu
);
expect(() =>
buildPeakNormalizationFilter(-1, { inputPeakDbfs: -80 })
).toThrow(/more than 60 dB/iu);
expect(() =>
buildSubtitleExtractionPlan({
jobId: 'invalid-subtitle-index',
source: source('media', 0, 'pattern-av.mp4'),
stream: { index: -1, codec: 'subrip' },
outputFormat: 'srt',
})
).toThrow(/stream index/iu);
});
it('makes the contact-sheet aspect toggle affect the typed filter graph', () => {
const plan = buildContactSheetPlan({
jobId: 'square-contact-cells',
source: source('media', 0, 'pattern-av.mp4'),
durationSeconds: 2,
frameCount: 4,
rows: 2,
columns: 2,
cellWidth: 80,
maintainAspect: false,
format: 'png',
});
expect(plan.args.find((argument) => argument.includes('tile='))).toContain(
'scale=80:80'
);
});
});
describeNative('native execution of structured command plans', () => {
it('executes conversion, remux, fast/accurate trim, transform, fades, and preview plans', () => {
const media = source('media', 0, 'pattern-av.mp4');
const mediaFile = fixture('pattern-av.mp4');
const h264 = preset('mp4-h264-balanced');
const converted = runNative(
buildConvertPlan({
jobId: 'convert-native',
source: media,
preset: h264,
streamSelection: { video: [0], audio: [1] },
expectedDurationSeconds: 2,
}),
{ media: mediaFile }
);
expect(probe(converted.outputPaths[0]!).streams?.map(streamType)).toEqual([
'video:h264',
'audio:aac',
]);
const gif = runNative(
buildConvertPlan({
jobId: 'gif-native',
source: media,
preset: preset('animated-gif'),
streamSelection: { video: [0] },
}),
{ media: mediaFile }
);
expect(probe(gif.outputPaths[0]!).streams?.[0]?.codec_name).toBe('gif');
const attachedPictureGif = runNative(
buildConvertPlan({
jobId: 'gif-absolute-stream-native',
source: source('cover-audio', 0, 'attached-cover.mp3'),
preset: preset('animated-gif'),
streamSelection: { video: [1] },
}),
{ 'cover-audio': fixture('attached-cover.mp3') }
);
expect(
probe(attachedPictureGif.outputPaths[0]!).streams?.[0]?.codec_name
).toBe('gif');
const remuxed = runNative(
buildRemuxPlan({
jobId: 'remux-native',
source: media,
targetContainer: 'matroska',
fileExtension: 'mkv',
description: {
streams: [
{ index: 0, kind: 'video', codec: 'h264' },
{ index: 1, kind: 'audio', codec: 'aac' },
],
},
expectedDurationSeconds: 2,
}),
{ media: mediaFile }
);
expect(probe(remuxed.outputPaths[0]!).streams).toHaveLength(2);
const fastTrim = runNative(
buildTrimPlan({
jobId: 'trim-fast-native',
source: media,
mode: 'fast',
startSeconds: 0.4,
endSeconds: 1.6,
sourceDurationSeconds: 2,
targetExtension: 'mp4',
}),
{ media: mediaFile }
);
expect(
Number(probe(fastTrim.outputPaths[0]!).format?.duration)
).toBeGreaterThan(0);
const accurateTrim = runNative(
buildTrimPlan({
jobId: 'trim-accurate-native',
source: media,
mode: 'accurate',
startSeconds: 0.4,
endSeconds: 1.6,
sourceDurationSeconds: 2,
preset: h264,
}),
{ media: mediaFile }
);
expect(
Number(probe(accurateTrim.outputPaths[0]!).format?.duration)
).toBeGreaterThanOrEqual(1.15);
expect(
Number(probe(accurateTrim.outputPaths[0]!).format?.duration)
).toBeLessThanOrEqual(1.3);
const transformed = runNative(
buildTransformPlan({
jobId: 'transform-native',
source: media,
preset: h264,
expectedDurationSeconds: 2,
settings: {
crop: {
x: 0,
y: 0,
width: 120,
height: 80,
sourceWidth: 160,
sourceHeight: 90,
requireEvenDimensions: true,
},
resize: {
sourceWidth: 120,
sourceHeight: 80,
width: 96,
height: 72,
mode: 'contain',
},
rotation: 180,
frameRate: 8,
},
}),
{ media: mediaFile }
);
expect(probe(transformed.outputPaths[0]!).streams?.[0]).toMatchObject({
codec_type: 'video',
width: 96,
height: 72,
});
runNative(
buildFadePlan({
jobId: 'fades-native',
source: media,
preset: h264,
durationSeconds: 2,
audioFadeInSeconds: 0.2,
audioFadeOutSeconds: 0.2,
videoFadeInSeconds: 0.2,
videoFadeOutSeconds: 0.2,
}),
{ media: mediaFile }
);
const preview = runNative(
buildPreviewProxyPlan({
jobId: 'preview-native',
source: media,
sourceDurationSeconds: 2,
startSeconds: 0.25,
maxDurationSeconds: 1,
maxWidth: 160,
}),
{ media: mediaFile }
);
expect(
Number(probe(preview.outputPaths[0]!).format?.duration)
).toBeLessThanOrEqual(1.1);
const audioPreview = runNative(
buildPreviewProxyPlan({
jobId: 'audio-preview-native',
source: source('tone', 0, 'tone.wav'),
sourceDurationSeconds: 1,
maxDurationSeconds: 0.5,
hasVideo: false,
}),
{ tone: fixture('tone.wav') }
);
expect(audioPreview.outputPaths[0]).toMatch(/\.m4a$/u);
expect(probe(audioPreview.outputPaths[0]!).streams?.[0]).toMatchObject({
codec_type: 'audio',
codec_name: 'aac',
});
}, 120_000);
it('executes fast and accurate multi-output split plans', () => {
const media = source('media', 0, 'pattern-av.mp4');
const mediaFile = fixture('pattern-av.mp4');
for (const mode of ['fast', 'accurate'] as const) {
const plan = buildSplitPlan({
jobId: `split-${mode}-native`,
source: media,
durationSeconds: 2,
definition: { type: 'markers', markers: [0.7, 1.3] },
mode,
targetExtension: 'mp4',
...(mode === 'accurate' ? { preset: preset('mp4-h264-balanced') } : {}),
});
const execution = runNative(plan, { media: mediaFile });
expect(execution.outputPaths).toHaveLength(3);
for (const path of execution.outputPaths) {
expect(
probe(path).streams?.some((stream) => stream.codec_type === 'video'),
`${mode} split output ${basename(path)} must retain video`
).toBe(true);
}
}
}, 120_000);
it('executes fast and normalized concat, including generated silence', () => {
const first = concatSource('first', 8, 'compatible-a.mp4', 128, 72);
const second = concatSource('second', 3, 'compatible-b.mp4', 128, 72);
const firstRange = {
...first,
sourceInSeconds: 0.2,
sourceOutSeconds: 0.8,
};
const secondRange = {
...second,
sourceInSeconds: 0.1,
sourceOutSeconds: 0.9,
};
const fastPlan = buildFastConcatPlan({
jobId: 'fast-concat-native',
sources: [firstRange, secondRange],
targetExtension: 'mp4',
targetMuxer: 'mp4',
});
expect(String(fastPlan.temporaryFiles[0]?.content)).toContain(
'inpoint 0.2'
);
expect(fastPlan.expectedDurationSeconds).toBeCloseTo(1.4, 5);
const fast = runNative(fastPlan, {
first: fixture('compatible-a.mp4'),
second: fixture('compatible-b.mp4'),
});
expect(
Number(probe(fast.outputPaths[0]!).format?.duration)
).toBeGreaterThan(1.2);
expect(Number(probe(fast.outputPaths[0]!).format?.duration)).toBeLessThan(
2.1
);
const normalized = runNative(
buildNormalizedConcatPlan({
jobId: 'normalized-concat-native',
sources: [
concatSource('first', 0, 'compatible-a.mp4', 128, 72),
{
...concatSource('different', 1, 'different-dimensions.mp4', 96, 96),
sourceInSeconds: 0.2,
sourceOutSeconds: 0.8,
},
],
targetExtension: 'mp4',
targetMuxer: 'mp4',
preset: preset('mp4-h264-balanced'),
width: 128,
height: 72,
frameRate: 10,
sampleRate: 16_000,
channelLayout: 'mono',
missingAudioPolicy: 'reject',
}),
{
first: fixture('compatible-a.mp4'),
different: fixture('different-dimensions.mp4'),
}
);
expect(probe(normalized.outputPaths[0]!).streams?.[0]).toMatchObject({
width: 128,
height: 72,
});
expect(
Number(probe(normalized.outputPaths[0]!).format?.duration)
).toBeCloseTo(1.6, 1);
const videoOnly = join(suiteDirectory, 'compatible-video-only.mp4');
nativeCommand(
[
'-i',
fixture('compatible-b.mp4'),
'-map',
'0:v:0',
'-c',
'copy',
videoOnly,
],
videoOnly
);
const silence = runNative(
buildNormalizedConcatPlan({
jobId: 'silence-concat-native',
sources: [
concatSource('first', 0, 'compatible-a.mp4', 128, 72),
concatSource(
'video-only',
1,
'compatible-video-only.mp4',
128,
72,
false
),
],
targetExtension: 'mp4',
targetMuxer: 'mp4',
preset: preset('mp4-h264-balanced'),
width: 128,
height: 72,
frameRate: 10,
sampleRate: 16_000,
channelLayout: 'mono',
missingAudioPolicy: 'insert-silence',
}),
{ first: fixture('compatible-a.mp4'), 'video-only': videoOnly }
);
expect(
probe(silence.outputPaths[0]!).streams?.some(
(stream) => stream.codec_type === 'audio'
)
).toBe(true);
}, 120_000);
it('executes audio extraction, waveform, loudness, and normalization plans', () => {
const media = source('media', 0, 'pattern-av.mp4');
const mediaFile = fixture('pattern-av.mp4');
const copiedAudio = runNative(
buildAudioExtractionPlan({
jobId: 'audio-copy-native',
source: media,
audioStreamIndex: 1,
mode: 'copy',
inputCodec: 'aac',
targetMuxer: 'ipod',
targetExtension: 'm4a',
}),
{ media: mediaFile }
);
expect(probe(copiedAudio.outputPaths[0]!).streams?.[0]?.codec_name).toBe(
'aac'
);
const encodedAudio = runNative(
buildAudioExtractionPlan({
jobId: 'audio-encode-native',
source: media,
audioStreamIndex: 1,
mode: 'encode',
preset: preset('audio-wav-pcm'),
}),
{ media: mediaFile }
);
expect(probe(encodedAudio.outputPaths[0]!).streams?.[0]?.codec_name).toBe(
'pcm_s16le'
);
runNative(
buildWaveformAnalysisPlan({
jobId: 'waveform-data-native',
source: media,
audioStreamIndex: 1,
sampleRate: 8_000,
}),
{ media: mediaFile }
);
const staticWaveform = runNative(
buildStaticWaveformPlan({
jobId: 'waveform-static-native',
source: media,
audioStreamIndex: 1,
width: 320,
height: 80,
color: '#ffffff',
background: '#000000',
}),
{ media: mediaFile }
);
expect(probe(staticWaveform.outputPaths[0]!).streams?.[0]).toMatchObject({
codec_name: 'png',
width: 320,
height: 80,
});
const targets = {
integratedLufs: -16,
truePeakDbtp: -1,
loudnessRangeLu: 11,
};
const analysis = runNative(
buildLoudnessAnalysisPlan({
jobId: 'loudness-analysis-native',
source: media,
targets,
audioStreamIndex: 1,
}),
{ media: mediaFile },
'info'
);
const measurement = parseLoudnessMeasurement(
`${analysis.stderr}\n${analysis.stdout}`
);
expect(measurement.inputIntegratedLufs).toBeTypeOf('number');
runNative(
buildLoudnessApplyPlan({
jobId: 'loudness-apply-native',
source: media,
targets,
audioStreamIndex: 1,
measurement,
preset: preset('audio-m4a-aac'),
}),
{ media: mediaFile }
);
const peakAnalysis = runNative(
buildPeakAnalysisPlan({
jobId: 'peak-analysis-native',
source: media,
audioStreamIndex: 1,
preset: preset('audio-m4a-aac'),
}),
{ media: mediaFile },
'info'
);
const peakMeasurement = parsePeakMeasurement(
`${peakAnalysis.stderr}\n${peakAnalysis.stdout}`
);
expect(peakMeasurement.inputPeakDbfs).toBeLessThanOrEqual(0);
const peakApplied = runNative(
buildPeakNormalizationPlan({
jobId: 'peak-native',
source: media,
audioStreamIndex: 1,
targetPeakDb: -1,
measurement: peakMeasurement,
preset: preset('audio-m4a-aac'),
}),
{ media: mediaFile }
);
const verifiedPeak = runNative(
buildPeakAnalysisPlan({
jobId: 'peak-verify-native',
source: source('normalized', 0, 'peak-normalized.m4a'),
audioStreamIndex: 0,
preset: preset('audio-m4a-aac'),
}),
{ normalized: peakApplied.outputPaths[0]! },
'info'
);
const outputPeak = parsePeakMeasurement(
`${verifiedPeak.stderr}\n${verifiedPeak.stdout}`
).inputPeakDbfs;
expect(outputPeak).toBeGreaterThanOrEqual(-2);
expect(outputPeak).toBeLessThanOrEqual(0);
}, 120_000);
it('executes stream removal, metadata editing, and chapter mux plans', () => {
const media = source('media', 0, 'metadata.mp4');
const mediaFile = fixture('metadata.mp4');
const removed = runNative(
buildStreamRemovalPlan({
jobId: 'streams-native',
source: media,
selection: { audio: [1] },
targetExtension: 'm4a',
targetMuxer: 'ipod',
}),
{ media: mediaFile }
);
expect(probe(removed.outputPaths[0]!).streams?.map(streamType)).toEqual([
'audio:aac',
]);
const metadataPlan = buildMetadataEditPlan({
jobId: 'metadata-native',
source: media,
edits: {
format: { title: 'Runtime title', artist: 'Local artist' },
streams: { 1: { language: 'deu' } },
dispositions: { 0: [], 1: ['forced'] },
},
targetExtension: 'mp4',
targetMuxer: 'mp4',
sourceMetadataPolicy: 'remove',
chapterPolicy: 'remove',
});
expect(metadataPlan.args).toEqual(
expect.arrayContaining(['-disposition:0', '0'])
);
const metadata = runNative(metadataPlan, { media: mediaFile });
expect(probe(metadata.outputPaths[0]!).format?.tags).toMatchObject({
title: 'Runtime title',
artist: 'Local artist',
});
expect(probe(metadata.outputPaths[0]!).streams).toEqual(
expect.arrayContaining([
expect.objectContaining({
index: 1,
disposition: expect.objectContaining({ forced: 1 }),
}),
])
);
const chaptered = runNative(
buildChapterMuxPlan({
jobId: 'chapters-native',
source: source('media', 0, 'pattern-av.mp4'),
chapters: [
{
id: 'intro',
title: 'Intro',
startSeconds: 0,
endSeconds: 0.75,
timeBase: '1/48000',
},
{
id: 'main',
title: 'Main',
startSeconds: 0.75,
endSeconds: 2,
timeBase: '1/1000',
},
],
targetExtension: 'mp4',
targetMuxer: 'mp4',
durationSeconds: 2,
}),
{ media: fixture('pattern-av.mp4') }
);
const chapterProbe = probe(chaptered.outputPaths[0]!).chapters;
expect(chapterProbe?.map(chapterTitle)).toEqual(['Intro', 'Main']);
expect(Number(chapterProbe?.[0]?.end_time)).toBeCloseTo(0.75, 3);
expect(Number(chapterProbe?.[1]?.start_time)).toBeCloseTo(0.75, 3);
}, 120_000);
it('executes subtitle extraction, conversion, mux, and burn-in plans', () => {
const subtitledSourcePath = join(suiteDirectory, 'subtitled-source.mp4');
nativeCommand(
[
'-i',
fixture('pattern-av.mp4'),
'-i',
fixture('subtitle.srt'),
'-map',
'0',
'-map',
'1:s:0',
'-c',
'copy',
'-c:s',
'mov_text',
subtitledSourcePath,
],
subtitledSourcePath
);
const subtitledSource = source('media', 0, 'subtitled-source.mp4');
const convertedMedia = runNative(
buildConvertPlan({
jobId: 'convert-selected-subtitle-native',
source: subtitledSource,
preset: preset('mp4-h264-balanced'),
streamSelection: { video: [0], audio: [1], subtitles: [2] },
}),
{ media: subtitledSourcePath }
);
expect(
probe(convertedMedia.outputPaths[0]!).streams?.map(streamType)
).toEqual(['video:h264', 'audio:aac', 'subtitle:mov_text']);
const extracted = runNative(
buildSubtitleExtractionPlan({
jobId: 'subtitle-extract-native',
source: subtitledSource,
stream: { index: 2, codec: 'mov_text', language: 'eng' },
outputFormat: 'srt',
}),
{ media: subtitledSourcePath }
);
expect(readFileSync(extracted.outputPaths[0]!, 'utf8')).toContain(
'Generated subtitle'
);
const externalSubtitle = source('subtitle', 1, 'subtitle.srt');
const converted = runNative(
buildSubtitleConversionPlan({
jobId: 'subtitle-convert-native',
subtitle: externalSubtitle,
outputFormat: 'vtt',
offsetSeconds: 0.1,
}),
{ subtitle: fixture('subtitle.srt') }
);
expect(readFileSync(converted.outputPaths[0]!, 'utf8')).toContain('WEBVTT');
const muxed = runNative(
buildSubtitleMuxPlan({
jobId: 'subtitle-mux-native',
source: subtitledSource,
subtitle: externalSubtitle,
subtitleCodec: 'subrip',
sourceStreams: [
{ index: 0, kind: 'video', codec: 'h264' },
{ index: 1, kind: 'audio', codec: 'aac' },
{ index: 2, kind: 'subtitle', codec: 'mov_text' },
],
targetContainer: 'mp4',
targetExtension: 'mp4',
language: 'eng',
title: 'English',
default: true,
}),
{
media: subtitledSourcePath,
subtitle: fixture('subtitle.srt'),
}
);
expect(
probe(muxed.outputPaths[0]!).streams?.filter(
(stream) => stream.codec_name === 'mov_text'
)
).toHaveLength(2);
expect(
probe(muxed.outputPaths[0]!).streams?.filter(
(stream) => stream.codec_type === 'subtitle'
)[1]?.tags
).toMatchObject({ language: 'eng', handler_name: 'English' });
}, 120_000);
itNativeSubtitles(
'executes subtitle burn-in when the native test binary has libass',
() => {
const burned = runNative(
buildSubtitleBurnPlan({
jobId: 'subtitle-burn-native',
source: source('media', 0, 'pattern-av.mp4'),
subtitle: source('subtitle', 1, 'subtitle.srt'),
preset: preset('mp4-h264-balanced'),
expectedDurationSeconds: 2,
}),
{
media: fixture('pattern-av.mp4'),
subtitle: fixture('subtitle.srt'),
}
);
expect(
probe(burned.outputPaths[0]!).streams?.some(
(stream) => stream.codec_type === 'subtitle'
)
).toBe(false);
},
120_000
);
it('executes single/series thumbnail and contact-sheet plans', () => {
const media = source('media', 0, 'pattern-av.mp4');
const mediaFile = fixture('pattern-av.mp4');
const single = runNative(
buildSingleThumbnailPlan({
jobId: 'thumbnail-native',
source: media,
timeSeconds: 0.5,
size: { width: 80 },
format: 'png',
}),
{ media: mediaFile }
);
expect(probe(single.outputPaths[0]!).streams?.[0]).toMatchObject({
codec_name: 'png',
width: 80,
});
const series = runNative(
buildThumbnailSeriesPlan({
jobId: 'thumbnail-series-native',
source: media,
durationSeconds: 2,
timesSeconds: [0.2, 0.8, 1.4],
size: { width: 80 },
format: 'jpeg',
quality: 75,
}),
{ media: mediaFile }
);
expect(series.outputPaths).toHaveLength(3);
expect(
series.outputPaths.map((path) => probe(path).streams?.[0]?.codec_name)
).toEqual(['mjpeg', 'mjpeg', 'mjpeg']);
const sheet = runNative(
buildContactSheetPlan({
jobId: 'contact-sheet-native',
source: media,
durationSeconds: 2,
frameCount: 4,
rows: 2,
columns: 2,
cellWidth: 80,
spacing: 2,
timestampLabels: false,
sourceLabel: false,
format: 'png',
}),
{ media: mediaFile }
);
expect(probe(sheet.outputPaths[0]!).streams?.[0]).toMatchObject({
codec_name: 'png',
width: 166,
});
}, 120_000);
it('executes a transformed, faded, normalized sequential timeline', () => {
const execution = runNative(
buildTimelineExportPlan({
jobId: 'timeline-native',
clips: [
{
id: 'clip-a',
source: source('first', 0, 'compatible-a.mp4'),
sourceInSeconds: 0.1,
sourceOutSeconds: 0.9,
sourceDurationSeconds: 1,
hasVideo: true,
hasAudio: true,
transform: { rotation: 180 },
audioGainDb: -1,
audioNormalization: {
mode: 'peak',
targetPeakDb: -1,
measurement: { inputPeakDbfs: -18 },
},
audioFadeInSeconds: 0.1,
audioFadeCurve: 'qsin',
videoFadeOutSeconds: 0.1,
videoFadeCurve: 'exp',
},
{
id: 'clip-b',
source: source('second', 1, 'different-dimensions.mp4'),
sourceInSeconds: 0,
sourceOutSeconds: 0.8,
sourceDurationSeconds: 1,
hasVideo: true,
hasAudio: true,
transform: {
crop: {
x: 0,
y: 0,
width: 80,
height: 80,
sourceWidth: 96,
sourceHeight: 96,
requireEvenDimensions: true,
},
},
audioFadeOutSeconds: 0.1,
audioFadeCurve: 'exp',
videoFadeInSeconds: 0.1,
videoFadeCurve: 'qsin',
},
],
preset: preset('mp4-h264-balanced'),
outputWidth: 128,
outputHeight: 72,
frameRate: 10,
sampleRate: 16_000,
channelLayout: 'mono',
missingAudioPolicy: 'reject',
}),
{
first: fixture('compatible-a.mp4'),
second: fixture('different-dimensions.mp4'),
}
);
const result = probe(execution.outputPaths[0]!);
expect(result.streams?.[0]).toMatchObject({ width: 128, height: 72 });
expect(
result.streams?.some((stream) => stream.codec_type === 'audio')
).toBe(true);
expect(Number(result.format?.duration)).toBeCloseTo(1.6, 1);
}, 120_000);
});
function streamType(stream: {
readonly codec_type?: string;
readonly codec_name?: string;
}): string {
return `${stream.codec_type ?? 'unknown'}:${stream.codec_name ?? 'unknown'}`;
}
function chapterTitle(chapter: {
readonly tags?: Readonly<Record<string, string>>;
}): string | undefined {
return chapter.tags?.title;
}