← Files Cecil-IA Labs FFmpegARCHIVED FILE

dist/composition/slideshow.js

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

↓ Download file

import { readdir } from "node:fs/promises";
import path from "node:path";
import { ToolkitRuntimeError } from "../core/errors.js";
import { buildFitFilters } from "../media/fit.js";
import { preflightOutputPath } from "../media/io.js";
import { matchesAnyPattern } from "../conversion/patterns.js";
import { resolveEncodingProfile } from "../video/encoding.js";
import { FilterGraphBuilder } from "./filter-graph.js";
import { executeComposition } from "./helpers.js";
import { xfadeFilter } from "./transitions.js";
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp"]);
async function discoverImages(directory, recursive, includes, excludes) {
    const root = path.resolve(directory);
    const found = [];
    async function visit(current) {
        const entries = await readdir(current, { withFileTypes: true });
        entries.sort((left, right) => left.name.localeCompare(right.name, undefined, { numeric: true }));
        for (const entry of entries) {
            const absolute = path.join(current, entry.name);
            if (entry.isDirectory() && recursive) {
                await visit(absolute);
                continue;
            }
            if (!entry.isFile() || !IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
                continue;
            const relative = path.relative(root, absolute);
            if (includes.length > 0 && !matchesAnyPattern(relative, includes))
                continue;
            if (excludes.length > 0 && matchesAnyPattern(relative, excludes))
                continue;
            found.push(absolute);
        }
    }
    try {
        await visit(root);
    }
    catch (error) {
        throw new ToolkitRuntimeError("E_INPUT_UNREADABLE", `Unable to read slideshow directory: ${root}`, { cause: error });
    }
    return found;
}
function positiveInteger(value, fallback, name) {
    const resolved = value ?? fallback;
    if (!Number.isInteger(resolved) || resolved <= 0) {
        throw new ToolkitRuntimeError("E_USAGE_INVALID_ARGUMENT", `${name} must be a positive integer.`);
    }
    return resolved;
}
function positiveNumber(value, fallback, name) {
    const resolved = value ?? fallback;
    if (!Number.isFinite(resolved) || resolved <= 0) {
        throw new ToolkitRuntimeError("E_USAGE_INVALID_ARGUMENT", `${name} must be positive.`);
    }
    return resolved;
}
function safeColor(value) {
    if (!/^(?:[A-Za-z]+|#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?)$/.test(value)) {
        throw new ToolkitRuntimeError("E_USAGE_INVALID_ARGUMENT", "background must be a named color or #RRGGBB/#RRGGBBAA value.");
    }
    return value;
}
function outputPath(directory, request, to) {
    if (request.output !== undefined)
        return path.resolve(request.cwd ?? process.cwd(), request.output);
    return path.join(path.resolve(directory), `slideshow.${request.style ?? "vertical-stack"}.${to}`);
}
function outputEncodingArgs(format) {
    if (format === "mp4" || format === "webm") {
        const profile = resolveEncodingProfile(`output.${format}`);
        return [
            "-c:v", profile.videoCodec,
            ...profile.videoQualityArgs,
            ...profile.muxerArgs,
            "-pix_fmt", "yuv420p",
        ];
    }
    if (format === "webp") {
        return ["-c:v", "libwebp", "-quality", "82", "-compression_level", "4", "-loop", "0", "-f", "webp"];
    }
    return ["-c:v", "gif", "-loop", "0"];
}
function normalizeImageFilters(width, height, fps, request) {
    return [
        ...buildFitFilters({
            width,
            height,
            ...(request.fit !== undefined ? { fit: request.fit } : {}),
            ...(request.background !== undefined ? { background: request.background } : {}),
        }),
        "setsar=1",
        "setpts=PTS-STARTPTS",
        `fps=${fps}`,
        "format=yuv420p",
        "settb=AVTB",
    ];
}
function buildSequenceGraph(graph, images, width, height, fps, duration, request) {
    const transition = request.transition ?? "fade";
    const transitionDuration = transition === "none"
        ? 0
        : positiveNumber(request.transitionDuration, 0.75, "transitionDuration");
    const perSlide = transition === "none"
        ? duration / images.length
        : (duration + transitionDuration * (images.length - 1)) / images.length;
    if (transition !== "none" && perSlide <= transitionDuration) {
        throw new ToolkitRuntimeError("E_OPERATION_INVALID_RANGE", "Slideshow duration is too short for the selected transition duration.", { details: { duration, imageCount: images.length, transitionDuration, perSlide } });
    }
    images.forEach((_image, index) => {
        graph.add([`${index}:v`], normalizeImageFilters(width, height, fps, request), `slide${index}`);
    });
    if (transition === "none") {
        graph.addRaw(`${images.map((_image, index) => `[slide${index}]`).join("")}concat=n=${images.length}:v=1:a=0[vout]`);
        return { videoOut: "vout", perSlide, transitionDuration: 0 };
    }
    let videoOut = "slide0";
    let outputDuration = perSlide;
    for (let index = 1; index < images.length; index += 1) {
        const next = `slide${index}`;
        const current = `sx${index}`;
        const offset = outputDuration - transitionDuration;
        graph.addRaw(`[${videoOut}][${next}]${xfadeFilter(transition, transitionDuration, offset)}[${current}]`);
        videoOut = current;
        outputDuration += perSlide - transitionDuration;
    }
    return { videoOut, perSlide, transitionDuration };
}
export async function createSlideshow(directory, request = {}) {
    const style = request.style ?? "vertical-stack";
    const to = request.to ?? "mp4";
    const output = outputPath(directory, { ...request, style }, to);
    await preflightOutputPath({ output, overwrite: request.overwrite ?? false });
    const recursive = request.recursive ?? false;
    const includes = request.includes ?? [];
    const excludes = request.excludes ?? [];
    const images = await discoverImages(directory, recursive, includes, excludes);
    if (images.length < 2) {
        throw new ToolkitRuntimeError("E_BATCH_EMPTY_SELECTION", "Slideshow requires at least two selected images.", {
            details: { directory, discovered: images.length, includes, excludes },
        });
    }
    const width = positiveInteger(request.width, 1280, "width");
    const height = positiveInteger(request.height, 720, "height");
    const fps = positiveInteger(request.fps, 30, "fps");
    const duration = positiveNumber(request.duration, 10, "duration");
    const background = safeColor(request.background ?? "black");
    const graph = new FilterGraphBuilder();
    const args = [];
    let videoOut = "vout";
    let sequenceDetails = {};
    if (style === "sequence") {
        const built = buildSequenceGraph(graph, images, width, height, fps, duration, { ...request, background });
        videoOut = built.videoOut;
        sequenceDetails = {
            transition: request.transition ?? "fade",
            transitionDuration: built.transitionDuration,
            perSlide: built.perSlide,
        };
        for (const image of images) {
            args.push("-loop", "1", "-framerate", String(fps), "-t", String(built.perSlide), "-i", image);
        }
    }
    else {
        if (request.transition !== undefined && request.transition !== "none") {
            throw new ToolkitRuntimeError("E_CONFIG_CONFLICT", "Slideshow transitions require --style sequence; vertical-stack uses continuous scrolling.");
        }
        const direction = request.direction ?? "up";
        const includeIntro = request.includeIntro ?? true;
        const includeOutro = request.includeOutro ?? false;
        images.forEach((_image, index) => {
            graph.add([`${index}:v`], [
                ...buildFitFilters({
                    width,
                    height,
                    ...(request.fit !== undefined ? { fit: request.fit } : {}),
                    background,
                }),
                "setsar=1",
                `fps=${fps}`,
                "setpts=PTS-STARTPTS",
            ], `slide${index}`);
        });
        graph.addRaw(`color=c=${background}:s=${width}x${height}:r=${fps}:d=${duration}[bg]`);
        if (includeIntro)
            graph.addRaw(`color=c=${background}:s=${width}x${height}:r=${fps}:d=${duration}[intro]`);
        if (includeOutro)
            graph.addRaw(`color=c=${background}:s=${width}x${height}:r=${fps}:d=${duration}[outro]`);
        const stacked = [
            ...(includeIntro ? ["[intro]"] : []),
            ...images.map((_image, index) => `[slide${index}]`),
            ...(includeOutro ? ["[outro]"] : []),
        ];
        graph.addRaw(`${stacked.join("")}vstack=inputs=${stacked.length}[stack]`);
        const y = direction === "down"
            ? `-(overlay_h-${height})+(overlay_h-${height})*t/${duration}`
            : `-(overlay_h-${height})*t/${duration}`;
        graph.addRaw(`[bg][stack]overlay=x=0:y='${y}':shortest=1,trim=duration=${duration},format=yuv420p[vout]`);
        for (const image of images)
            args.push("-loop", "1", "-framerate", String(fps), "-i", image);
        sequenceDetails = { direction, includeIntro, includeOutro };
    }
    args.push("-filter_complex", graph.build(), "-map", `[${videoOut}]`, ...outputEncodingArgs(to), "-r", String(fps), "-an", "-t", String(duration));
    return await executeComposition({
        operation: "slideshow",
        sources: images,
        output,
        argsBeforeOutput: args,
        runtime: request,
        warnings: [],
        details: {
            style,
            imageCount: images.length,
            width,
            height,
            fps,
            duration,
            background,
            fit: request.fit ?? "contain",
            recursive,
            includes,
            excludes,
            to,
            ...sequenceDetails,
        },
    });
}
/** Compatibility alias for the Milestone 7 vertical-stack implementation. */
export async function createVerticalStackSlideshow(directory, request = {}) {
    return await createSlideshow(directory, { ...request, style: "vertical-stack" });
}
//# sourceMappingURL=slideshow.js.map

SHA-256: f95d9db22350b93b067f13533b0fbe9e34d2dfac202578236b0a9b0617e166b6