Files
av-tools/src/commands/fades.ts

123 lines
3.2 KiB
TypeScript

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`
);
}
}