feat: publish av-tools 0.1.0
This commit is contained in:
366
src/commands/chapters.ts
Normal file
366
src/commands/chapters.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user