393 lines
11 KiB
TypeScript
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('..');
|
|
});
|
|
});
|