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(); const titles = new Set(); 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 { return typeof value === 'object' && value !== null && !Array.isArray(value); }