import { spawn } from "node:child_process";
import ffmpeg from "fluent-ffmpeg";
import ffmpegStatic from "ffmpeg-static";
/**
 * Compose one storyboard shot into a WxH MP4: footage/clip scaled to fill the
 * frame, the TTS narration as the audio track, and (when a font is available)
 * the narration burned in as a styled subtitle. Optional fade-in/out gives an
 * inter-shot transition without cross-clip probing. All shots use identical
 * encode settings so the final concat-merge can stream-copy.
 *
 * Shot length follows the narration: we probe the TTS audio duration and bound
 * the output with `-t` (NOT `-shortest` — that drops the audio track entirely
 * when paired with a lavfi/looped video source in ffmpeg-static).
 */
const FFMPEG_BIN = ffmpegStatic || "ffmpeg";
if (ffmpegStatic)
    ffmpeg.setFfmpegPath(FFMPEG_BIN);
/** Probe a media file's duration (ms) by parsing `ffmpeg -i` stderr. Null if unknown. */
function probeDurationMs(file) {
    return new Promise((resolve) => {
        const p = spawn(FFMPEG_BIN, ["-i", file], { stdio: ["ignore", "ignore", "pipe"] });
        let err = "";
        p.stderr.on("data", (d) => (err += d.toString()));
        p.on("error", () => resolve(null));
        p.on("close", () => {
            const m = err.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
            if (!m)
                return resolve(null);
            resolve(Math.round((Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3])) * 1000));
        });
    });
}
/** Escape text for ffmpeg drawtext (colon, backslash, quote, percent, newline). */
function escapeDrawtext(s) {
    return s
        .replace(/\\/g, "\\\\")
        .replace(/:/g, "\\:")
        .replace(/'/g, "’")
        .replace(/%/g, "\\%")
        .replace(/\n/g, "\\n");
}
/** Word/char wrap so long narration doesn't run off-frame (CJK has no spaces → char wrap). */
function wrap(text, perLine = 20) {
    const t = text.trim();
    const hasSpaces = /\s/.test(t);
    const lines = [];
    if (hasSpaces) {
        let cur = "";
        for (const w of t.split(/\s+/)) {
            if ((cur + " " + w).trim().length > perLine) {
                if (cur)
                    lines.push(cur);
                cur = w;
            }
            else
                cur = (cur + " " + w).trim();
        }
        if (cur)
            lines.push(cur);
    }
    else {
        for (let i = 0; i < t.length; i += perLine)
            lines.push(t.slice(i, i + perLine));
    }
    return lines.slice(0, 4).join("\n");
}
export async function composeShot(input) {
    const { width: W, height: H } = input;
    // Shot length follows the narration audio (so it's never cut off). Fall back
    // to the planned duration when there's no audio to probe.
    let durMs = input.durationMs ?? 4000;
    if (input.audio) {
        const probed = await probeDurationMs(input.audio);
        if (probed && probed > 500)
            durMs = probed;
    }
    const durSec = Math.max(1, durMs / 1000);
    const frames = Math.round(durSec * 30);
    return new Promise((resolve, reject) => {
        const cmd = ffmpeg();
        if (input.video) {
            cmd.input(input.video).inputOptions(["-stream_loop", "-1"]); // loop footage to cover narration
        }
        else if (input.image) {
            cmd.input(input.image).inputOptions(["-loop", "1"]);
        }
        else {
            // No footage/image → a dark "text-card" background (free, no provider).
            cmd.input(`color=c=0x0f172a:s=${W}x${H}`).inputFormat("lavfi");
        }
        if (input.audio)
            cmd.input(input.audio);
        // Build the base video filter depending on source type.
        let baseFilter;
        if (input.image && input.motion === "kenburns") {
            // Scale way up so zoompan has resolution headroom (avoids jitter), then
            // slow zoom-in centered. d = total output frames.
            baseFilter =
                `[0:v]scale=8000:-1,zoompan=z='min(zoom+0.0012,1.4)':d=${frames}:` +
                    `x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${W}x${H}:fps=30,setsar=1[vbg]`;
        }
        else {
            baseFilter = `[0:v]scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},setsar=1[vbg]`;
        }
        const filters = [baseFilter];
        let last = "[vbg]";
        if (input.subtitle && input.fontFile) {
            const st = input.subtitleStyle ?? {};
            const text = escapeDrawtext(wrap(input.subtitle));
            const fontSize = Math.round(H * (st.fontSizeRatio ?? 1 / 22));
            const color = st.color ?? "white";
            const pos = st.position ?? "bottom";
            const y = pos === "center" ? "(h-text_h)/2" : pos === "top" ? `${Math.round(H / 12)}` : `h-text_h-${Math.round(H / 10)}`;
            const box = st.background === false ? "" : ":box=1:boxcolor=black@0.5:boxborderw=16";
            const border = st.stroke === false ? "" : ":borderw=3:bordercolor=black@0.9";
            filters.push(`${last}drawtext=fontfile='${input.fontFile}':text='${text}':` +
                `fontcolor=${color}:fontsize=${fontSize}:line_spacing=8:` +
                `x=(w-text_w)/2:y=${y}${box}${border}[vt]`);
            last = "[vt]";
        }
        if (input.fade) {
            const fadeD = 0.3;
            const outStart = Math.max(0, durSec - fadeD);
            filters.push(`${last}fade=t=in:st=0:d=${fadeD},fade=t=out:st=${outStart.toFixed(2)}:d=${fadeD}[vf]`);
            last = "[vf]";
        }
        cmd.complexFilter(filters);
        // Bound the whole output with -t (NOT -shortest — it drops audio with a
        // looped/lavfi video source). All sources loop, so -t defines shot length.
        const outputOptions = ["-map", last];
        if (input.audio)
            outputOptions.push("-map", "1:a", "-c:a", "aac", "-b:a", "192k");
        outputOptions.push("-t", durSec.toFixed(2), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast", "-movflags", "+faststart");
        cmd
            .outputOptions(outputOptions)
            .on("error", (err) => reject(new Error(`ffmpeg compose failed: ${err.message}`)))
            .on("end", () => resolve())
            .save(input.out);
    });
}
//# sourceMappingURL=ffmpeg-compose.js.map