YiffyBeds/scripts/common.ts

293 lines
11 KiB
TypeScript
Raw Normal View History

2024-07-27 23:46:56 +00:00
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<void>((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();
}