feat: publish av-tools 0.1.0

This commit is contained in:
2026-07-24 14:58:03 +02:00
commit fcc537b024
254 changed files with 63270 additions and 0 deletions

View 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);
});
});