564 lines
16 KiB
TypeScript
564 lines
16 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
buildContactSheetPlan,
|
|
buildConvertPlan,
|
|
buildFadePlan,
|
|
buildLoudnessAnalysisPlan,
|
|
buildLoudnessApplyPlan,
|
|
buildMetadataEditPlan,
|
|
buildPeakAnalysisPlan,
|
|
buildPeakNormalizationFilter,
|
|
buildPeakNormalizationPlan,
|
|
buildSceneChangeAnalysisPlan,
|
|
buildSceneThumbnailSeriesPlan,
|
|
buildSubtitleBurnPlan,
|
|
buildSubtitleExtractionPlan,
|
|
buildSubtitleMuxPlan,
|
|
buildStaticWaveformPlan,
|
|
buildSingleThumbnailPlan,
|
|
buildThumbnailSeriesPlan,
|
|
buildTransformPlan,
|
|
classifySubtitleCodec,
|
|
createChaptersAtIntervals,
|
|
createChaptersFromMarkers,
|
|
escapeFFMetadata,
|
|
escapeFilterValue,
|
|
metadataArguments,
|
|
parseChapterJson,
|
|
parseLoudnessMeasurement,
|
|
parsePeakMeasurement,
|
|
parseSceneChangeFrames,
|
|
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('analyzes a bounded number of scene changes and parses exact timestamps', () => {
|
|
const plan = buildSceneChangeAnalysisPlan({
|
|
jobId: 'scenes',
|
|
source: SOURCE,
|
|
durationSeconds: 10,
|
|
threshold: 0.35,
|
|
maxFrames: 12,
|
|
});
|
|
expect(plan.outputs).toEqual([]);
|
|
expect(plan.args).toEqual(
|
|
expect.arrayContaining([
|
|
"select='gt(scene\\,0.35)',showinfo",
|
|
'-frames:v',
|
|
'12',
|
|
'-f',
|
|
'null',
|
|
])
|
|
);
|
|
expect(plan.requiredCapabilities.filters).toEqual(['select', 'showinfo']);
|
|
expect(
|
|
parseSceneChangeFrames(`
|
|
[Parsed_showinfo_1 @ 0x1] n: 0 pts: 4096 pts_time:0.25 duration:512
|
|
n: 1 pts: 16384 pts_time:1 duration:512
|
|
[Parsed_showinfo_1 @ 0x1] n: 2 pts: 16384 pts_time:1 duration:512
|
|
invalid showinfo n: 3 pts: nope pts_time:2
|
|
`)
|
|
).toEqual([
|
|
{ pts: 4096, timeSeconds: 0.25 },
|
|
{ pts: 16384, timeSeconds: 1 },
|
|
]);
|
|
});
|
|
|
|
it('builds exact scene-selected thumbnails from analyzed PTS values', () => {
|
|
const plan = buildSceneThumbnailSeriesPlan({
|
|
jobId: 'scene-thumbs',
|
|
source: SOURCE,
|
|
durationSeconds: 10,
|
|
frames: [
|
|
{ pts: 4096, timeSeconds: 0.25 },
|
|
{ pts: 16384, timeSeconds: 1 },
|
|
],
|
|
format: 'png',
|
|
size: { width: 320 },
|
|
});
|
|
expect(plan.outputs.map((output) => output.fileName)).toEqual([
|
|
'source-thumbnail-001-250ms.png',
|
|
'source-thumbnail-002-1000ms.png',
|
|
]);
|
|
expect(plan.args.join(' ')).toContain(
|
|
"select='eq(pts\\,4096)+eq(pts\\,16384)',split=2"
|
|
);
|
|
expect(plan.requiredCapabilities.filters).toEqual([
|
|
'select',
|
|
'split',
|
|
'setpts',
|
|
'scale',
|
|
]);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('uses a mounted reviewed font and exact scene frames for labels', () => {
|
|
const plan = buildContactSheetPlan({
|
|
jobId: 'scene-sheet',
|
|
source: SOURCE,
|
|
durationSeconds: 10,
|
|
frameCount: 2,
|
|
rows: 1,
|
|
columns: 2,
|
|
cellWidth: 240,
|
|
timestampLabels: true,
|
|
sourceLabel: true,
|
|
format: 'jpeg',
|
|
drawtextAvailable: true,
|
|
font: {
|
|
id: 'bundled-font',
|
|
sourceIndex: 1,
|
|
fileName: 'DejaVuSans.ttf',
|
|
},
|
|
sceneFrames: [
|
|
{ pts: 4096, timeSeconds: 0.25 },
|
|
{ pts: 16384, timeSeconds: 1 },
|
|
],
|
|
});
|
|
expect(plan.inputs[1]).toMatchObject({
|
|
kind: 'font',
|
|
sourceIndex: 1,
|
|
});
|
|
expect(plan.args.join(' ')).toContain(
|
|
"select='eq(pts\\,4096)+eq(pts\\,16384)'"
|
|
);
|
|
expect(plan.args.join(' ')).toContain(
|
|
"drawtext=fontfile='/input/job-scene-sheet/source-1.ttf'"
|
|
);
|
|
expect(plan.args.join(' ')).toContain('nb_frames=2');
|
|
expect(plan.requiredCapabilities.filters).toEqual([
|
|
'select',
|
|
'scale',
|
|
'setpts',
|
|
'tile',
|
|
'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);
|
|
});
|
|
});
|