← Files Cecil-IA Labs FFmpegARCHIVED FILE

dist/diagnostics/repair.js

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

↓ Download file

import path from "node:path";
import { renderCommandForDisplay } from "../core/command-result.js";
import { ToolkitRuntimeError } from "../core/errors.js";
import { runFFmpeg } from "../core/ffmpeg-runner.js";
import { deriveOutputPath, preflightOutputPath, prepareOutputTransaction, resolveReadableFile } from "../media/io.js";
import { probeMedia } from "../media/probe.js";
import { diagnoseMedia } from "./analyze.js";
function finitePositive(value, fallback) {
    return value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback;
}
function parseRate(value) {
    if (!value)
        return undefined;
    const [a, b] = value.split("/");
    if (a === undefined || b === undefined)
        return undefined;
    const numerator = Number(a);
    const denominator = Number(b);
    if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0)
        return undefined;
    const result = numerator / denominator;
    return Number.isFinite(result) && result > 0 ? result : undefined;
}
function extensionProfile(output, media) {
    const ext = path.extname(output).toLowerCase();
    if (ext === ".webm")
        return { video: ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0"], audio: ["-c:a", "libopus", "-b:a", "128k"] };
    if (media.video.length === 0 && ext === ".wav")
        return { video: [], audio: ["-c:a", "pcm_s16le"] };
    return { video: ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p"], audio: ["-c:a", "aac", "-b:a", "192k"] };
}
function defaultOutput(source, suffix, media, explicit) {
    if (explicit)
        return path.resolve(explicit);
    const preferredExtension = media.video.length > 0 ? ".mp4" : ".wav";
    return deriveOutputPath(source, suffix, undefined, { defaultExtension: preferredExtension });
}
async function inputMedia(source, options) {
    const probe = await probeMedia(source, {
        ...(options.ffprobePath !== undefined ? { ffprobePath: options.ffprobePath } : {}),
        ...(options.verbose !== undefined ? { verbose: options.verbose } : {}),
        ...(options.signal !== undefined ? { signal: options.signal } : {}),
    });
    if (!probe.media)
        throw new ToolkitRuntimeError("E_PROBE_FAILED", "Unable to inspect media before repair.");
    return probe.media;
}
async function executeRepair(options) {
    await preflightOutputPath({
        source: options.source,
        output: options.output,
        overwrite: options.runtime.overwrite ?? false,
    });
    const beforeReport = await diagnoseMedia(options.source, {
        ...(options.runtime.ffmpegPath !== undefined ? { ffmpegPath: options.runtime.ffmpegPath } : {}),
        ...(options.runtime.ffprobePath !== undefined ? { ffprobePath: options.runtime.ffprobePath } : {}),
        ...(options.runtime.verbose !== undefined ? { verbose: options.runtime.verbose } : {}),
        ...(options.runtime.signal !== undefined ? { signal: options.runtime.signal } : {}),
    });
    const transaction = await prepareOutputTransaction({
        source: options.source,
        output: options.output,
        ...(options.runtime.overwrite !== undefined ? { overwrite: options.runtime.overwrite } : {}),
        ...(options.runtime.dryRun !== undefined ? { dryRun: options.runtime.dryRun } : {}),
        ...(options.runtime.keepTemp !== undefined ? { keepTemp: options.runtime.keepTemp } : {}),
    });
    try {
        const execution = await runFFmpeg(["-hide_banner", "-loglevel", "error", ...options.args, "-n", transaction.temporary], {
            ...(options.runtime.ffmpegPath !== undefined ? { ffmpegPath: options.runtime.ffmpegPath } : {}),
            ...(options.runtime.dryRun !== undefined ? { dryRun: options.runtime.dryRun } : {}),
            ...(options.runtime.verbose !== undefined ? { verbose: options.runtime.verbose } : {}),
            ...(options.runtime.signal !== undefined ? { signal: options.runtime.signal } : {}),
        });
        const invocation = renderCommandForDisplay({ binary: execution.binary, args: execution.args });
        if (!execution.executed) {
            return {
                operation: options.operation,
                source: options.source,
                output: transaction.output,
                planned: true,
                invocation,
                execution,
                inputMedia: options.media,
                before: beforeReport.issues,
                warnings: [...(options.warnings ?? [])],
                details: options.details,
            };
        }
        await transaction.finalize();
        const afterProbe = await probeMedia(transaction.output, {
            ...(options.runtime.ffprobePath !== undefined ? { ffprobePath: options.runtime.ffprobePath } : {}),
            ...(options.runtime.verbose !== undefined ? { verbose: options.runtime.verbose } : {}),
            ...(options.runtime.signal !== undefined ? { signal: options.runtime.signal } : {}),
        });
        const afterReport = await diagnoseMedia(transaction.output, {
            ...(options.runtime.ffmpegPath !== undefined ? { ffmpegPath: options.runtime.ffmpegPath } : {}),
            ...(options.runtime.ffprobePath !== undefined ? { ffprobePath: options.runtime.ffprobePath } : {}),
            ...(options.runtime.verbose !== undefined ? { verbose: options.runtime.verbose } : {}),
            ...(options.runtime.signal !== undefined ? { signal: options.runtime.signal } : {}),
        });
        return {
            operation: options.operation,
            source: options.source,
            output: transaction.output,
            planned: false,
            invocation,
            execution,
            inputMedia: options.media,
            ...(afterProbe.media !== undefined ? { outputMedia: afterProbe.media } : {}),
            before: beforeReport.issues,
            after: afterReport.issues,
            warnings: [...(options.warnings ?? [])],
            details: options.details,
        };
    }
    catch (error) {
        await transaction.cleanup();
        throw error;
    }
}
export async function repairTimestamps(input, options = {}) {
    const source = await resolveReadableFile(input, options.cwd);
    const mode = options.mode ?? "reencode";
    if (options.output !== undefined) {
        await preflightOutputPath({
            source,
            output: path.resolve(options.output),
            overwrite: options.overwrite ?? false,
        });
    }
    const media = await inputMedia(source, options);
    const output = options.output !== undefined
        ? path.resolve(options.output)
        : mode === "remux"
            ? deriveOutputPath(source, "timestamps-repaired", undefined, { defaultExtension: ".mkv" })
            : defaultOutput(source, "timestamps-repaired", media);
    const args = ["-fflags", "+genpts", "-i", source, "-map", "0:v?", "-map", "0:a?", "-map_metadata", "0"];
    const warnings = media.streams.some((stream) => stream.codecType !== "video" && stream.codecType !== "audio")
        ? [{ code: "W_AUXILIARY_STREAMS_DROPPED", message: "Timestamp repair keeps video/audio streams and drops auxiliary streams." }]
        : [];
    if (mode === "remux") {
        args.push("-c", "copy", "-avoid_negative_ts", "make_zero");
        warnings.push({ code: "W_TIMESTAMP_REMUX_LIMITED", message: "Remux mode can regenerate container timestamps but cannot repair malformed decoded frame timing." });
    }
    else {
        const fps = finitePositive(options.fps, parseRate(media.video[0]?.averageFrameRate) ?? 30);
        const profile = extensionProfile(output, media);
        if (media.video.length > 0)
            args.push("-fps_mode", "cfr", "-r", String(fps), ...profile.video);
        if (media.audio.length > 0)
            args.push("-af", "aresample=async=1:first_pts=0", ...profile.audio);
        args.push("-avoid_negative_ts", "make_zero");
    }
    return executeRepair({
        operation: "timestamps",
        source,
        output,
        args,
        runtime: options,
        media,
        details: { mode, ...(options.fps !== undefined ? { requestedFps: options.fps } : {}) },
        warnings,
    });
}
export async function normalizeMedia(input, options = {}) {
    const source = await resolveReadableFile(input, options.cwd);
    if (options.output !== undefined) {
        await preflightOutputPath({
            source,
            output: path.resolve(options.output),
            overwrite: options.overwrite ?? false,
        });
    }
    const media = await inputMedia(source, options);
    const output = defaultOutput(source, "normalized", media, options.output);
    const args = ["-i", source, "-map", "0:v:0?", "-map", "0:a:0?"];
    const profile = extensionProfile(output, media);
    const details = {};
    if (media.video.length > 0) {
        const video = media.video[0];
        const width = Math.max(2, Math.floor(finitePositive(options.width, video?.width ?? 1280) / 2) * 2);
        const height = Math.max(2, Math.floor(finitePositive(options.height, video?.height ?? 720) / 2) * 2);
        const fps = finitePositive(options.fps, parseRate(video?.averageFrameRate) ?? 30);
        const pixelFormat = options.pixelFormat ?? "yuv420p";
        const filters = [
            `scale=${width}:${height}:force_original_aspect_ratio=decrease`,
            `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`,
            "setsar=1",
            `fps=${fps}`,
            `format=${pixelFormat}`,
            "settb=AVTB",
            "setpts=PTS-STARTPTS",
        ];
        args.push("-vf", filters.join(","), "-fps_mode", "cfr", "-r", String(fps), ...profile.video);
        details["video"] = { width, height, fps, pixelFormat };
    }
    if (media.audio.length > 0) {
        const sampleRate = finitePositive(options.sampleRate, media.audio[0]?.sampleRate ?? 48000);
        const channels = Math.max(1, Math.round(finitePositive(options.channels, media.audio[0]?.channels ?? 2)));
        const layout = channels === 1 ? "mono" : channels === 2 ? "stereo" : undefined;
        const filters = [`aresample=${sampleRate}:async=1:first_pts=0`, ...(layout ? [`aformat=channel_layouts=${layout}`] : [])];
        args.push("-af", filters.join(","), ...profile.audio);
        details["audio"] = { sampleRate, channels, ...(layout ? { layout } : {}) };
    }
    if (media.video.length === 0 && media.audio.length === 0) {
        throw new ToolkitRuntimeError("E_MEDIA_NO_MATCHING_STREAM", "Input contains neither video nor audio streams that can be normalized.");
    }
    const warnings = media.streams.some((stream) => stream.codecType !== "video" && stream.codecType !== "audio")
        ? [{ code: "W_AUXILIARY_STREAMS_DROPPED", message: "Normalization keeps the primary video/audio streams and drops auxiliary streams." }]
        : [];
    return executeRepair({ operation: "normalize", source, output, args, runtime: options, media, details, warnings });
}
//# sourceMappingURL=repair.js.map

SHA-256: 3353c88fcd8c57732a4c4d8e8ddd2f39aa065b84dd2701cc53581b7174cba489