feat(catalog): generate derived scale 32 with preview and backup restore
CI / check (push) Successful in 1m32s
CI / deploy (push) Successful in 1m12s
CI / e2e (push) Successful in 21s

This commit is contained in:
Simo committed 2026-09-05 17:36:22 +02:00
1 parent 08b0b5021e
commit cd75361b9f
9 files changed
+749 -11

No files matched your search

+4
View File
@@ -4,6 +4,7 @@ export interface ScaleIssue {
}
export interface ScaleReport {
size: 32 | 64;
derived?: boolean;
state: "missing" | "issues" | "present";
assets: string[];
animations: string[];
@@ -178,6 +179,9 @@ export function inspectNitroScales(
].sort();
return {
size,
derived:
size === 32 &&
object(object(metadata.catalogStudio).scale32).derivedFrom === 64,
state: issues.length ? "issues" : names.length ? "present" : "missing",
assets: names,
animations,
+179
View File
@@ -0,0 +1,179 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import {
createNitroBundle,
parseNitroBundle,
} from "@/lib/services/swf/nitro-builder";
import { generateScale32 } from "./nitro-scale32";
const metadata = () => ({
name: "chair",
assets: {
chair_64_a_0_0: { x: -5, y: 7 },
chair_64_a_2_0: { source: "chair_64_a_0_0", x: 5, y: 7, flipH: true },
},
visualizations: [
{
size: 64,
layerCount: 1,
layers: { 0: { x: 6, y: -4, z: 3 } },
animations: {
0: {
layers: {
0: {
frameRepeat: 4,
frameSequences: {
0: { frames: { 0: { id: 0, x: 6, y: 4, randomX: 8 } } },
},
},
},
},
},
},
],
logic: { model: { dimensions: { x: 2, y: 1, z: 1 } } },
spritesheet: {
frames: {
chair_64_a_0_0: {
frame: { x: 0, y: 0, w: 6, h: 8 },
rotated: false,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: 6, h: 8 },
sourceSize: { w: 6, h: 8 },
},
},
meta: { size: { w: 6, h: 8 }, scale: "1" },
},
});
async function bundle(json = metadata()) {
return createNitroBundle(
json,
await sharp({
create: { width: 6, height: 8, channels: 4, background: "#ff0000" },
})
.png()
.toBuffer(),
"chair",
);
}
describe("scale 32 generation", () => {
it("preserves originals and adds half-size artwork and offsets without changing timing or geometry", async () => {
const source = await bundle();
const result = await generateScale32(source);
const { json, png } = parseNitroBundle(result.bundle);
const m = json as ReturnType<typeof metadata>;
expect(m.assets.chair_64_a_0_0).toEqual(metadata().assets.chair_64_a_0_0);
expect(m.logic).toEqual(metadata().logic);
expect(
(json.assets as Record<string, unknown>).chair_32_a_0_0,
).toMatchObject({ x: -2, y: 4 });
expect(
(json.assets as Record<string, unknown>).chair_32_a_2_0,
).toMatchObject({
source: "chair_32_a_0_0",
flipH: true,
});
expect(
(json.spritesheet as { frames: Record<string, { frame: unknown }> })
.frames.chair_32_a_0_0.frame,
).toMatchObject({ w: 3, h: 4 });
expect(m.visualizations[1]).toMatchObject({
size: 32,
layers: { 0: { x: 3, y: -2, z: 3 } },
animations: {
0: {
layers: {
0: {
frameRepeat: 4,
frameSequences: {
0: { frames: { 0: { id: 0, x: 3, y: 2, randomX: 4 } } },
},
},
},
},
},
});
expect(
await sharp(png)
.extract({ left: 0, top: 0, width: 6, height: 8 })
.raw()
.toBuffer(),
).toEqual(await sharp(parseNitroBundle(source).png).raw().toBuffer());
});
it("never overwrites a native or partial scale32", async () => {
const m = metadata();
m.visualizations.push({ ...m.visualizations[0], size: 32 });
await expect(generateScale32(await bundle(m))).rejects.toThrow("already");
});
it("rejects missing sprite references", async () => {
const m = metadata();
m.assets.chair_64_a_2_0.source = "missing";
await expect(generateScale32(await bundle(m))).rejects.toThrow("Missing");
});
});
it("restores trimmed sprite geometry before reducing it", async () => {
const png = await sharp({
create: { width: 2, height: 2, channels: 4, background: "#00ff00" },
})
.png()
.toBuffer();
const source = createNitroBundle(
{
name: "trim",
assets: { trim_64_a_0_0: { x: 4, y: 6 } },
visualizations: [{ size: 64 }],
spritesheet: {
frames: {
trim_64_a_0_0: {
frame: { x: 0, y: 0, w: 2, h: 2 },
trimmed: true,
sourceSize: { w: 6, h: 8 },
spriteSourceSize: { x: 2, y: 4, w: 2, h: 2 },
},
},
},
},
png,
"trim",
);
const generated = await generateScale32(source);
const { json } = parseNitroBundle(generated.bundle);
expect(
(json.spritesheet as { frames: Record<string, { sourceSize: unknown }> })
.frames.trim_32_a_0_0.sourceSize,
).toEqual({ w: 3, h: 4 });
const pixels = await sharp(
Buffer.from(generated.after.split(",")[1], "base64"),
)
.raw()
.toBuffer();
expect([...pixels.subarray((2 * 3 + 1) * 4, (2 * 3 + 1) * 4 + 4)]).toEqual([
0, 255, 0, 255,
]);
expect(pixels[3]).toBe(0);
});
it("unrotates atlas artwork before generating the smaller frame", async () => {
const source = createNitroBundle(
{
name: "rot",
assets: { rot_64_a_0_0: {} },
visualizations: [{ size: 64 }],
spritesheet: {
frames: {
rot_64_a_0_0: { frame: { x: 0, y: 0, w: 2, h: 6 }, rotated: true },
},
},
},
await sharp({
create: { width: 2, height: 6, channels: 4, background: "#ff0000" },
})
.png()
.toBuffer(),
"rot",
);
const generated = await generateScale32(source);
const size = await sharp(
Buffer.from(generated.after.split(",")[1], "base64"),
).metadata();
expect([size.width, size.height]).toEqual([3, 1]);
});
+179
View File
@@ -0,0 +1,179 @@
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),
]),
);
}
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)
)
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");
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 = object(frames[name]),
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(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,
{ ...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,
};
}