import type { ExportPreset } from './convert'; import { buildConvertPlan } from './convert'; import type { SourceReference } from './command-utils'; import type { FFmpegCommandPlan } from './command-plan'; import { audioFadeFilters, videoFadeFilters, type FadeCurve, type AudioFilterStage, type FilterNode, type VideoFilterStage, } from './filtergraph-builder'; export { audioFadeFilters, videoFadeFilters, type AudioFilterStage, type FilterNode, type VideoFilterStage, }; export interface FadePlanOptions { readonly jobId: string; readonly source: SourceReference; readonly preset: ExportPreset; readonly durationSeconds: number; readonly audioFadeInSeconds?: number; readonly audioFadeOutSeconds?: number; readonly videoFadeInSeconds?: number; readonly videoFadeOutSeconds?: number; readonly audioCurve?: FadeCurve; readonly videoCurve?: FadeCurve; } export function buildFadePlan(options: FadePlanOptions): FFmpegCommandPlan { if ( !Number.isFinite(options.durationSeconds) || options.durationSeconds <= 0 ) { throw new RangeError('Fade clip duration must be positive'); } validatePair( options.durationSeconds, options.audioFadeInSeconds, options.audioFadeOutSeconds, 'Audio' ); validatePair( options.durationSeconds, options.videoFadeInSeconds, options.videoFadeOutSeconds, 'Video' ); const audioNodes = audioFadeFilters( options.durationSeconds, options.audioFadeInSeconds, options.audioFadeOutSeconds, options.audioCurve ); const videoNodes = videoFadeFilters( options.durationSeconds, options.videoFadeInSeconds, options.videoFadeOutSeconds, options.videoCurve ); if (audioNodes.length > 0 && !options.preset.audio) { throw new TypeError('Audio fades require a preset with audio'); } if (videoNodes.length > 0 && !options.preset.video) { throw new TypeError('Video fades require a preset with video'); } if (audioNodes.length === 0 && videoNodes.length === 0) { throw new TypeError('At least one fade duration is required'); } return buildConvertPlan({ jobId: options.jobId, source: options.source, preset: options.preset, operation: 'fades', expectedDurationSeconds: options.durationSeconds, ...(audioNodes.length > 0 ? { audioFiltergraph: audioNodes.map((node) => node.expression).join(','), } : {}), ...(videoNodes.length > 0 ? { videoFiltergraph: videoNodes.map((node) => node.expression).join(','), } : {}), additionalRequirements: { filters: [ ...new Set( [...audioNodes, ...videoNodes].flatMap( (node) => node.requiredCapabilities?.filters ?? [] ) ), ], }, }); } function validatePair( durationSeconds: number, fadeInSeconds: number | undefined, fadeOutSeconds: number | undefined, label: string ): void { const fadeIn = fadeInSeconds ?? 0; const fadeOut = fadeOutSeconds ?? 0; if ( !Number.isFinite(fadeIn) || !Number.isFinite(fadeOut) || fadeIn < 0 || fadeOut < 0 || fadeIn + fadeOut > durationSeconds ) { throw new RangeError( `${label} fade durations must be nonnegative and cannot overlap` ); } }