Files
av-tools/tests/unit/commands.test.ts

393 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
buildConvertPlan,
buildFastConcatPlan,
buildPreviewProxyPlan,
buildRemuxPlan,
buildSplitPlan,
buildStreamRemovalPlan,
buildTimelineExportPlan,
buildTrimPlan,
calculateResize,
createConcatList,
createPlannedInput,
createVideoFiltergraph,
displayArguments,
sourceVirtualPath,
validateConcatCompatibility,
validateCrop,
validateSplitMarkers,
} from '../../src/commands';
import { findBuiltInPreset } from '../../src/presets';
const SOURCE = {
id: 'source',
sourceIndex: 0,
fileName: "../../My holiday's clip.mp4",
extension: 'mp4',
} as const;
describe('structured command plans', () => {
it('generates safe deterministic virtual paths without source path disclosure', () => {
expect(sourceVirtualPath('../job id', SOURCE)).toBe(
'/input/job-job-id/source-0.mp4'
);
expect(createPlannedInput('abc', SOURCE)).toEqual({
id: 'source',
sourceIndex: 0,
kind: 'media',
path: '/input/job-abc/source-0.mp4',
});
});
it('renders shell-like diagnostics without changing structured arguments', () => {
const args = [
'-i',
"/input/a file's.mp4",
'-metadata',
'title=hello world',
];
expect(displayArguments(args)).toBe(
"-i '/input/a file'\\''s.mp4' -metadata 'title=hello world'"
);
expect(args).toEqual([
'-i',
"/input/a file's.mp4",
'-metadata',
'title=hello world',
]);
});
it('escapes generated concat paths and rejects line injection', () => {
expect(createConcatList(['/input/source-0.mp4', "/input/it's.mp4"])).toBe(
"file '/input/source-0.mp4'\nfile '/input/it'\\''s.mp4'\n"
);
expect(() =>
createConcatList(["/input/a.mp4\nfile '/etc/passwd'"])
).toThrow(/line breaks/u);
});
it('composes conversion filters into one -vf argument', () => {
const preset = findBuiltInPreset('mp4-h264-balanced');
expect(preset).toBeDefined();
const plan = buildConvertPlan({
jobId: 'convert-1',
source: SOURCE,
preset: preset!,
videoFiltergraph: 'crop=640:360:0:0,fade=t=in:st=0:d=1',
audioFiltergraph: 'afade=t=in:st=0:d=1',
expectedDurationSeconds: 10,
});
expect(plan.args.filter((argument) => argument === '-vf')).toHaveLength(1);
expect(plan.args).toContain('crop=640:360:0:0,fade=t=in:st=0:d=1');
expect(plan.args).toContain('afade=t=in:st=0:d=1');
expect(plan.args.at(-1)).toMatch(/\.mp4$/u);
expect(Object.isFrozen(plan.args)).toBe(true);
});
it('distinguishes fast and accurate trim argument placement', () => {
const fast = buildTrimPlan({
jobId: 'trim-fast',
source: SOURCE,
mode: 'fast',
startSeconds: 1.25,
endSeconds: 3.5,
sourceDurationSeconds: 10,
targetExtension: 'mp4',
});
expect(fast.args.slice(0, 7)).toEqual([
'-ss',
'1.25',
'-to',
'3.5',
'-i',
'/input/job-trim-fast/source-0.mp4',
'-map',
]);
expect(fast.args).toContain('copy');
expect(fast.diagnostics[0]?.severity).toBe('warning');
const preset = findBuiltInPreset('mp4-h264-balanced');
const accurate = buildTrimPlan({
jobId: 'trim-accurate',
source: SOURCE,
mode: 'accurate',
startSeconds: 1.25,
endSeconds: 3.5,
preset: preset!,
});
expect(accurate.args.slice(0, 6)).toEqual([
'-ss',
'1.25',
'-to',
'3.5',
'-i',
'/input/job-trim-accurate/source-0.mp4',
]);
expect(accurate.args).toContain('libx264');
});
it('validates and expands every supported split definition', () => {
expect(validateSplitMarkers([8, 2, 5], 10)).toEqual([2, 5, 8]);
expect(() => validateSplitMarkers([2, 2.01], 10)).toThrow(/apart/u);
const plan = buildSplitPlan({
jobId: 'split',
source: SOURCE,
durationSeconds: 10,
definition: { type: 'part-count', parts: 3 },
mode: 'fast',
targetExtension: 'mp4',
});
expect(plan.outputs.map((output) => output.fileName)).toEqual([
'My-holiday-s-clip-split-001.mp4',
'My-holiday-s-clip-split-002.mp4',
'My-holiday-s-clip-split-003.mp4',
]);
expect(plan.outputs.map((output) => output.timeRange)).toEqual([
{ startSeconds: 0, endSeconds: 10 / 3 },
{ startSeconds: 10 / 3, endSeconds: 20 / 3 },
{ startSeconds: 20 / 3, endSeconds: 10 },
]);
expect(
plan.args.filter((argument) => argument === '-progress')
).toHaveLength(1);
expect(plan.args.filter((argument) => argument === 'copy')).toHaveLength(3);
});
it('blocks incompatible remuxes before returning a plan', () => {
expect(() =>
buildRemuxPlan({
jobId: 'remux',
source: SOURCE,
targetContainer: 'mp4',
fileExtension: 'mp4',
description: {
streams: [{ index: 0, kind: 'video', codec: 'vp9' }],
},
})
).toThrow(/cannot be remuxed/u);
});
it('maps selected attachment and data streams during compatible remux', () => {
const plan = buildRemuxPlan({
jobId: 'remux-auxiliary',
source: SOURCE,
targetContainer: 'matroska',
fileExtension: 'mkv',
description: {
streams: [
{ index: 2, kind: 'attachment', codec: 'ttf' },
{ index: 3, kind: 'data', codec: 'bin_data' },
],
},
streamSelection: {
attachments: [2],
data: [3],
},
});
expect(plan.args).toEqual(
expect.arrayContaining(['-map', '0:2', '-map', '0:3', '-c', 'copy'])
);
});
it('maps only explicitly retained streams for stream removal', () => {
const plan = buildStreamRemovalPlan({
jobId: 'streams',
source: SOURCE,
selection: { video: [0], audio: [1], subtitles: [] },
targetExtension: 'mkv',
targetMuxer: 'matroska',
});
expect(plan.args).toEqual([
'-i',
'/input/job-streams/source-0.mp4',
'-map',
'0:0',
'-map',
'0:1',
'-c',
'copy',
'-map_metadata',
'0',
'-map_chapters',
'0',
'/work/job-streams/My-holiday-s-clip-streams.mkv',
]);
});
});
describe('concat and transform validation', () => {
const STREAMS = [
{
kind: 'video' as const,
codec: 'h264',
width: 640,
height: 360,
pixelFormat: 'yuv420p',
timeBase: '1/12800',
},
{
kind: 'audio' as const,
codec: 'aac',
sampleRate: 48_000,
channelLayout: 'stereo',
timeBase: '1/48000',
},
];
const SOURCES = [
{
...SOURCE,
id: 'one',
sourceIndex: 0,
durationSeconds: 2,
streams: STREAMS,
},
{
...SOURCE,
id: 'two',
sourceIndex: 1,
fileName: 'two.mp4',
durationSeconds: 3,
streams: STREAMS,
},
];
it('checks exact fast-concat stream arrangement and properties', () => {
expect(validateConcatCompatibility(SOURCES)).toEqual([]);
expect(
validateConcatCompatibility([
SOURCES[0]!,
{
...SOURCES[1]!,
streams: [{ ...STREAMS[0]!, width: 1280 }, STREAMS[1]!],
},
])[0]
).toMatchObject({ code: 'stream-property', severity: 'error' });
const plan = buildFastConcatPlan({
jobId: 'concat',
sources: SOURCES,
targetExtension: 'mp4',
targetMuxer: 'mp4',
});
expect(plan.temporaryFiles[0]?.content).toBe(
"file '/input/job-concat/source-0.mp4'\nfile '/input/job-concat/source-1.mp4'\n"
);
expect(plan.expectedDurationSeconds).toBe(5);
});
it('corrects crop to even bounds and calculates aspect-preserving resize', () => {
expect(
validateCrop({
x: 3,
y: 5,
width: 101,
height: 51,
sourceWidth: 200,
sourceHeight: 100,
requireEvenDimensions: true,
})
).toMatchObject({ x: 2, y: 4, width: 100, height: 50 });
expect(
calculateResize({
sourceWidth: 1920,
sourceHeight: 1080,
width: 640,
height: 640,
mode: 'contain',
})
).toEqual({ width: 640, height: 360 });
});
it('orders typed filter stages independently of insertion order', () => {
const builder = createVideoFiltergraph()
.add({ id: 'fade', stage: 'fade', expression: 'fade=t=in:d=1' })
.add({ id: 'crop', stage: 'crop', expression: 'crop=100:100:0:0' })
.add({ id: 'fps', stage: 'frame-rate', expression: 'fps=25' });
expect(builder.build().graph).toBe('crop=100:100:0:0,fps=25,fade=t=in:d=1');
});
it('builds a bounded preview proxy with optional audio mapping', () => {
const plan = buildPreviewProxyPlan({
jobId: 'preview',
source: SOURCE,
sourceDurationSeconds: 120,
startSeconds: 10,
});
expect(plan.expectedDurationSeconds).toBe(30);
expect(plan.args).toContain('0:a:0?');
expect(plan.args).toContain("scale=w='max(2,trunc(min(960,iw)/2)*2)':h=-2");
});
it('builds a bounded AAC/M4A proxy for an audio-only source', () => {
const plan = buildPreviewProxyPlan({
jobId: 'audio-preview',
source: SOURCE,
sourceDurationSeconds: 90,
maxDurationSeconds: 20,
hasVideo: false,
});
expect(plan.expectedDurationSeconds).toBe(20);
expect(plan.outputs[0]?.fileName).toMatch(/-preview\.m4a$/u);
expect(plan.outputs[0]?.mediaType).toBe('audio/mp4');
expect(plan.args).toContain('0:a:0');
expect(plan.args).toContain('aac');
expect(plan.args).not.toContain('0:v:0');
expect(plan.args).not.toContain('libx264');
expect(plan.args).not.toContain('-vf');
expect(plan.requiredCapabilities).toMatchObject({
muxers: ['ipod'],
encoders: ['aac'],
filters: [],
});
});
it('builds one normalized timeline filter graph', () => {
const preset = findBuiltInPreset('mp4-h264-balanced');
const plan = buildTimelineExportPlan({
jobId: 'timeline',
preset: preset!,
outputWidth: 640,
outputHeight: 360,
frameRate: 25,
missingAudioPolicy: 'insert-silence',
outputFileName: '../../My final cut.mov',
clips: [
{
id: 'one',
source: SOURCES[0]!,
sourceInSeconds: 0.25,
sourceOutSeconds: 1.75,
hasVideo: true,
hasAudio: true,
audioFadeInSeconds: 0.25,
audioFadeCurve: 'qsin',
videoFadeOutSeconds: 0.25,
videoFadeCurve: 'exp',
},
{
id: 'two',
source: SOURCES[1]!,
sourceInSeconds: 0,
sourceOutSeconds: 2,
hasVideo: true,
hasAudio: false,
},
],
});
expect(
plan.args.filter((argument) => argument === '-filter_complex')
).toHaveLength(1);
const graph = plan.args[plan.args.indexOf('-filter_complex') + 1];
expect(graph).toContain('anullsrc=');
expect(graph).toContain('curve=qsin');
expect(graph).toContain("geq=lum_expr='lum(X,Y)*((exp(5*");
expect(graph).toContain('concat=n=2:v=1:a=1[vout][aout]');
expect(plan.requiredCapabilities.filters).toContain('geq');
expect(plan.expectedDurationSeconds).toBe(3.5);
expect(plan.outputs[0]?.fileName).toBe('My-final-cut.mp4');
expect(plan.outputs[0]?.path).not.toContain('..');
});
});