123 lines
3.2 KiB
TypeScript
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`
|
|
);
|
|
}
|
|
}
|