feat: publish av-tools 0.1.0
This commit is contained in:
392
tests/unit/commands.test.ts
Normal file
392
tests/unit/commands.test.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
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('..');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user