1274 lines
35 KiB
TypeScript
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;
|
|
}
|