// src/server/render-pipeline.ts
import { renderCompositionToMp4 } from "./remotion-renderer.js";
import { renderViaStreamingBridge } from "./render/remotion-bridge.js";
import { applySpeedRampPrePass } from "./speed-ramp-ffmpeg.js";
import { applyTransformsPrePass, applyTimeWarpPrePass, } from "./transforms-ffmpeg.js";
import { pickEncoder } from "./render/gpu-encoder.js";
import { mixAudioTracks, normalizeLufs, burnSubtitles, compositionTextTrackToJson, } from "../domain/audio-tools.js";
import { join } from "node:path";
import { rename, stat as fsStat, writeFile } from "node:fs/promises";
import { spawn } from "node:child_process";
import { FFMPEG_BIN } from "./ffmpeg-paths.js";
/**
 * Returns true iff `inputPath` has an audio stream AND that stream is not
 * effectively silent. Remotion auto-fills a silent audio track during
 * render, so a naive "has audio stream?" probe always returns true. Run
 * ffmpeg's volumedetect to read max_volume — `-inf` (or extremely low)
 * means silent, and the loudnorm pass-2 filter aborts on silent input
 * without writing the output file (which surfaces later as ENOENT).
 *
 * Returns false on probe failure: skipping loudnorm on a borderline file
 * is better than throwing the whole render away.
 */
