feat: publish av-tools 0.1.0
This commit is contained in:
458
tests/unit/commands-advanced.test.ts
Normal file
458
tests/unit/commands-advanced.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildContactSheetPlan,
|
||||
buildConvertPlan,
|
||||
buildFadePlan,
|
||||
buildLoudnessAnalysisPlan,
|
||||
buildLoudnessApplyPlan,
|
||||
buildMetadataEditPlan,
|
||||
buildPeakAnalysisPlan,
|
||||
buildPeakNormalizationFilter,
|
||||
buildPeakNormalizationPlan,
|
||||
buildSubtitleBurnPlan,
|
||||
buildSubtitleExtractionPlan,
|
||||
buildSubtitleMuxPlan,
|
||||
buildStaticWaveformPlan,
|
||||
buildSingleThumbnailPlan,
|
||||
buildThumbnailSeriesPlan,
|
||||
buildTransformPlan,
|
||||
classifySubtitleCodec,
|
||||
createChaptersAtIntervals,
|
||||
createChaptersFromMarkers,
|
||||
escapeFFMetadata,
|
||||
escapeFilterValue,
|
||||
metadataArguments,
|
||||
parseChapterJson,
|
||||
parseLoudnessMeasurement,
|
||||
parsePeakMeasurement,
|
||||
parseMetadataJson,
|
||||
resolveThumbnailTimes,
|
||||
serializeChapterJson,
|
||||
serializeFFMetadata,
|
||||
validateChapters,
|
||||
} from '../../src/commands';
|
||||
import { findBuiltInPreset } from '../../src/presets';
|
||||
|
||||
const SOURCE = {
|
||||
id: 'media',
|
||||
sourceIndex: 0,
|
||||
fileName: 'source.mp4',
|
||||
extension: 'mp4',
|
||||
} as const;
|
||||
|
||||
describe('metadata and chapter commands', () => {
|
||||
it('serializes deterministic metadata args without a command string', () => {
|
||||
expect(
|
||||
metadataArguments({
|
||||
format: { title: 'A title', artist: 'An artist' },
|
||||
streams: { 1: { language: 'deu', title: null } },
|
||||
})
|
||||
).toEqual([
|
||||
'-metadata',
|
||||
'artist=An artist',
|
||||
'-metadata',
|
||||
'title=A title',
|
||||
'-metadata:s:1',
|
||||
'language=deu',
|
||||
'-metadata:s:1',
|
||||
'title=',
|
||||
]);
|
||||
expect(
|
||||
parseMetadataJson(
|
||||
'{"format":{"title":"Demo"},"streams":{"0":{"language":"eng"}},"dispositions":{"0":["forced","default"]}}'
|
||||
)
|
||||
).toEqual({
|
||||
format: { title: 'Demo' },
|
||||
streams: { 0: { language: 'eng' } },
|
||||
dispositions: { 0: ['default', 'forced'] },
|
||||
});
|
||||
expect(
|
||||
metadataArguments({
|
||||
dispositions: { 0: [], 1: ['forced', 'default'] },
|
||||
})
|
||||
).toEqual(['-disposition:0', '0', '-disposition:1', 'default+forced']);
|
||||
expect(() =>
|
||||
parseMetadataJson('{"dispositions":{"0":["default","default"]}}')
|
||||
).toThrow(/duplicates/iu);
|
||||
expect(() => parseMetadataJson('{"args":["-i","remote"]}')).toThrow(
|
||||
/unsupported/iu
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes every FFMETADATA delimiter and round-trips chapter JSON', () => {
|
||||
expect(escapeFFMetadata('a=b;c#d\\e\nf')).toBe('a\\=b\\;c\\#d\\\\e\\\nf');
|
||||
const chapters = [
|
||||
{ id: 'intro', title: 'Intro #1', startSeconds: 0, endSeconds: 2 },
|
||||
{ id: 'main', title: 'Main', startSeconds: 2, endSeconds: 5 },
|
||||
];
|
||||
expect(serializeFFMetadata(chapters)).toContain('title=Intro \\#1');
|
||||
const json = serializeChapterJson(chapters);
|
||||
expect(parseChapterJson(json).chapters).toEqual(chapters);
|
||||
expect(
|
||||
validateChapters([chapters[0]!, { ...chapters[1]!, startSeconds: 1 }])[0]
|
||||
).toMatchObject({ severity: 'error' });
|
||||
expect(createChaptersFromMarkers([2, 5], 8)).toHaveLength(3);
|
||||
expect(
|
||||
createChaptersAtIntervals(10, 4).map((chapter) => chapter.startSeconds)
|
||||
).toEqual([0, 4, 8]);
|
||||
});
|
||||
|
||||
it('preserves validated per-chapter time bases and converts seconds to ticks', () => {
|
||||
const metadata = serializeFFMetadata([
|
||||
{
|
||||
id: 'milliseconds',
|
||||
title: 'Milliseconds',
|
||||
startSeconds: 0,
|
||||
endSeconds: 0.5,
|
||||
timeBase: '1/1000',
|
||||
},
|
||||
{
|
||||
id: 'samples',
|
||||
title: 'Samples',
|
||||
startSeconds: 0.5,
|
||||
endSeconds: 1,
|
||||
timeBase: '1/48000',
|
||||
},
|
||||
]);
|
||||
expect(metadata).toContain('TIMEBASE=1/1000\nSTART=0\nEND=500');
|
||||
expect(metadata).toContain('TIMEBASE=1/48000\nSTART=24000\nEND=48000');
|
||||
expect(() =>
|
||||
serializeFFMetadata([
|
||||
{
|
||||
id: 'invalid',
|
||||
title: 'Invalid',
|
||||
startSeconds: 0,
|
||||
endSeconds: 1,
|
||||
timeBase: '0/1000',
|
||||
},
|
||||
])
|
||||
).toThrow(/time base/iu);
|
||||
expect(() =>
|
||||
serializeFFMetadata([
|
||||
{
|
||||
id: 'coarse',
|
||||
title: 'Coarse',
|
||||
startSeconds: 0,
|
||||
endSeconds: 0.1,
|
||||
timeBase: '1/1',
|
||||
},
|
||||
])
|
||||
).toThrow(/too coarse/iu);
|
||||
});
|
||||
|
||||
it('builds a stream-copy metadata edit with explicit removal diagnostics', () => {
|
||||
const plan = buildMetadataEditPlan({
|
||||
jobId: 'meta',
|
||||
source: SOURCE,
|
||||
edits: { format: { title: 'Edited' } },
|
||||
targetExtension: 'mp4',
|
||||
targetMuxer: 'mp4',
|
||||
sourceMetadataPolicy: 'remove',
|
||||
chapterPolicy: 'remove',
|
||||
});
|
||||
expect(plan.args).toContain('title=Edited');
|
||||
expect(plan.args).toContain('copy');
|
||||
expect(plan.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: 'metadata-removal-scope' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loudness and subtitle commands', () => {
|
||||
const LOG = `
|
||||
[Parsed_loudnorm_0] {
|
||||
"input_i" : "-19.70",
|
||||
"input_tp" : "-2.10",
|
||||
"input_lra" : "4.20",
|
||||
"input_thresh" : "-30.00",
|
||||
"target_offset" : "-0.30"
|
||||
}`;
|
||||
|
||||
it('parses complete loudnorm JSON and rejects invalid measurements', () => {
|
||||
expect(parseLoudnessMeasurement(LOG)).toEqual({
|
||||
inputIntegratedLufs: -19.7,
|
||||
inputTruePeakDbtp: -2.1,
|
||||
inputLoudnessRangeLu: 4.2,
|
||||
inputThresholdLufs: -30,
|
||||
targetOffsetLu: -0.3,
|
||||
});
|
||||
expect(() =>
|
||||
parseLoudnessMeasurement(
|
||||
'{"input_i":"-inf","input_tp":"0","input_lra":"0","input_thresh":"0"}'
|
||||
)
|
||||
).toThrow(/finite/u);
|
||||
expect(buildPeakNormalizationFilter(-1, { inputPeakDbfs: -6 })).toMatch(
|
||||
/^volume=1\.778/u
|
||||
);
|
||||
});
|
||||
|
||||
it('builds distinct first and second loudness passes', () => {
|
||||
const targets = {
|
||||
integratedLufs: -16,
|
||||
truePeakDbtp: -1,
|
||||
loudnessRangeLu: 11,
|
||||
};
|
||||
const measurement = parseLoudnessMeasurement(LOG);
|
||||
const analysis = buildLoudnessAnalysisPlan({
|
||||
jobId: 'loud',
|
||||
source: SOURCE,
|
||||
targets,
|
||||
audioStreamIndex: 1,
|
||||
});
|
||||
expect(analysis.outputs).toEqual([]);
|
||||
expect(analysis.args).toContain('-f');
|
||||
expect(analysis.args).toContain('null');
|
||||
expect(analysis.args).toContain('0:1');
|
||||
expect(
|
||||
analysis.args.find((value) => value.startsWith('loudnorm='))
|
||||
).toContain('print_format=json');
|
||||
const apply = buildLoudnessApplyPlan({
|
||||
jobId: 'loud',
|
||||
source: SOURCE,
|
||||
targets,
|
||||
measurement,
|
||||
audioStreamIndex: 1,
|
||||
preset: findBuiltInPreset('audio-m4a-aac')!,
|
||||
});
|
||||
expect(apply.args.find((value) => value.startsWith('loudnorm='))).toContain(
|
||||
'measured_I=-19.7'
|
||||
);
|
||||
const peakAnalysis = buildPeakAnalysisPlan({
|
||||
jobId: 'peak',
|
||||
source: SOURCE,
|
||||
audioStreamIndex: 1,
|
||||
preset: findBuiltInPreset('audio-m4a-aac')!,
|
||||
});
|
||||
expect(
|
||||
peakAnalysis.args.find((value) => value.includes('volumedetect'))
|
||||
).toContain('aformat=channel_layouts=stereo');
|
||||
const peakMeasurement = parsePeakMeasurement('max_volume: -6.0 dB');
|
||||
const peak = buildPeakNormalizationPlan({
|
||||
jobId: 'peak',
|
||||
source: SOURCE,
|
||||
audioStreamIndex: 1,
|
||||
targetPeakDb: -1,
|
||||
measurement: peakMeasurement,
|
||||
preset: findBuiltInPreset('audio-m4a-aac')!,
|
||||
});
|
||||
expect(peak.args.find((value) => value.includes('volume='))).toContain(
|
||||
'volume=1.778'
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies text and bitmap subtitles and blocks bitmap-to-text extraction', () => {
|
||||
expect(classifySubtitleCodec('subrip')).toBe('text');
|
||||
expect(classifySubtitleCodec('hdmv_pgs_subtitle')).toBe('bitmap');
|
||||
expect(() =>
|
||||
buildSubtitleExtractionPlan({
|
||||
jobId: 'sub',
|
||||
source: SOURCE,
|
||||
stream: { index: 2, codec: 'hdmv_pgs_subtitle' },
|
||||
outputFormat: 'srt',
|
||||
})
|
||||
).toThrow(/bitmap/iu);
|
||||
});
|
||||
|
||||
it('converts text subtitles for target containers and safely builds burn-in', () => {
|
||||
const subtitle = {
|
||||
id: 'subtitle',
|
||||
sourceIndex: 1,
|
||||
fileName: "my:sub's.srt",
|
||||
extension: 'srt',
|
||||
} as const;
|
||||
const mux = buildSubtitleMuxPlan({
|
||||
jobId: 'mux',
|
||||
source: SOURCE,
|
||||
subtitle,
|
||||
subtitleCodec: 'subrip',
|
||||
sourceStreams: [
|
||||
{ index: 0, kind: 'video', codec: 'h264' },
|
||||
{ index: 1, kind: 'audio', codec: 'aac' },
|
||||
],
|
||||
targetContainer: 'mp4',
|
||||
targetExtension: 'mp4',
|
||||
language: 'eng',
|
||||
default: true,
|
||||
});
|
||||
expect(mux.args).toContain('mov_text');
|
||||
const burn = buildSubtitleBurnPlan({
|
||||
jobId: 'burn',
|
||||
source: SOURCE,
|
||||
subtitle,
|
||||
preset: findBuiltInPreset('mp4-h264-balanced')!,
|
||||
});
|
||||
expect(burn.inputs).toHaveLength(2);
|
||||
expect(burn.args.find((value) => value.startsWith('subtitles='))).toContain(
|
||||
'/input/job-burn/source-1.srt'
|
||||
);
|
||||
expect(escapeFilterValue("/path/a:b,c's.ass")).toBe(
|
||||
"/path/a\\:b\\,c\\'s.ass"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waveform plans', () => {
|
||||
it('builds a bounded static derivative for an absolute audio stream', () => {
|
||||
const plan = buildStaticWaveformPlan({
|
||||
jobId: 'wave',
|
||||
source: SOURCE,
|
||||
audioStreamIndex: 1,
|
||||
width: 640,
|
||||
height: 160,
|
||||
});
|
||||
expect(plan.args.join(' ')).toContain(
|
||||
'[0:1]aformat=channel_layouts=mono,showwavespic=s=640x160'
|
||||
);
|
||||
expect(plan.outputs[0]).toMatchObject({
|
||||
role: 'analysis',
|
||||
mediaType: 'image/png',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbnail and contact sheet plans', () => {
|
||||
it('supports explicit stretched single-frame dimensions and quality', () => {
|
||||
const plan = buildSingleThumbnailPlan({
|
||||
jobId: 'single-thumb',
|
||||
source: SOURCE,
|
||||
timeSeconds: 1.25,
|
||||
size: { width: 640, height: 360, maintainAspect: false },
|
||||
format: 'jpeg',
|
||||
quality: 85,
|
||||
});
|
||||
expect(plan.args).toContain('scale=640:360');
|
||||
expect(plan.args).not.toContain(
|
||||
'scale=640:360:force_original_aspect_ratio=decrease'
|
||||
);
|
||||
expect(plan.args).toEqual(
|
||||
expect.arrayContaining(['-q:v', '6', '-frames:v', '1'])
|
||||
);
|
||||
});
|
||||
|
||||
it('generates ordered unique times from interval, count, and explicit selections', () => {
|
||||
expect(
|
||||
resolveThumbnailTimes({ durationSeconds: 10, intervalSeconds: 4 })
|
||||
).toEqual([0, 4, 8]);
|
||||
expect(resolveThumbnailTimes({ durationSeconds: 10, count: 3 })).toEqual([
|
||||
2.5, 5, 7.5,
|
||||
]);
|
||||
expect(
|
||||
resolveThumbnailTimes({ durationSeconds: 10, timesSeconds: [5, 1, 5] })
|
||||
).toEqual([1, 5]);
|
||||
});
|
||||
|
||||
it('builds one invocation for a series and declares every output', () => {
|
||||
const plan = buildThumbnailSeriesPlan({
|
||||
jobId: 'thumbs',
|
||||
source: SOURCE,
|
||||
durationSeconds: 10,
|
||||
count: 3,
|
||||
format: 'png',
|
||||
size: { width: 320 },
|
||||
});
|
||||
expect(plan.outputs).toHaveLength(3);
|
||||
expect(plan.args.filter((value) => value === '-i')).toHaveLength(1);
|
||||
expect(plan.args).toContain('-filter_complex');
|
||||
expect(plan.args.filter((value) => value === '-frames:v')).toHaveLength(3);
|
||||
for (const output of plan.outputs) {
|
||||
expect(plan.args).toContain(output.path);
|
||||
}
|
||||
expect(plan.args.some((value) => value.includes('%03d'))).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades contact-sheet labels when drawtext is unavailable', () => {
|
||||
const plan = buildContactSheetPlan({
|
||||
jobId: 'sheet',
|
||||
source: SOURCE,
|
||||
durationSeconds: 10,
|
||||
frameCount: 6,
|
||||
rows: 2,
|
||||
columns: 3,
|
||||
cellWidth: 240,
|
||||
timestampLabels: true,
|
||||
format: 'jpeg',
|
||||
drawtextAvailable: false,
|
||||
});
|
||||
expect(plan.diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'labels-unavailable',
|
||||
severity: 'warning',
|
||||
})
|
||||
);
|
||||
expect(plan.requiredCapabilities.filters).not.toContain('drawtext');
|
||||
});
|
||||
});
|
||||
|
||||
describe('composed visual operations', () => {
|
||||
it('creates palette-based GIF conversion instead of a raw encoder pass', () => {
|
||||
const plan = buildConvertPlan({
|
||||
jobId: 'gif',
|
||||
source: SOURCE,
|
||||
preset: findBuiltInPreset('animated-gif')!,
|
||||
});
|
||||
expect(plan.args).toContain('-filter_complex');
|
||||
expect(plan.args.find((value) => value.includes('palettegen'))).toContain(
|
||||
'paletteuse'
|
||||
);
|
||||
expect(plan.args).not.toContain('-vf');
|
||||
});
|
||||
|
||||
it('wraps transforms and A/V fades into executable conversion plans', () => {
|
||||
const preset = findBuiltInPreset('mp4-h264-balanced')!;
|
||||
const transformed = buildTransformPlan({
|
||||
jobId: 'transform',
|
||||
source: SOURCE,
|
||||
preset,
|
||||
settings: {
|
||||
crop: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 640,
|
||||
height: 360,
|
||||
sourceWidth: 1280,
|
||||
sourceHeight: 720,
|
||||
requireEvenDimensions: true,
|
||||
},
|
||||
frameRate: 25,
|
||||
},
|
||||
});
|
||||
expect(transformed.args).toContain('crop=640:360:0:0,fps=25');
|
||||
const faded = buildFadePlan({
|
||||
jobId: 'fade',
|
||||
source: SOURCE,
|
||||
preset,
|
||||
durationSeconds: 10,
|
||||
audioFadeInSeconds: 1,
|
||||
videoFadeOutSeconds: 2,
|
||||
audioCurve: 'qsin',
|
||||
videoCurve: 'exp',
|
||||
});
|
||||
expect(faded.args.find((value) => value.startsWith('afade='))).toContain(
|
||||
'curve=qsin'
|
||||
);
|
||||
expect(faded.args.find((value) => value.startsWith('geq='))).toContain(
|
||||
'exp(5*'
|
||||
);
|
||||
expect(faded.requiredCapabilities.filters).toContain('geq');
|
||||
expect(() =>
|
||||
buildFadePlan({
|
||||
jobId: 'overlap',
|
||||
source: SOURCE,
|
||||
preset,
|
||||
durationSeconds: 2,
|
||||
audioFadeInSeconds: 1.5,
|
||||
audioFadeOutSeconds: 1,
|
||||
})
|
||||
).toThrow(/overlap/u);
|
||||
expect(() =>
|
||||
buildFadePlan({
|
||||
jobId: 'unsupported-curve',
|
||||
source: SOURCE,
|
||||
preset,
|
||||
durationSeconds: 2,
|
||||
audioFadeInSeconds: 1,
|
||||
audioCurve: 'log' as 'tri',
|
||||
})
|
||||
).toThrow(/tri, qsin, or exp/u);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user