feat(catalog): generate derived scale 32 with preview and backup restore
This commit is contained in:
1 parent
08b0b5021e
commit
cd75361b9f
9 files changed
+749
-11
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user