fix(furni): generate scale 32 from bundles with .png frame keys
CI / check (push) Successful in 2m30s
CI / deploy (push) Successful in 1m25s
CI / publish-container (push) Successful in 48s

resolveNitroFrame now matches spritesheet frame keys that carry a .png
suffix or namespaced naming, and isScale/scaleName preserve that suffix.
Broken source sprites (missing frames or references to icon artwork) are
skipped and reported instead of aborting the whole generation, and the
studio UI surfaces the skipped count.
This commit is contained in:
openhands committed 2026-09-12 12:46:17 +02:00
1 parent 0842788a28
commit 4d720ee03c
5 files changed
+119 -29

No files matched your search

@@ -73,6 +73,7 @@ export const POST = withAdmin(
before: generated.before,
after: generated.after,
count: generated.count,
skipped: generated.skipped,
});
await fs.mkdir(backupDir, { recursive: true });
const backupFile = path.join(
@@ -97,6 +98,7 @@ export const POST = withAdmin(
hash: hash(generated.bundle),
backupHash: originalHash,
metadata: parseNitroBundle(generated.bundle).json,
skipped: generated.skipped,
});
});
try {
@@ -16,6 +16,7 @@ export function NitroScale32Generator({
before: string;
after: string;
count: number;
skipped?: number;
} | null>(null),
[undo, setUndo] = useState<{ hash: string; backupHash: string } | null>(
null,
@@ -78,6 +79,12 @@ export function NitroScale32Generator({
{preview.count} sprite entries will be added. Check positioning and
animations in the client after saving.
</p>
{preview.skipped ? (
<p className="text-sm text-destructive" role="alert">
{preview.skipped} sprite(s) were skipped because the source bundle
has no usable artwork for them.
</p>
) : null}
<div className="grid grid-cols-2 gap-3">
{[
["Original 64", preview.before],
+17 -14
View File
@@ -44,20 +44,23 @@ export function resolveNitroFrame(
current = asset.source;
continue;
}
const frameName = Object.hasOwn(frames, current)
? current
: typeof metadata.name === "string"
? `${metadata.name}_${current}`
: current;
const frame = object(frames[frameName]);
if (Object.keys(frame).length)
return {
name: frameName,
asset: object(assets[name]),
frame,
flipH,
flipV,
};
const candidates =
typeof metadata.name === "string"
? [current, `${metadata.name}_${current}`]
: [current];
for (const candidate of candidates) {
for (const key of [candidate, `${candidate}.png`]) {
const frame = object(frames[key]);
if (Object.keys(frame).length)
return {
name: key,
asset: object(assets[name]),
frame,
flipH,
flipV,
};
}
}
throw Error(`Missing texture frame: ${current}`);
}
throw Error("Sprite reference chain is too long");
+53 -2
View File
@@ -105,10 +105,18 @@ describe("scale 32 generation", () => {
m.visualizations.push({ ...m.visualizations[0], size: 32 });
await expect(generateScale32(await bundle(m))).rejects.toThrow("already");
});
it("rejects missing sprite references", async () => {
it("skips sprites whose artwork is missing and reports them", async () => {
const m = metadata();
m.assets.chair_64_a_2_0.source = "missing";
await expect(generateScale32(await bundle(m))).rejects.toThrow("Missing");
const result = await generateScale32(await bundle(m));
expect(result.skipped).toBe(1);
const { json } = parseNitroBundle(result.bundle);
expect(
(json.assets as Record<string, unknown>).chair_32_a_0_0,
).toBeDefined();
expect(
(json.assets as Record<string, unknown>).chair_32_a_2_0,
).toBeUndefined();
});
});
it("restores trimmed sprite geometry before reducing it", async () => {
@@ -211,3 +219,46 @@ it("fills an empty scale declaration and retains namespaced frame naming", async
.chair_chair_32_a_0_0,
).toBeDefined();
});
it("handles frame keys with a .png suffix and namespaced naming", async () => {
const m = metadata();
const frames = m.spritesheet.frames as Record<string, unknown>;
for (const key of Object.keys(frames) as string[]) {
frames[`chair_${key}.png`] = frames[key];
delete frames[key];
}
const source = createNitroBundle(
m,
await sharp({
create: { width: 6, height: 8, channels: 4, background: "#ff0000" },
})
.png()
.toBuffer(),
"chair",
);
const result = parseNitroBundle((await generateScale32(source)).bundle);
expect(
(result.json.spritesheet as { frames: Record<string, unknown> }).frames[
"chair_chair_32_a_0_0.png"
],
).toBeDefined();
expect(
(result.json.assets as Record<string, unknown>).chair_32_a_0_0,
).toBeDefined();
});
it("rejects bundles that already contain a non-empty scale32 frame", async () => {
const m = metadata();
const frames = m.spritesheet.frames as Record<string, unknown>;
for (const key of Object.keys(frames) as string[]) {
frames[`chair_${key}.png`] = frames[key];
delete frames[key];
}
(m.assets as Record<string, unknown>).chair_32_a_0_0 = { x: 0, y: 0 };
frames["chair_chair_32_a_0_0.png"] = {
frame: { x: 0, y: 0, w: 3, h: 4 },
rotated: false,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: 3, h: 4 },
sourceSize: { w: 3, h: 4 },
};
await expect(generateScale32(await bundle(m))).rejects.toThrow("already");
});
+40 -13
View File
@@ -6,10 +6,14 @@ import {
} from "@/lib/services/swf/nitro-builder";
import { object, resolveNitroFrame } from "./nitro-quality";
const scaleName = (name: string) =>
name.replace(/_64_(?=[^_]+_[0-7]_\d+$)/, "_32_");
const scaleName = (name: string) => {
const png = name.endsWith(".png");
const base = png ? name.slice(0, -4) : name;
const renamed = base.replace(/_64_(?=[^_]+_[0-7]_\d+$)/, "_32_");
return png ? `${renamed}.png` : renamed;
};
const isScale = (name: string, size: number) =>
new RegExp(`_${size}_[^_]+_[0-7]_\\d+$`).test(name);
new RegExp(`_${size}_[^_]+_[0-7]_\\d+(\\.png)?$`).test(name);
function halfOffsets(value: unknown): unknown {
if (Array.isArray(value)) return value.map(halfOffsets);
if (!value || typeof value !== "object") return value;
@@ -34,9 +38,13 @@ function emptyScaleDeclaration(viz: Record<string, unknown>): boolean {
);
});
}
export async function generateScale32(
input: Buffer,
): Promise<{ bundle: Buffer; before: string; after: string; count: number }> {
export async function generateScale32(input: Buffer): Promise<{
bundle: Buffer;
before: string;
after: string;
count: number;
skipped: number;
}> {
const { json, png } = parseNitroBundle(input);
const assets = object(json.assets),
sheet = object(json.spritesheet),
@@ -54,7 +62,9 @@ export async function generateScale32(
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)
if (!viz)
throw Error("The bundle has no scale 64 visualization to derive from");
if (!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();
@@ -68,6 +78,7 @@ export async function generateScale32(
}[] = [];
let before = "";
let pixels = 0;
const skipped = new Set<string>();
for (const name of names) {
const asset = object(assets[name]);
for (const key of ["x", "y"])
@@ -76,10 +87,18 @@ export async function generateScale32(
(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");
if (!names.includes(asset.source)) {
skipped.add(name);
continue;
}
continue;
}
let resolved: ReturnType<typeof resolveNitroFrame>;
try {
resolved = resolveNitroFrame(json, name);
} catch (error) {
skipped.add(name);
continue;
}
const frame = resolved.frame,
@@ -149,10 +168,17 @@ export async function generateScale32(
])
.png()
.toBuffer();
const generated = new Map<string, boolean>();
for (const name of names)
if (!skipped.has(name)) generated.set(scaleName(name), true);
for (const name of names) {
if (skipped.has(name)) continue;
const asset = object(halfOffsets(assets[name]));
if (typeof asset.source === "string")
asset.source = scaleName(asset.source);
if (typeof asset.source === "string") {
const target = scaleName(asset.source);
if (!generated.has(target)) continue;
asset.source = target;
}
assets[scaleName(name)] = asset;
}
for (const [name, rect] of atlas.frames) {
@@ -188,6 +214,7 @@ export async function generateScale32(
bundle: createNitroBundle(json, combined, String(json.name || "furniture")),
before,
after: `data:image/png;base64,${preview.toString("base64")}`,
count: names.length,
count: images.length,
skipped: skipped.size,
};
}