← Files Cecil-IA Labs FFmpegARCHIVED FILE

dist/composition/concat.js

5.38 KB · Sep 30, 2026 · 23:17 UTC

↓ Download file

import { ToolkitRuntimeError } from "../core/errors.js";
import { encodingArgs, resolveEncodingProfile } from "../video/encoding.js";
import { FilterGraphBuilder } from "./filter-graph.js";
import { deriveCompositionOutput, durationSeconds, executeComposition, inspectCompositionInput, preflightCompositionOutput, requireVideoStreams, resolveCompositionFiles } from "./helpers.js";
import { AUDIO_NORMALIZATION_FILTERS, resolveVideoNormalization, videoNormalizationFilters } from "./normalization.js";
import { COMPOSITION_TRANSITIONS, xfadeFilter } from "./transitions.js";
function resolveAudioMode(requested, allHaveAudio) {
    const mode = requested ?? "auto";
    if (mode === "preserve" && !allHaveAudio) {
        throw new ToolkitRuntimeError("E_MEDIA_INCOMPATIBLE", "--audio preserve requires every input to contain audio.");
    }
    if (mode === "auto")
        return allHaveAudio ? "preserve" : "drop";
    return mode;
}
export async function concatMedia(inputs, request = {}) {
    const sources = await resolveCompositionFiles(inputs, request);
    const output = deriveCompositionOutput(sources[0], "concat", request.output, request.cwd, request.to ?? "mp4");
    await preflightCompositionOutput(sources, output, request.overwrite ?? false);
    const media = await Promise.all(sources.map(async (source) => await inspectCompositionInput(source, request)));
    requireVideoStreams(media, sources);
    const transition = request.transition ?? "none";
    if (transition !== "none" && !COMPOSITION_TRANSITIONS.has(transition)) {
        throw new ToolkitRuntimeError("E_USAGE_INVALID_ARGUMENT", `Unsupported transition: ${transition}`);
    }
    const transitionDuration = request.transitionDuration ?? 1;
    if (!Number.isFinite(transitionDuration) || transitionDuration <= 0) {
        throw new ToolkitRuntimeError("E_USAGE_INVALID_ARGUMENT", "transition duration must be positive.");
    }
    const durations = media.map((item, index) => durationSeconds(item, sources[index] ?? "unknown"));
    if (transition !== "none" && durations.some((duration) => duration <= transitionDuration)) {
        throw new ToolkitRuntimeError("E_OPERATION_INVALID_RANGE", "Every input must be longer than the transition duration.", {
            details: { durations, transitionDuration },
        });
    }
    const normalization = resolveVideoNormalization(media, request);
    const allHaveAudio = media.every((item) => item.audio.length > 0);
    const audioMode = resolveAudioMode(request.audio, allHaveAudio);
    const warnings = request.audio === undefined && !allHaveAudio
        ? [{ code: "W_COMPOSITION_AUDIO_DROPPED", message: "At least one input has no audio; auto mode produced video-only output." }]
        : [];
    const graph = new FilterGraphBuilder();
    sources.forEach((_source, index) => {
        graph.add([`${index}:v`], videoNormalizationFilters(normalization), `v${index}`);
        if (audioMode === "preserve")
            graph.add([`${index}:a`], AUDIO_NORMALIZATION_FILTERS, `a${index}`);
    });
    let videoOut = "v0";
    let audioOut = audioMode === "preserve" ? "a0" : undefined;
    let outputDuration = durations[0] ?? 0;
    for (let index = 1; index < sources.length; index += 1) {
        const nextDuration = durations[index] ?? 0;
        const nextVideo = `v${index}`;
        const nextAudio = `a${index}`;
        const currentVideoOut = `vx${index}`;
        const currentAudioOut = `ax${index}`;
        if (transition === "none") {
            // Defer concatenation until all normalized pads are available.
            continue;
        }
        const offset = outputDuration - transitionDuration;
        graph.addRaw(`[${videoOut}][${nextVideo}]${xfadeFilter(transition, transitionDuration, offset)}[${currentVideoOut}]`);
        videoOut = currentVideoOut;
        if (audioOut !== undefined) {
            graph.addRaw(`[${audioOut}][${nextAudio}]acrossfade=d=${transitionDuration}:c1=tri:c2=tri[${currentAudioOut}]`);
            audioOut = currentAudioOut;
        }
        outputDuration += nextDuration - transitionDuration;
    }
    if (transition === "none") {
        graph.addRaw(`${sources.map((_source, index) => `[v${index}]`).join("")}concat=n=${sources.length}:v=1:a=0[vout]`);
        videoOut = "vout";
        if (audioMode === "preserve") {
            graph.addRaw(`${sources.map((_source, index) => `[a${index}]`).join("")}concat=n=${sources.length}:v=0:a=1[aout]`);
            audioOut = "aout";
        }
        outputDuration = durations.reduce((sum, value) => sum + value, 0);
    }
    const encoding = resolveEncodingProfile(output);
    const args = [];
    for (const source of sources)
        args.push("-i", source);
    args.push("-filter_complex", graph.build(), "-map", `[${videoOut}]`);
    if (audioOut !== undefined)
        args.push("-map", `[${audioOut}]`);
    args.push(...encodingArgs(encoding, audioOut !== undefined), "-r", String(normalization.fps));
    return await executeComposition({
        operation: "concat",
        sources,
        output,
        argsBeforeOutput: args,
        runtime: request,
        inputMedia: media,
        warnings,
        details: {
            transition,
            transitionDuration: transition === "none" ? 0 : transitionDuration,
            audioMode,
            normalization,
            estimatedDuration: Number(outputDuration.toFixed(6)),
            inputCount: sources.length,
        },
    });
}
//# sourceMappingURL=concat.js.map

SHA-256: b24bab7a846d540ba80bd8aee983610a49727358b8c0db4bad3766bb64085e1d