core/adapters/image.adapter.ts

core/adapters/image.adapter.ts is a file in Bane's Lab Social Share. 108 lines of code and 17 definitions.

import {
    FFMPEG_BINARY,
    FRAME_DIGITS,
    FRAME_EXTENSION,
    FRAME_PREFIX,
    MILLISECONDS,
    VIDEO_FILE,
} from "#configuration/constants/card.constants";
import { FFMPEG_FAILED, FFMPEG_MISSING, SUBJECT_SEPARATOR } from "#configuration/strings/card.strings";
import {
    GIF_COLORS,
    GIF_DITHER,
    GIF_PALETTE_STATS,
    OUTPUT_FRAME_MS,
    VIDEO_CRF,
} from "#configuration/configs/card.config";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import type { ImageSize } from "#types/image.types";
import type { Timeline } from "#types/card.types";
import { evenSizeOf } from "#core/converters/filename.converter";
import { execFile } from "node:child_process";
import { join } from "node:path";
import { writeVerbatim } from "@govlab/canonical-write";

const ENDLESS = "0";
const ONCE = "-1";
const MISSING_CODE = "ENOENT";
const FRAME_PATTERN = `${FRAME_PREFIX}%0${String(FRAME_DIGITS)}d${FRAME_EXTENSION}`;
const RATE = `${String(MILLISECONDS)}/${String(OUTPUT_FRAME_MS)}`;

const hasCode = function hasCode(error: unknown): error is { code: unknown } {
    return typeof error === "object" && error !== null && "code" in error;
};

const runFfmpeg = async function runFfmpeg(args: readonly string[]): Promise<void> {
    return new Promise((settle, fail) => {
        execFile(FFMPEG_BINARY, ["-hide_banner", "-loglevel", "error", "-y", ...args], (error, _stdout, stderr) => {
            if (error === null) {
                settle();
                return;
            }
            const reason =
                hasCode(error) && error.code === MISSING_CODE
                    ? FFMPEG_MISSING
                    : FFMPEG_FAILED + SUBJECT_SEPARATOR + stderr;
            fail(new Error(reason, { cause: error }));
        });
    });
};

const frameName = function frameName(index: number): string {
    return FRAME_PREFIX + String(index).padStart(FRAME_DIGITS, "0") + FRAME_EXTENSION;
};

export const encodeLoop = async function encodeLoop(frames: readonly Buffer[], folder: string): Promise<string> {
    rmSync(folder, { force: true, recursive: true });
    mkdirSync(folder, { recursive: true });
    for (const [index, frame] of frames.entries()) {
        writeVerbatim(join(folder, frameName(index)), frame);
    }
    const video = join(folder, VIDEO_FILE);
    await runFfmpeg([
        "-framerate",
        RATE,
        "-i",
        join(folder, FRAME_PATTERN),
        "-c:v",
        "libx264",
        "-qp",
        "0",
        "-pix_fmt",
        "yuv444p",
        video,
    ]);
    return video;
};

export const videoFrom = async function videoFrom(master: string, size: ImageSize): Promise<Buffer> {
    const even = evenSizeOf(size);
    const output = `${master}.${String(even.width)}x${String(even.height)}.mp4`;
    await runFfmpeg([
        "-i",
        master,
        "-vf",
        `scale=${String(size.width)}:${String(size.height)}:flags=lanczos,crop=${String(even.width)}:${String(even.height)}:0:0`,
        "-c:v",
        "libx264",
        "-crf",
        String(VIDEO_CRF),
        "-preset",
        "slow",
        "-pix_fmt",
        "yuv420p",
        "-movflags",
        "+faststart",
        output,
    ]);
    return readFileSync(output);
};

export const gifFrom = async function gifFrom(video: string, size: ImageSize, timeline: Timeline): Promise<Buffer> {
    const output = `${video}.${String(size.width)}x${String(size.height)}.gif`;
    const scale = `scale=${String(size.width)}:${String(size.height)}:flags=lanczos`;
    const palette = `split[source][copy];[source]palettegen=stats_mode=${GIF_PALETTE_STATS}:max_colors=${String(GIF_COLORS)}[palette];[copy][palette]paletteuse=dither=${GIF_DITHER}:diff_mode=rectangle`;
    await runFfmpeg([
        "-i",
        video,
        "-vf",
        `fps=${RATE},${scale},${palette}`,
        "-loop",
        timeline.loop ? ENDLESS : ONCE,
        output,
    ]);
    return readFileSync(output);
};