async function hasMeaningfulAudio(inputPath) {
    // If the file isn't on disk we have nothing to probe. Return true so the
    // caller proceeds to normalizeLufs and surfaces the real problem there,
    // rather than silently swallowing a missing-file bug behind a "no audio"
    // skip. This also keeps unit tests that mock the renderer (returning a
    // fake path that never lands on disk) on the same path as before.
    try {
        await fsStat(inputPath);
    }
    catch {
        return true;
    }
    return new Promise((resolve) => {
        const proc = spawn(FFMPEG_BIN, [
            "-hide_banner",
            "-i", inputPath,
            "-af", "volumedetect",
            "-vn",
            "-f", "null",
            "-",
        ]);
        let stderr = "";
        proc.stderr.on("data", (d) => { stderr += d.toString(); });
        proc.on("close", () => {
            if (!/Stream #\d+:\d+.*Audio/.test(stderr))
                return resolve(false);
            const m = stderr.match(/max_volume:\s*(-?\d+(?:\.\d+)?|-inf)\s*dB/);
            if (!m)
                return resolve(true); // probe inconclusive — try loudnorm anyway
            if (m[1] === "-inf")
                return resolve(false);
            const max = parseFloat(m[1]);
            if (!Number.isFinite(max))
                return resolve(false);
            return resolve(max > -60); // > -60 dBFS counts as "real" signal
        });
        proc.on("error", () => resolve(false));
    });
}
/**
 * Composition.yaml stores `clip.src` as workspace-relative paths
 * (e.g. "assets/videos/test.mp4") so the file is portable. Remotion's
 * compositor on the other hand 404s on relative URLs and refuses
 * file:// URLs (`@remotion/renderer` only accepts http/https). Rewrite
 * relative paths to the local server's `/api/works/:id/assets/...`
 * route, which is already wired for browser preview. URLs with an
 * existing scheme (http://, https://, data:, blob:) pass through.
 */
export function rewriteClipSrcsToAbsolute(comp) {
    const SCHEME = /^[a-z][a-z0-9+.\-]*:/i;
    const port = process.env.AUTOVIRAL_PORT ?? "3271";
    const baseUrl = `http://localhost:${port}/api/works/${comp.workId}`;
    const resolveOne = (src) => {
        if (!src || SCHEME.test(src))
            return src;
        // Some comps store clip.src already as a page-absolute path
        // ("/api/works/<id>/assets/..."). It only needs the localhost origin so
        // headless Chromium can fetch it — re-wrapping it through the assets/
        // prefix below would double-prefix into a 404 (snapshot E2E 2026-06-04;
        // the mp4 export path shares this fn, so this fixes both).
        if (src.startsWith("/"))
            return `http://localhost:${port}${src}`;
        // Server route already prefixes "assets/", so we only need the suffix.
        const trimmed = src.startsWith("assets/") ? src.slice("assets/".length) : src;
        const segments = trimmed.split("/").map(encodeURIComponent).join("/");
        return `${baseUrl}/assets/${segments}`;
    };
    return {
        ...comp,
        tracks: comp.tracks.map((t) => ({
            ...t,
            clips: t.clips.map((c) => {
                if (c.kind === "text")
                    return c;
                const src = c.src;
                if (typeof src !== "string")
                    return c;
                return { ...c, src: resolveOne(src) };
            }),
        })),
    };
}
/** Phase H — escape a caption text line into SRT-safe form. SRT uses CRLF
 *  pair as the segment terminator and CRLF inside a cue separates lines.
 *  We strip BOMs and normalise existing newlines to single `\n` (the
 *  emitter joins with `\n` to keep the output well-formed under
 *  Windows/macOS readers alike). Empty lines become a single space so a
 *  cue with whitespace-only text doesn't degenerate into an empty
 *  block that SRT parsers reject. */
function escapeSrtLine(text) {
    const collapsed = text
        .replace(/^﻿/, "")
        .replace(/\r\n|\r/g, "\n")
        .replace(/\n+/g, " ")
        .trim();
    return collapsed.length === 0 ? " " : collapsed;
}
/** Format a duration in seconds as `HH:MM:SS,mmm` per SRT spec.
 *  Clamps negative inputs to zero so a buggy clip with trackOffset < 0
 *  doesn't emit a `-00:00:00,500` cue (which most parsers reject). */
function formatSrtTimecode(seconds) {
    const clamped = Math.max(0, seconds);
    const totalMs = Math.round(clamped * 1000);
    const hours = Math.floor(totalMs / 3_600_000);
    const minutes = Math.floor((totalMs % 3_600_000) / 60_000);
    const secs = Math.floor((totalMs % 60_000) / 1000);
    const ms = totalMs % 1000;
    return (String(hours).padStart(2, "0") +
        ":" +
        String(minutes).padStart(2, "0") +
        ":" +
        String(secs).padStart(2, "0") +
        "," +
        String(ms).padStart(3, "0"));
}
/** Phase H — convert a text track into an SRT document body. Cues are
 *  emitted in trackOffset order with a 1-based sequence number per spec.
 *  Pure; no I/O. Exposed for the multi-caption render test. */
export function textTrackToSrt(track) {
    const cues = track.clips
        .filter((c) => c.kind === "text" && typeof c.text === "string")
        .slice()
        .sort((a, b) => a.trackOffset - b.trackOffset)
        .map((c, idx) => {
        const start = formatSrtTimecode(c.trackOffset);
        const end = formatSrtTimecode(c.trackOffset + (c.duration ?? 0));
        const body = escapeSrtLine(c.text);
        return `${idx + 1}\n${start} --> ${end}\n${body}\n`;
    });
    return cues.join("\n");
}
/** Phase H — sanitise a language tag for a sidecar filename. Falls back
 *  to ISO 639-2 "und" (undetermined) when the track has no language, per
 *  the issue spec. Strips path separators / control chars defensively so
 *  a hostile yaml can't break out of outDir via `../`. */
function sidecarLangSegment(language) {
    const raw = (language ?? "und").toString().trim();
    if (raw.length === 0)
        return "und";
    const safe = raw.replace(/[^a-zA-Z0-9_-]/g, "");
    return safe.length === 0 ? "und" : safe;
}
/** Round n/2 down to the nearest even integer (libx264 yuv420p requires even dims). */
function evenHalf(n) {
    return Math.max(2, Math.floor(n / 2 / 2) * 2);
}
/**
 * Phase 7.C — produce a deep-clone of `comp` with half-resolution (rounded
 * to even ints), fps clamped to 24, and any export presets' videoBitrate
 * halved. Audio bitrate is intentionally preserved for review-quality audio.
 * Never mutates the input.
 */
function applyProxy(comp) {
    // Composition.fps is a literal union (24|25|30|60); proxy always lands at 24
    // when the source is >= 24, otherwise we keep the original literal.
    const proxyFps = comp.fps >= 24 ? 24 : comp.fps;
    const presets = (comp.exportPresets ?? []).map((p) => ({
        ...p,
        width: evenHalf(p.width),
        height: evenHalf(p.height),
        fps: Math.min(p.fps, 24),
        videoBitrate: Math.max(500, Math.round(p.videoBitrate / 2)),
    }));
    return {
        ...comp,
        width: evenHalf(comp.width),
        height: evenHalf(comp.height),
        fps: proxyFps,
        exportPresets: presets,
    };
}
/**
 * Adapts AudioClip ducking to mixAudioTracks' MixTrack contract.
 *
 * For each AudioClip with `ducking`, emits a MixTrack with the same
 * `type` discriminator and a ducking config whose `trigger` is hard-coded
 * to "voiceover" — when a voiceover clip exists, all bgm/sfx/original
 * clips that have ducking configs duck to it. AudioClip.ducking has
 * extra fields (attack, release) that MixTrack doesn't model; only
 * ratio is forwarded. Phase 6 will widen this when MixTrack grows.
 *
 * Phase G (issue #34) — per-track volume + mute:
 * Each emitted MixTrack's linear volume is
 *   `clip.volume * dbToLinear(track.volume)`
 * where `track.volume` is dB on the lane (default 0 dB = unity). When
 * `track.muted === true`, the multiplier collapses to ~0 (we use 1e-6 to
 * stay inside ffmpeg's `volume=` numeric domain and effectively silence
 * the track without throwing). The composition's underlying clip.volume
 * is never mutated — gain folds at the MixTrack adapter only.
 */
const MUTED_LINEAR_GAIN = 1e-6; // ~ -120 dBFS, audibly silent
function dbToLinear(db) {
    return Math.pow(10, db / 20);
}
function compositionToMixTracks(comp) {
    const tracks = [];
    const audioLanes = comp.tracks.filter((t) => t.kind === "audio");
    const pairs = audioLanes.flatMap((lane) => lane.clips
        .filter((c) => c.kind === "audio")
        .map((c) => ({ lane, clip: c })));
    const hasVoiceover = pairs.some(({ clip }) => clip.type === "voiceover");
    for (const { lane, clip } of pairs) {
        const laneGainLinear = lane.muted
            ? MUTED_LINEAR_GAIN
            : dbToLinear(lane.volume ?? 0);
        const mt = {
            source: clip.src,
            type: clip.type ?? "bgm",
            volume: (clip.volume ?? 1) * laneGainLinear,
            delay: clip.trackOffset,
            fadeIn: clip.fadeIn,
            fadeOut: clip.fadeOut,
        };
        if (clip.ducking && hasVoiceover && clip.type !== "voiceover") {
            mt.ducking = {
                trigger: "voiceover",
                ratio: clip.ducking.ratio,
            };
        }
        tracks.push(mt);
    }
    return tracks;
}
// Phase G (issue #34) — exported so the render test can independently
// exercise the mix-track adapter without spinning up the full pipeline.
// Lets us assert dB-to-linear math, mute semantics, and reorder-invariance
// without touching ffmpeg.
export { compositionToMixTracks as __compositionToMixTracksForTest };
// R46 — software-codec fallbacks. Kept for backwards compat with any
// caller that imports CODEC_MAP directly; the active path now goes
// through pickEncoder() which probes for hardware accel and translates
// the preset vocabulary so we don't crash NVENC with "medium".
const CODEC_MAP = {
    h264: "libx264",
    h265: "libx265",
    vp9: "libvpx-vp9",
    av1: "libaom-av1",
};
/**
 * Phase 6.E — re-encode `input` to `output` using `preset` for codec /
 * bitrate / audio settings. Loudnorm runs upstream (stage 4); this stage
 * only honours codec + bitrate flags, plus a faststart container hint
 * for web seek.
 *
 * R46: hardware encoder auto-detection. Was hard-coded to libx264 (CPU
 * software encoder); now probes ffmpeg -encoders once and prefers
 * h264_nvenc / h264_videotoolbox / h264_vaapi / h264_qsv. macOS Apple
 * Silicon now uses VideoToolbox = ~2-4× faster on the same h264 baseline.
 */
export async function runEncodeStage(input, output, preset, signal) {
    const choice = await pickEncoder(preset.codec, "medium");
    const args = [
        "-y", "-loglevel", "error",
        "-i", input,
        "-c:v", choice.codec,
        ...choice.presetArgs,
        ...choice.extraArgs,
        "-b:v", `${preset.videoBitrate}k`,
        "-c:a", "aac",
        "-b:a", `${preset.audioBitrate}k`,
        "-movflags", "+faststart",
        output,
    ];
    return new Promise((resolve, reject) => {
        if (signal?.aborted) {
            reject(new Error("runEncodeStage: aborted before spawn"));
            return;
        }
        const child = spawn(FFMPEG_BIN, args);
        let stderr = "";
        child.stderr.on("data", (b) => { stderr += b.toString(); });
        const onAbort = () => {
            try {
                child.kill("SIGTERM");
            }
            catch { /* ignore */ }
        };
        signal?.addEventListener("abort", onAbort, { once: true });
        child.on("close", (code) => {
            signal?.removeEventListener("abort", onAbort);
            if (signal?.aborted) {
                reject(new Error("runEncodeStage: aborted"));
            }
            else if (code === 0) {
                resolve();
            }
            else {
                reject(new Error(`runEncodeStage: ffmpeg exit ${code}\n${stderr}`));
            }
        });
        child.on("error", (err) => {
            signal?.removeEventListener("abort", onAbort);
            reject(err);
        });
    });
}
export async function runRenderPipeline(opts) {
    // Phase 7.C — apply proxy transform (deep-clone, never mutates caller's comp).
    const compProxy = opts.proxy ? applyProxy(opts.comp) : opts.comp;
    const target = opts.loudnessTargetLufs ?? -14;
    const onP = opts.onProgress ?? (() => undefined);
    const checkAbort = () => {
        if (opts.signal?.aborted) {
            throw new Error("runRenderPipeline: aborted");
        }
    };
    // Stage 0 (Phase 8.3.E) — speed-ramp pre-pass for static-speed VideoClips.
    // For each video clip with a static, non-1 speed (D6) we run an ffmpeg
    // setpts/atempo invocation that resamples the source MP4 *before* Remotion
    // ever sees it, rewriting clip.src to the cached output. Variable-speed
    // clips emit a console warning and fall back to 1× export (deferred to
    // Phase 8.3.5). Pre-Remotion lives upstream of every other stage so that
    // ducking / loudnorm / encode all see the resampled audio + video.
    checkAbort();
    const compAfterSpeed = await applySpeedRampPrePass(compProxy, opts.outDir, opts.signal);
    checkAbort();
    // Stage 0.4 (S19 — US 29/30) — time-warp pre-pass. For each video clip that
    // carries `reverse` and/or `freezeAtSec`, run an ffmpeg reverse/areverse or
    // trim+tpad freeze invocation that bakes the time-domain op into a cached MP4
    // *before* Remotion sees it, then strip the consumed fields. freeze is shown
    // in BOTH preview + export (WYSIWYG); reverse is EXPORT-ONLY (the preview
    // shows an explicit "export-only" placeholder, never a fake backwards play),
    // and THIS is where the real reverse happens. Runs upstream of crop/flip so a
    // clip can be both reversed AND cropped (crop layers on the warped output).
    checkAbort();
    const compAfterWarp = await applyTimeWarpPrePass(compAfterSpeed, opts.outDir, opts.signal);
    checkAbort();
    // Stage 0.5 (S18 — US 27/28) — crop + flip pre-pass. For each video clip that
    // carries transforms.crop / flipH / flipV, run an ffmpeg crop/hflip/vflip
    // invocation that bakes the transform into a cached MP4 *before* Remotion
    // composites it, then strip the consumed fields so Remotion doesn't re-apply.
    // This is the EXPORT consumption of the same crop/flip the preview shows via
    // CSS clip-path inset() + scaleX(-1)/scaleY(-1) (WYSIWYG by construction).
    checkAbort();
    const comp = await applyTransformsPrePass(compAfterWarp, opts.outDir, opts.signal);
    checkAbort();
    // Phase H (issue #35) — multi-caption track partition.
    // When opts.captionTracks is supplied, partition every `kind: "text"`
    // lane into one of three buckets:
    //   • burn   — survives into Stage 1 (Remotion <Text> bakes it in)
    //   • sidecar — dropped from Stage 1, written as `<output>.<lang>.srt`
    //   • drop    — dropped entirely (the "both checkboxes off" path)
    // When opts.captionTracks is OMITTED, behaviour is unchanged: every text
    // track renders via Remotion (legacy single-default path). We never mutate
    // the input comp — both branches build a fresh tracks array.
    const captionStrategy = opts.captionTracks;
    const burnTrackId = captionStrategy?.burnTrackId ?? null;
    const sidecarIdSet = new Set(captionStrategy?.sidecarTrackIds ?? []);
    const sidecarTextTracks = captionStrategy != null
        ? comp.tracks.filter((t) => t.kind === "text" && sidecarIdSet.has(t.id))
        : [];
    const compForStage1 = captionStrategy != null
        ? {
            ...comp,
            tracks: comp.tracks.filter((t) => t.kind !== "text" || t.id === burnTrackId),
        }
        : comp;
    // Stage 1: Remotion render
    // #44: renderCompositionToMp4 / renderViaStreamingBridge now accept the
    // AbortSignal and bridge it onto Remotion's cancelSignal, so a Cancel during
    // this (heaviest, ~75% of the bar) stage kills the render within ~1-2s
    // instead of only taking effect at the next stage boundary.
    // R46 #2.5: pass through Remotion's per-frame onProgress so the worker's
    // weighted budget (render = 75% of the bar) actually advances smoothly
    // instead of binary 0/1.
    checkAbort();
    onP("render", 0);
    const compForRender = rewriteClipSrcsToAbsolute({ ...compForStage1, title: opts.outputTitle });
    // R46 #3.d — opt-in streaming renderer. Either an env flag or a
    // per-composition experimentalFlags hint flips the path. We always
    // fall back to the canonical Remotion path on bridge failure so a
    // bad flag day doesn't block users mid-render.
    const useStreaming = process.env.AUTOVIRAL_USE_STREAMING_RENDERER === "1" ||
        Boolean(compForRender
            .experimentalFlags?.streamingRenderer);
    const renderProgress = (fraction) => {
        if (opts.signal?.aborted)
            return;
        // Cap at 0.99 so the explicit onP("render", 1) below remains the
        // canonical "render done" signal — protects against floating-point
        // edge where renderedFrames === totalFrames triggers prematurely.
        onP("render", Math.min(0.99, fraction));
    };
    let workingPath;
    try {
        workingPath = useStreaming
            ? await renderViaStreamingBridge(compForRender, opts.outDir, {
                onProgress: renderProgress,
                signal: opts.signal,
            })
            : await renderCompositionToMp4(compForRender, opts.outDir, {
                onProgress: renderProgress,
                signal: opts.signal,
            });
    }
    catch (err) {
        // #44 — never fall back to a full Remotion re-render when the user
        // cancelled: that would restart the very stage we were asked to abort and
        // defeat the whole fix. Propagate so the worker flips the job to
        // `cancelled` (it keys off ac.signal.aborted, not the error type).
        if (opts.signal?.aborted)
            throw err;
        if (useStreaming) {
            // eslint-disable-next-line no-console
            console.warn("[render] streaming bridge failed, falling back to Remotion:", err);
            workingPath = await renderCompositionToMp4(compForRender, opts.outDir, {
                onProgress: renderProgress,
                signal: opts.signal,
            });
        }
        else {
            throw err;
        }
    }
    onP("render", 1);
    checkAbort();
    // Stage 2: ducking (optional, only if any audio clip has ducking)
    const audioClips = comp.tracks
        .filter((t) => t.kind === "audio")
        .flatMap((t) => t.clips);
    const needsDucking = audioClips.some((c) => c.ducking);
    if (needsDucking) {
        onP("duck", 0);
        const ducked = workingPath.replace(/\.mp4$/, "-ducked.mp4");
        await mixAudioTracks({
            videoPath: workingPath,
            tracks: compositionToMixTracks(comp),
            outputPath: ducked,
        });
        workingPath = ducked;
        onP("duck", 1);
        checkAbort();
    }
    // R46 #4 — overlay-strategy captions baked into Stage 1 by Remotion.
    // When the composition opts in via captionStrategy="overlay", the
    // CaptionsLayer React component already rendered captions on top of
    // the video frames during Stage 1. Skipping libass burn here avoids
    // double-rendering captions (and also the libass crash when no text
    // track exists, since overlay captions don't populate text tracks).
    const skipBurnForOverlay = comp.captionStrategy === "overlay" && comp.captions != null;
    // Stage 3: subtitle burn (optional). Explicit opt-in must not silently no-op:
    // a missing text track when burnSubtitles=true is a programming error, not
    // graceful degradation. Callers can pre-check via compositionTextTrackToJson.
    if (opts.burnSubtitles && !skipBurnForOverlay) {
        const hasTextTrack = compositionTextTrackToJson(comp).length > 0;
        if (!hasTextTrack) {
            throw new Error("runRenderPipeline: burnSubtitles=true but the composition has no text-track clips to burn");
        }
        onP("burn", 0);
        const burned = workingPath.replace(/\.mp4$/, "-burned.mp4");
        await burnSubtitles({
            inputVideo: workingPath,
            comp,
            outputVideo: burned,
        });
        workingPath = burned;
        onP("burn", 1);
        checkAbort();
    }
    // Stage 4: loudnorm two-pass.
    // ffmpeg's loudnorm filter aborts when the input has no audio stream
    // (e.g. user uploaded a silent screen-recording with no BGM). Probe
    // the working file first; skip the stage if there is no audio.
    onP("loudnorm", 0);
    const hasAudio = await hasMeaningfulAudio(workingPath);
    if (hasAudio) {
        const normalized = workingPath.replace(/\.mp4$/, "-normalized.mp4");
        await normalizeLufs(workingPath, normalized, { target, truePeak: -1.5, lra: 11 });
        workingPath = normalized;
    }
    onP("loudnorm", 1);
    checkAbort();
    // Stage 5: final encode. If a platform preset is present, re-encode using
    // its codec + bitrate. Otherwise (legacy compositions w/o presets), keep
    // the prior behaviour: rename + done.
    onP("encode", 0);
    // e2e-report F68: distinguish proxy vs full export filenames so users can
    // tell artifacts apart at-a-glance (timestamp alone is ambiguous when both
    // types are rendered in quick succession).
    const filePrefix = opts.proxy ? "proxy" : "final";
    const finalPath = join(opts.outDir, `${filePrefix}-${Date.now()}.mp4`);
    const preset = comp.exportPresets?.[0];
    if (preset) {
        await runEncodeStage(workingPath, finalPath, preset, opts.signal);
    }
    else {
        await rename(workingPath, finalPath);
    }
    onP("encode", 1);
    // Phase H (issue #35) — sidecar SRT emission. After the final mp4 path is
    // settled (so the filename stems match), write one `.srt` per text track
    // the user flagged as Sidecar. Naming follows YouTube/FCP convention:
    //   final-1717000000000.en.srt   ← English
    //   final-1717000000000.zh.srt   ← Chinese
    // Failure here is non-fatal: the user still gets the rendered video, and
    // we surface the write error on stderr so a future job can retry. Hard-
    // failing would mean a 25-minute render gets thrown away over a missing
    // disk byte — explicitly the wrong trade.
    if (sidecarTextTracks.length > 0) {
        const baseNoExt = finalPath.replace(/\.mp4$/, "");
        await Promise.all(sidecarTextTracks.map(async (track) => {
            const lang = sidecarLangSegment(track.language);
            const srtPath = `${baseNoExt}.${lang}.srt`;
            const body = textTrackToSrt(track);
            try {
                await writeFile(srtPath, body, "utf-8");
            }
            catch (err) {
                // eslint-disable-next-line no-console
                console.warn(`[render] sidecar SRT write failed for ${srtPath}:`, err);
            }
        }));
    }
    return finalPath;
}
//# sourceMappingURL=render-pipeline.js.map