← Files Cecil-IA Labs FFmpegARCHIVED FILE

dist/conversion/profiles.js

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

↓ Download file

import path from "node:path";
import { ToolkitRuntimeError } from "../core/errors.js";
import { hardwareFilterSuffix, hardwareGlobalArgs, hardwareVideoEncodingArgs, } from "../hardware/index.js";
import { buildFitFilters } from "../media/fit.js";
const FORMAT_EXTENSIONS = {
    mp4: [".mp4", ".m4v", ".mov"],
    webm: [".webm"],
    gif: [".gif"],
    webp: [".webp"],
    png: [".png"],
    jpeg: [".jpg", ".jpeg"],
    wav: [".wav"],
    mp3: [".mp3"],
    aac: [".aac"],
    m4a: [".m4a"],
    flac: [".flac"],
    opus: [".opus"],
    ogg: [".ogg", ".oga"],
};
const TARGET_EXTENSION = {
    mp4: ".mp4",
    webm: ".webm",
    gif: ".gif",
    webp: ".webp",
    png: ".png",
    jpeg: ".jpg",
    wav: ".wav",
    mp3: ".mp3",
    aac: ".aac",
    m4a: ".m4a",
    flac: ".flac",
    opus: ".opus",
    ogg: ".ogg",
};
const VISUAL_TARGETS = new Set(["mp4", "webm", "gif", "webp", "png", "jpeg"]);
const AUDIO_TARGETS = new Set(["wav", "mp3", "aac", "m4a", "flac", "opus", "ogg"]);
export function targetExtension(format) {
    return TARGET_EXTENSION[format];
}
export function normalizeConversionFormat(value) {
    const normalized = value.toLowerCase();
    if (normalized === "jpg")
        return "jpeg";
    return normalized in TARGET_EXTENSION ? normalized : undefined;
}
export function inferConversionFormat(file) {
    const extension = path.extname(file).toLowerCase();
    for (const [format, extensions] of Object.entries(FORMAT_EXTENSIONS)) {
        if (extensions.includes(extension))
            return format;
    }
    return undefined;
}
export function isExtensionForFormat(file, format) {
    return FORMAT_EXTENSIONS[format].includes(path.extname(file).toLowerCase());
}
export function assertSupportedConversion(from, to) {
    if (from === to) {
        throw new ToolkitRuntimeError("E_OPERATION_UNSUPPORTED", `Source and target formats are both ${to}.`);
    }
    if (!VISUAL_TARGETS.has(to) && !AUDIO_TARGETS.has(to)) {
        throw new ToolkitRuntimeError("E_OPERATION_UNSUPPORTED", `Unsupported conversion target: ${to}.`);
    }
}
function requireVisualStream(media, source) {
    if (media.video.length === 0) {
        throw new ToolkitRuntimeError("E_MEDIA_NO_MATCHING_STREAM", "Conversion requires a video/image stream.", {
            details: { source },
        });
    }
}
function requireAudioStream(media, source) {
    if (media.audio.length === 0) {
        throw new ToolkitRuntimeError("E_MEDIA_NO_MATCHING_STREAM", "Audio conversion requires an audio stream.", {
            details: { source },
        });
    }
}
function positiveInteger(value, fallback, label) {
    const resolved = value ?? fallback;
    if (!Number.isInteger(resolved) || resolved <= 0) {
        throw new ToolkitRuntimeError("E_USAGE_INVALID_ARGUMENT", `${label} must be a positive integer.`, {
            details: { [label]: value },
        });
    }
    return resolved;
}
function boundedInteger(value, fallback, min, max, label) {
    const resolved = value ?? fallback;
    if (!Number.isInteger(resolved) || resolved < min || resolved > max) {
        throw new ToolkitRuntimeError("E_USAGE_INVALID_ARGUMENT", `${label} must be an integer from ${min} to ${max}.`, {
            details: { [label]: value, min, max },
        });
    }
    return resolved;
}
function videoFilter(options, defaultFps) {
    const filters = [];
    const fps = options.fps ?? defaultFps;
    if (fps !== undefined)
        filters.push(`fps=${positiveInteger(fps, fps, "fps")}`);
    if (options.width !== undefined && options.height !== undefined) {
        filters.push(...buildFitFilters({
            width: positiveInteger(options.width, options.width, "width"),
            height: positiveInteger(options.height, options.height, "height"),
            ...(options.fit !== undefined ? { fit: options.fit } : {}),
            ...(options.background !== undefined ? { background: options.background } : {}),
        }));
    }
    else if (options.width !== undefined) {
        const width = positiveInteger(options.width, options.width, "width");
        filters.push(`scale=${width}:-2:flags=lanczos`);
    }
    else if (options.height !== undefined) {
        const height = positiveInteger(options.height, options.height, "height");
        filters.push(`scale=-2:${height}:flags=lanczos`);
    }
    return filters.length > 0 ? filters.join(",") : undefined;
}
function droppedAudioWarning(media, target) {
    if (media.audio.length === 0)
        return [];
    if (!["gif", "webp", "png", "jpeg"].includes(target))
        return [];
    return [{
            code: "W_CONVERSION_AUDIO_DROPPED",
            message: `${target.toUpperCase()} output does not preserve the input audio stream in this conversion profile.`,
            details: { target, audioStreams: media.audio.length },
        }];
}
function audioPlan(source, to, media, options) {
    requireAudioStream(media, source);
    const common = ["-i", source, "-map", "0:a:0", "-vn"];
    const sample = options.sampleRate !== undefined ? ["-ar", String(positiveInteger(options.sampleRate, options.sampleRate, "sampleRate"))] : [];
    const channels = options.channels !== undefined ? ["-ac", String(positiveInteger(options.channels, options.channels, "channels"))] : [];
    const bitrate = options.audioBitrate ?? "192k";
    const profile = {
        wav: ["-c:a", "pcm_s16le"],
        mp3: ["-c:a", "libmp3lame", "-b:a", bitrate],
        aac: ["-c:a", "aac", "-b:a", bitrate, "-f", "adts"],
        m4a: ["-c:a", "aac", "-b:a", bitrate, "-f", "ipod"],
        flac: ["-c:a", "flac"],
        opus: ["-c:a", "libopus", "-b:a", options.audioBitrate ?? "128k", "-f", "opus"],
        ogg: ["-c:a", "libopus", "-b:a", options.audioBitrate ?? "128k", "-f", "ogg"],
    };
    return {
        argsBeforeOutput: [...common, ...sample, ...channels, ...(profile[to] ?? [])],
        warnings: media.video.length > 0 ? [{
                code: "W_CONVERSION_VIDEO_DROPPED",
                message: "Audio-only target drops the input video stream.",
                details: { target: to, videoStreams: media.video.length },
            }] : [],
        details: { mediaType: "audio", target: to, audioBitrate: bitrate },
    };
}
function hardwareAwareFilter(filter, hardware) {
    const suffix = hardware === undefined ? [] : hardwareFilterSuffix(hardware);
    const parts = [...(filter ? [filter] : []), ...suffix];
    return parts.length > 0 ? parts.join(",") : undefined;
}
function hardwareWarnings(base, hardware) {
    return hardware?.warning === undefined ? base : [...base, hardware.warning];
}
function hardwareDetails(hardware) {
    return hardware === undefined ? {} : {
        hardware: {
            requested: hardware.requested,
            resolved: hardware.resolved,
            encoder: hardware.encoder,
            runtimeVerified: hardware.runtimeVerified,
            fallback: hardware.fallback,
            ...(hardware.device !== undefined ? { device: hardware.device } : {}),
            attempts: hardware.attempts,
        },
    };
}
export function buildConversionPlan(source, from, to, media, options = {}, hardware) {
    assertSupportedConversion(from, to);
    if (AUDIO_TARGETS.has(to))
        return audioPlan(source, to, media, options);
    requireVisualStream(media, source);
    const warnings = droppedAudioWarning(media, to);
    const vf = videoFilter(options);
    if (to === "mp4") {
        const hasAudio = media.audio.length > 0;
        const effectiveFilter = hardwareAwareFilter(vf, hardware);
        const videoArgs = hardware === undefined
            ? ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p"]
            : hardwareVideoEncodingArgs(hardware, { softwareCrf: 18, softwarePreset: "medium" });
        return {
            argsBeforeOutput: [
                ...(hardware === undefined ? [] : hardwareGlobalArgs(hardware)),
                "-i", source,
                "-map", "0:v:0",
                ...(hasAudio ? ["-map", "0:a:0?"] : []),
                ...(effectiveFilter ? ["-vf", effectiveFilter] : []),
                ...videoArgs,
                ...(hasAudio ? ["-c:a", "aac", "-b:a", options.audioBitrate ?? "192k"] : ["-an"]),
                "-movflags", "+faststart", "-map_metadata", "0",
            ],
            warnings: hardwareWarnings(warnings, hardware),
            details: {
                codec: hardware?.encoder ?? "libx264",
                audioCodec: hasAudio ? "aac" : null,
                ...(effectiveFilter ? { videoFilter: effectiveFilter } : {}),
                ...hardwareDetails(hardware),
            },
        };
    }
    if (to === "webm") {
        const hasAudio = media.audio.length > 0;
        const effectiveFilter = hardwareAwareFilter(vf, hardware);
        const videoArgs = hardware === undefined
            ? ["-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0", "-pix_fmt", "yuv420p"]
            : hardwareVideoEncodingArgs(hardware, { softwareCrf: 32 });
        return {
            argsBeforeOutput: [
                ...(hardware === undefined ? [] : hardwareGlobalArgs(hardware)),
                "-i", source,
                "-map", "0:v:0",
                ...(hasAudio ? ["-map", "0:a:0?"] : []),
                ...(effectiveFilter ? ["-vf", effectiveFilter] : []),
                ...videoArgs,
                ...(hasAudio ? ["-c:a", "libopus", "-b:a", options.audioBitrate ?? "128k"] : ["-an"]),
                "-map_metadata", "0",
            ],
            warnings: hardwareWarnings(warnings, hardware),
            details: {
                codec: hardware?.encoder ?? "libvpx-vp9",
                audioCodec: hasAudio ? "libopus" : null,
                ...(effectiveFilter ? { videoFilter: effectiveFilter } : {}),
                ...hardwareDetails(hardware),
            },
        };
    }
    if (to === "gif") {
        const fps = positiveInteger(options.fps, 10, "fps");
        const maxColors = boundedInteger(options.maxColors, 256, 2, 256, "maxColors");
        const loop = boundedInteger(options.loop, 0, 0, 65535, "loop");
        const pre = [`fps=${fps}`, ...(vf ? [vf] : [])].join(",");
        const filter = `[0:v]${pre},split[v1][v2];[v1]palettegen=max_colors=${maxColors}[p];[v2][p]paletteuse=dither=sierra2_4a[v]`;
        return {
            argsBeforeOutput: ["-i", source, "-filter_complex", filter, "-map", "[v]", "-an", "-loop", String(loop)],
            warnings,
            details: { fps, maxColors, loop, palette: "generated-inline" },
        };
    }
    if (to === "webp") {
        const fps = positiveInteger(options.fps, 10, "fps");
        const quality = boundedInteger(options.quality, 80, 0, 100, "quality");
        const loop = boundedInteger(options.loop, 0, 0, 65535, "loop");
        const webpFilter = videoFilter({ ...options, fps }, fps);
        return {
            argsBeforeOutput: [
                "-i", source, ...(webpFilter ? ["-vf", webpFilter] : []), "-an",
                "-c:v", "libwebp", "-quality", String(quality), "-compression_level", "4",
                "-loop", String(loop), "-f", "webp",
            ],
            warnings,
            details: { codec: "libwebp", fps, quality, loop, ...(webpFilter ? { videoFilter: webpFilter } : {}) },
        };
    }
    if (to === "png" || to === "jpeg") {
        return {
            argsBeforeOutput: [
                "-i", source, "-map", "0:v:0", ...(vf ? ["-vf", vf] : []),
                "-frames:v", "1", "-an",
                ...(to === "png" ? ["-c:v", "png"] : ["-c:v", "mjpeg", "-q:v", "2"]),
            ],
            warnings,
            details: { codec: to === "png" ? "png" : "mjpeg", frameSelection: "first", ...(vf ? { videoFilter: vf } : {}) },
        };
    }
    throw new ToolkitRuntimeError("E_OPERATION_UNSUPPORTED", `No conversion profile is defined for ${from} -> ${to}.`);
}
//# sourceMappingURL=profiles.js.map

SHA-256: 21858cef70d0a4c3e6844df72cbaebaa7df79273127d44a87b463586151320a6