import sharp from "sharp"; import { buildSpritesheet, createNitroBundle, parseNitroBundle, } from "@/lib/services/swf/nitro-builder"; import { object, resolveNitroFrame } from "./nitro-quality"; const scaleName = (name: string) => name.replace(/_64_(?=[^_]+_[0-7]_\d+$)/, "_32_"); const isScale = (name: string, size: number) => new RegExp(`_${size}_[^_]+_[0-7]_\\d+$`).test(name); function halfOffsets(value: unknown): unknown { if (Array.isArray(value)) return value.map(halfOffsets); if (!value || typeof value !== "object") return value; return Object.fromEntries( Object.entries(value).map(([key, v]) => [ key, ["x", "y", "randomX", "randomY"].includes(key) && typeof v === "number" ? Math.round(v / 2) : halfOffsets(v), ]), ); } function emptyScaleDeclaration(viz: Record): boolean { return Object.entries(viz).every(([key, value]) => { if (["size", "layerCount", "angle"].includes(key)) return true; if (key === "directions") return Object.values(object(value)).every( (direction) => Object.keys(object(direction)).length === 0, ); return ( value && typeof value === "object" && Object.keys(value).length === 0 ); }); } export async function generateScale32( input: Buffer, ): Promise<{ bundle: Buffer; before: string; after: string; count: number }> { const { json, png } = parseNitroBundle(input); const assets = object(json.assets), sheet = object(json.spritesheet), frames = object(sheet.frames); const visualizations = Array.isArray(json.visualizations) ? json.visualizations : []; if ( Object.keys(assets).some((n) => isScale(n, 32)) || Object.keys(frames).some((n) => isScale(n, 32)) || visualizations.some( (v) => object(v).size === 32 && !emptyScaleDeclaration(object(v)), ) ) throw Error("Scale 32 already exists; original artwork is preserved"); const viz = visualizations.find((v) => object(v).size === 64); const names = Object.keys(assets).filter((n) => isScale(n, 64)); if (!viz || !names.length) throw Error("No supported scale 64 furniture sprites were found"); if (names.length > 10000) throw Error("Too many sprites to generate safely"); const dimensions = await sharp(png).metadata(); if (!dimensions.width || !dimensions.height) throw Error("Invalid texture"); const images: { id: number; name: string; width: number; height: number; pixels: Buffer; }[] = []; let before = ""; let pixels = 0; for (const name of names) { const asset = object(assets[name]); for (const key of ["x", "y"]) if ( asset[key] !== undefined && (typeof asset[key] !== "number" || !Number.isFinite(asset[key])) ) throw Error("Invalid sprite offset"); const resolved = resolveNitroFrame(json, name); if (typeof asset.source === "string" && asset.source !== name) { if (!names.includes(asset.source)) throw Error("A sprite references artwork outside scale 64"); continue; } const frame = resolved.frame, rect = object(frame.frame); let sprite = await sharp(png) .extract({ left: Number(rect.x), top: Number(rect.y), width: Number(rect.w), height: Number(rect.h), }) .png() .toBuffer(); if (frame.rotated) sprite = await sharp(sprite).rotate(270).png().toBuffer(); const size = await sharp(sprite).metadata(); if (frame.trimmed) { const original = object(frame.sourceSize), offset = object(frame.spriteSourceSize); sprite = await sharp(sprite) .extend({ left: Number(offset.x), top: Number(offset.y), right: Number(original.w) - Number(offset.x) - Number(size.width), bottom: Number(original.h) - Number(offset.y) - Number(size.height), background: { r: 0, g: 0, b: 0, alpha: 0 }, }) .png() .toBuffer(); } const full = await sharp(sprite).metadata(); const width = Math.max(1, Math.round(Number(full.width) / 2)), height = Math.max(1, Math.round(Number(full.height) / 2)); pixels += width * height; if (pixels > 16_000_000) throw Error("Generated sprites exceed the texture budget"); if (!before) before = `data:image/png;base64,${sprite.toString("base64")}`; images.push({ id: images.length, name: scaleName(resolved.name), width, height, pixels: await sharp(sprite) .resize(width, height, { kernel: "nearest" }) .ensureAlpha() .raw() .toBuffer(), }); } if (!images.length) throw Error("No independent texture frames were found"); const atlas = buildSpritesheet(images); const width = Math.max(dimensions.width, atlas.width), height = dimensions.height + atlas.height; if (width * height > 64_000_000) throw Error("Combined texture exceeds the texture budget"); const combined = await sharp({ create: { width, height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 }, }, }) .composite([ { input: png, left: 0, top: 0 }, { input: atlas.png, left: 0, top: dimensions.height }, ]) .png() .toBuffer(); for (const name of names) { const asset = object(halfOffsets(assets[name])); if (typeof asset.source === "string") asset.source = scaleName(asset.source); assets[scaleName(name)] = asset; } for (const [name, rect] of atlas.frames) { frames[name] = { frame: { ...rect, y: rect.y + dimensions.height }, rotated: false, trimmed: false, spriteSourceSize: { x: 0, y: 0, w: rect.w, h: rect.h }, sourceSize: { w: rect.w, h: rect.h }, }; } json.assets = assets; json.visualizations = [ ...visualizations.filter((v) => object(v).size !== 32), { ...object(halfOffsets(viz)), size: 32 }, ]; json.spritesheet = { ...sheet, frames, meta: { ...object(sheet.meta), size: { w: width, h: height } }, }; json.catalogStudio = { ...object(json.catalogStudio), scale32: { derivedFrom: 64, method: "nearest", version: 1 }, }; const first = images[0]; const preview = await sharp(first.pixels, { raw: { width: first.width, height: first.height, channels: 4 }, }) .png() .toBuffer(); return { bundle: createNitroBundle(json, combined, String(json.name || "furniture")), before, after: `data:image/png;base64,${preview.toString("base64")}`, count: names.length, }; }