import { randomBytes } from "crypto"; import { fileTypeFromBuffer } from "file-type"; import Ffmpeg from "fluent-ffmpeg"; import { rm } from "fs/promises"; import { readdir } from "fs/promises"; import { mkdtemp } from "fs/promises"; import { tmpdir } from "os"; import { basename } from "path"; import sharp, { type OverlayOptions, type Sharp } from "sharp"; import type { Config, Detail, Image } from "./images"; async function makeComposite(image: string | Buffer, details: Detail, sizeMultiplier: number, offset = 0) { let img = await sharp(image) .extract({ top: details.cut[1] * sizeMultiplier, left: details.cut[0] * sizeMultiplier, width: details.size[0] * sizeMultiplier, height: details.size[1] * sizeMultiplier }).toBuffer(); if (details.rotate) { img = await sharp(img).rotate(details.rotate).toBuffer(); } if (details.flip) { img = await sharp(img).flip(details.flip === "horizontal").flop(details.flip === "vertical").toBuffer(); } return { input: img, top: (details.pos[1] * sizeMultiplier) + offset, left: details.pos[0] * sizeMultiplier } } async function applyOptions(image: string | Buffer, options: Image, width: number, height: number) { let img = await sharp(image).toBuffer() if (options.rotate) { img = await sharp(img).rotate(options.rotate).toBuffer(); } if (options.flip) { img = await sharp(img).flip(options.flip === "horizontal").flop(options.flip === "vertical").toBuffer(); } img = await sharp(img).resize(width, height, { fit: "fill" }).toBuffer(); return img; } export async function formatImage(name: string, baseFile: string, config: Config, image: string | string[]) { const options = config.images.find(img => img.name === name)!; const isImage = !Array.isArray(image) && ["image/png", "image/jpeg"].includes((await fileTypeFromBuffer(Buffer.isBuffer(image) ? image : await Bun.file(image).arrayBuffer()))!.mime); let result: Sharp; const multplier = options.sizeMultiplier ?? 1; const cropWidth = Math.max(config.details.top.size[0], config.details.middle.size[0], config.details.bottom.size[0]) * multplier, cropHeight = (config.details.top.size[1] + config.details.middle.size[1] + config.details.bottom.size[1]) * multplier, fullWidth = Math.floor(config.defaultSize[0] * multplier), fullHeight = Math.floor(config.defaultSize[1] * multplier); const base = sharp(baseFile); if (multplier !== 1) { base.resize(fullWidth, fullHeight, { kernel: sharp.kernel.nearest }); } const baseBuf = await base.toBuffer(); if (isImage) { const img = await applyOptions(image, options, cropWidth, cropHeight); result = await sharp(baseBuf) .composite([ await makeComposite(img, config.details.top, multplier), await makeComposite(img, config.details.middle, multplier), await makeComposite(img, config.details.bottom, multplier) ]); } else { let tmpDir: string | undefined; let files: string[] = []; tmpDir = await mkdtemp(`${tmpdir()}/split-frames-`); if (!Array.isArray(image)) { let select = ""; if (Array.isArray(options.frames)) { select = options.frames.map(f => `eq(n\\,${f})`).join("+"); } else if (options.frames) { select = `gte(n\\, ${options.frames.start})${options.frames.end ? `*lte(n\\,${options.frames.end}` : ""})`; } else { select = "n"; } console.debug("Input file %s is not an image, assuming we need to extract frames.", name); await new Promise((resolve) => { Ffmpeg(image) .videoFilter(`scale=${cropWidth}:${cropHeight},select='${select}'`) .addOption("-vsync vfr") .output(`${tmpDir}/frame%04d.png`) .on("end", async() => { files = (await readdir(tmpDir!)).filter(f => /frame\d+\.png/.test(f)).sort().map(f => `${tmpDir}/${f}`); if (files.length === 0) { console.error("No frames extracted for %s.", name); if (tmpDir) { await rm(tmpDir, { recursive: true }); } process.exit(1); } console.log("Frames extracted to %s, %d total for %s", tmpDir, files.length, name); resolve(); }) .on("error", async(err) => { if (tmpDir) { await rm(tmpDir, { recursive: true }); } console.error("Failed to extract frames for %s.", name); console.error(err); process.exit(1); }) .run() }); } else { for (const img of image) { await sharp(await applyOptions(img, options, cropWidth, cropHeight)).toFile(`${tmpDir}/${basename(img)}`); } files = image.map(f => `${tmpDir}/${basename(f)}`).sort(); } const parts: OverlayOptions[] = []; for (const img of files) { const i = files.indexOf(img); console.log("Processing %s (%d/%d) for %s", img, i + 1, files.length, name); parts.push( { input: baseBuf, top: fullHeight * i, left: 0 }, await makeComposite(img, config.details.top, multplier, fullHeight * i), await makeComposite(img, config.details.middle, multplier, fullHeight * i), await makeComposite(img, config.details.bottom, multplier, fullHeight * i) ); } result = await sharp({ create: { width: fullWidth, height: fullHeight * files.length, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, limitInputPixels: false }) .composite(parts) if (tmpDir) { await rm(tmpDir, { recursive: true }); } } return { isImage, result: result! }; } const frameSizePercent = 0.1; async function createFrame(colors: number[], percent: number, width: number, height: number) { let frameW = width, frameH = height, i = 0; const parts: OverlayOptions[] = []; const frameVH = Math.floor(Math.min(width, height) * percent); while (frameW > 0) { if (i > colors.length - 1) { i = 0; } const color = colors[i], r = (color >> 16) & 0xFF, g = (color >> 8) & 0xFF, b = color & 0xFF, w = width * frameSizePercent, h = frameVH; parts.push({ input: { create: { width: w, height: h, channels: 4, background: { r, g, b, alpha: 255 } } }, top: 0, // left to right left: i * w }, { input: { create: { width: w, height: h, channels: 4, background: { r, g, b, alpha: 255 } } }, top: frameH - h, // right to left left: width - (i + 1) * w }); frameW -= w; i += 1; } i = 0; colors = colors.toReversed(); while (frameH > 0) { if (i > colors.length - 1) { i = 0; } const color = colors[i], r = (color >> 16) & 0xFF, g = (color >> 8) & 0xFF, b = color & 0xFF, w = frameVH, h = height * frameSizePercent; parts.push({ input: { create: { width: w, height: h, channels: 4, background: { r, g, b, alpha: 255 } } }, top: i * h, // top to bottom left: 0 }, { input: { create: { width: w, height: h, channels: 4, background: { r, g, b, alpha: 255 } } }, top: height - (i + 1) * h, // bottom to top left: width - w }); frameH -= h; i += 1; } return sharp({ create: { width, height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, limitInputPixels: false }).composite(parts).png().toBuffer(); } export const rgb = (hex: number) => ({ r: (hex >> 16) & 0xFF, g: (hex >> 8) & 0xFF, b: hex & 0xFF }); const kzBorderColor = rgb(0x6B3F7F) const kzFillColor = rgb(0xD67FFF); const plankSize = 4; // the things I do to not have to make this a file const plank = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAB3RJTUUH6AUfFCAKmCc95gAAATJJREFUKM9lUsFqwzAMlSXFXRYIYQRy6GX/tq/Zh/ZQGKUEOi9WpOyg1jWpL1Gen+X3/BS+vz6TCDxW2zQAoGZZ1et6JRF0UiSab1skUrMkklV9O4k4omZ+hv2TVfsuOC8SAQAh+paazbdtGu6/fLos9wrD4+a1aFht8+J0WZzAx4/Dq4ck0jZNEgEI820b+7sQQmRnF1TNXIyaubaxB5cKAKDK3rIdntTiyhtNQ2wrnM/XXGvd+Vlt25nkaYg19XzNfRdKGkV6yYFrxwAwDVHNPDhHXFWhcds0u05PiwCRaOyfD5BV+XzNq22urzjZZVLjew9VAlDdwz/z6kz0UfG3KsGVAStK+i74RDEhtojQiXetJ5QQk0gkotccAIBCWNQAlgPhosaYH1HI75+9v6F7+AdOcdbDy169/gAAAABJRU5ErkJggg==", "base64"); export async function makeKZ(squareSize: number, gridSize = 16) { const kzBorderSize = squareSize * 0.0625; const kzPlank = await sharp(plank).resize(squareSize, squareSize).toBuffer(); const plankStart = gridSize - plankSize; const kzParts: OverlayOptions[] = []; // i = top to bottom // j = left to right for (let i = 0; i < gridSize; i++) { for (let j = 0; j < gridSize; j++) { if (i < plankSize && j >= plankStart) { kzParts.push({ input: kzPlank, top: squareSize * i, left: squareSize * j }); } else { kzParts.push({ input: { create: { width: kzBorderSize, height: squareSize, channels: 3, background: kzBorderColor } }, top: squareSize * i, left: squareSize * j },{ input: { create: { width: squareSize, height: kzBorderSize, channels: 3, background: kzBorderColor } }, top: squareSize * i, left: squareSize * j }); } } } return sharp({ create: { width: squareSize * 16, height: squareSize * 16, channels: 3, background: kzFillColor }, limitInputPixels: false }).composite(kzParts).png().toBuffer(); }