194 lines
6.0 KiB
TypeScript
194 lines
6.0 KiB
TypeScript
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<string, unknown>): 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,
|
|
};
|
|
}
|