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

366
src/commands/chapters.ts Normal file
View File

@@ -0,0 +1,366 @@
import type { SourceReference } from './command-utils';
import {
chapterSecondsToTicks,
DEFAULT_CHAPTER_TIME_BASE,
parseChapterTimeBase,
} from '../media/chapter-time-base';
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
workVirtualPath,
} from './command-utils';
import {
freezeCommandPlan,
type CommandDiagnostic,
type FFmpegCommandPlan,
} from './command-plan';
import { validateSplitMarkers } from './split';
export interface EditableChapter {
readonly id: string;
readonly title: string;
readonly startSeconds: number;
readonly endSeconds: number;
readonly timeBase?: string;
}
export interface ChapterValidationOptions {
readonly durationSeconds?: number;
readonly allowOverlap?: boolean;
}
export interface ChapterDocument {
readonly schemaVersion: 1;
readonly chapters: readonly EditableChapter[];
}
export function createChaptersFromMarkers(
markers: readonly number[],
durationSeconds: number,
titles: readonly string[] = []
): readonly EditableChapter[] {
const sorted = validateSplitMarkers(markers, durationSeconds);
const boundaries = [0, ...sorted, durationSeconds];
return Object.freeze(
boundaries.slice(0, -1).map((startSeconds, index) =>
Object.freeze({
id: `chapter-${String(index + 1).padStart(3, '0')}`,
title: titles[index]?.trim() || `Chapter ${index + 1}`,
startSeconds,
endSeconds: boundaries[index + 1] as number,
timeBase: '1/1000',
})
)
);
}
export function createChaptersAtIntervals(
durationSeconds: number,
intervalSeconds: number
): readonly EditableChapter[] {
if (
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0 ||
!Number.isFinite(intervalSeconds) ||
intervalSeconds <= 0
) {
throw new RangeError('Chapter duration and interval must be positive');
}
const markers: number[] = [];
for (
let marker = intervalSeconds;
marker < durationSeconds;
marker += intervalSeconds
) {
markers.push(marker);
if (markers.length > 10_000) {
throw new RangeError('Fixed interval would create too many chapters');
}
}
return createChaptersFromMarkers(markers, durationSeconds);
}
export function validateChapters(
chapters: readonly EditableChapter[],
options: ChapterValidationOptions = {}
): readonly CommandDiagnostic[] {
const diagnostics: CommandDiagnostic[] = [];
const ids = new Set<string>();
const titles = new Set<string>();
let previousEnd = 0;
chapters.forEach((chapter, index) => {
if (!chapter.id || ids.has(chapter.id)) {
diagnostics.push(
diagnostic(
'chapter-id',
'error',
'Chapter IDs must be nonempty and unique.',
`${index}.id`
)
);
}
ids.add(chapter.id);
if (!chapter.title.trim()) {
diagnostics.push(
diagnostic(
'chapter-title',
'error',
'Chapter title is required.',
`${index}.title`
)
);
} else if (titles.has(chapter.title.trim())) {
diagnostics.push(
diagnostic(
'duplicate-chapter-title',
'warning',
`Duplicate chapter title: ${chapter.title.trim()}`,
`${index}.title`
)
);
}
titles.add(chapter.title.trim());
if (
!Number.isFinite(chapter.startSeconds) ||
!Number.isFinite(chapter.endSeconds) ||
chapter.startSeconds < 0 ||
chapter.endSeconds <= chapter.startSeconds
) {
diagnostics.push(
diagnostic(
'chapter-range',
'error',
'Chapter start must be nonnegative and before its end.',
`${index}.startSeconds`
)
);
}
const timeBase = parseChapterTimeBase(
chapter.timeBase ?? DEFAULT_CHAPTER_TIME_BASE
);
if (timeBase === undefined) {
diagnostics.push(
diagnostic(
'chapter-time-base',
'error',
'Chapter time base must be a positive integer fraction such as 1/1000.',
`${index}.timeBase`
)
);
} else {
const startTick = chapterSecondsToTicks(chapter.startSeconds, timeBase);
const endTick = chapterSecondsToTicks(chapter.endSeconds, timeBase);
if (
startTick === undefined ||
endTick === undefined ||
endTick <= startTick
) {
diagnostics.push(
diagnostic(
'chapter-time-base-resolution',
'error',
'Chapter time base is too coarse or large for this chapter range.',
`${index}.timeBase`
)
);
}
}
if (
options.durationSeconds !== undefined &&
chapter.endSeconds > options.durationSeconds
) {
diagnostics.push(
diagnostic(
'chapter-bounds',
'error',
'Chapter extends beyond output duration.',
`${index}.endSeconds`
)
);
}
if (chapter.startSeconds < previousEnd) {
diagnostics.push(
diagnostic(
options.allowOverlap
? 'chapter-overlap'
: 'chapter-overlap-forbidden',
options.allowOverlap ? 'warning' : 'error',
'Chapter overlaps or is out of chronological order.',
`${index}.startSeconds`
)
);
}
previousEnd = Math.max(previousEnd, chapter.endSeconds);
});
return Object.freeze(diagnostics);
}
export function escapeFFMetadata(value: string): string {
if (value.includes('\0')) {
throw new TypeError('FFMETADATA values cannot contain NUL');
}
return value
.replace(/\\/gu, '\\\\')
.replace(/\r\n?|\n/gu, '\\\n')
.replace(/([=;#])/gu, '\\$1');
}
export function serializeFFMetadata(
chapters: readonly EditableChapter[],
options: ChapterValidationOptions = {}
): string {
const diagnostics = validateChapters(chapters, options);
const errors = diagnostics.filter((entry) => entry.severity === 'error');
if (errors.length > 0) {
throw new RangeError(errors.map((entry) => entry.message).join('; '));
}
const lines = [';FFMETADATA1'];
for (const chapter of chapters) {
const timeBase = parseChapterTimeBase(
chapter.timeBase ?? DEFAULT_CHAPTER_TIME_BASE
);
if (timeBase === undefined) {
throw new TypeError('Chapter time base failed validation');
}
const startTick = chapterSecondsToTicks(chapter.startSeconds, timeBase);
const endTick = chapterSecondsToTicks(chapter.endSeconds, timeBase);
if (startTick === undefined || endTick === undefined) {
throw new RangeError('Chapter time cannot be represented safely');
}
lines.push(
'[CHAPTER]',
`TIMEBASE=${timeBase.normalized}`,
`START=${startTick}`,
`END=${endTick}`,
`title=${escapeFFMetadata(chapter.title)}`
);
}
return `${lines.join('\n')}\n`;
}
export function serializeChapterJson(
chapters: readonly EditableChapter[]
): string {
const document: ChapterDocument = { schemaVersion: 1, chapters };
const errors = validateChapters(chapters).filter(
(entry) => entry.severity === 'error'
);
if (errors.length > 0) {
throw new TypeError(errors.map((entry) => entry.message).join('; '));
}
return `${JSON.stringify(document, null, 2)}\n`;
}
export function parseChapterJson(json: string): ChapterDocument {
let value: unknown;
try {
value = JSON.parse(json) as unknown;
} catch {
throw new TypeError('Chapter JSON is malformed');
}
if (
!isRecord(value) ||
value.schemaVersion !== 1 ||
!Array.isArray(value.chapters)
) {
throw new TypeError('Chapter document must use schemaVersion 1');
}
const chapters = value.chapters.map((entry, index) =>
parseChapter(entry, index)
);
const errors = validateChapters(chapters).filter(
(entry) => entry.severity === 'error'
);
if (errors.length > 0) {
throw new TypeError(errors.map((entry) => entry.message).join('; '));
}
return Object.freeze({ schemaVersion: 1, chapters: Object.freeze(chapters) });
}
export interface ChapterMuxOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly chapters: readonly EditableChapter[];
readonly targetExtension: string;
readonly targetMuxer: string;
readonly durationSeconds?: number;
}
export function buildChapterMuxPlan(
options: ChapterMuxOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
const metadataContent = serializeFFMetadata(options.chapters, {
durationSeconds: options.durationSeconds,
});
const input = createPlannedInput(options.jobId, options.source);
const metadataPath = workVirtualPath(options.jobId, 'chapters.ffmeta');
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'chapters',
extension: options.targetExtension,
});
return freezeCommandPlan({
id: `${options.jobId}:chapters`,
operation: 'chapters',
inputs: [input],
temporaryFiles: [
{ path: metadataPath, content: metadataContent, purpose: 'metadata' },
],
args: [
'-i',
input.path,
'-i',
metadataPath,
'-map',
'0',
'-map_metadata',
'0',
'-map_chapters',
'1',
'-c',
'copy',
output.path,
],
outputs: [output],
...(options.durationSeconds !== undefined
? { expectedDurationSeconds: options.durationSeconds }
: {}),
requiredCapabilities: { muxers: [options.targetMuxer] },
diagnostics: [
diagnostic(
'chapter-round-trip',
'info',
'Re-probe the output to verify chapter count, times, and titles.'
),
],
});
}
function parseChapter(value: unknown, index: number): EditableChapter {
if (!isRecord(value)) {
throw new TypeError(`chapters.${index} must be an object`);
}
if (
typeof value.id !== 'string' ||
typeof value.title !== 'string' ||
typeof value.startSeconds !== 'number' ||
typeof value.endSeconds !== 'number' ||
(value.timeBase !== undefined && typeof value.timeBase !== 'string')
) {
throw new TypeError(`chapters.${index} has invalid fields`);
}
return {
id: value.id,
title: value.title,
startSeconds: value.startSeconds,
endSeconds: value.endSeconds,
...(typeof value.timeBase === 'string' ? { timeBase: value.timeBase } : {}),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}