From 4d720ee03c7a7b483ba3a9f45d7d089e652ea9f4 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 12 Sep 2026 12:46:17 +0200 Subject: [PATCH] fix(furni): generate scale 32 from bundles with .png frame keys 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. --- .../api/admin/studio/nitro-scale32/route.ts | 2 + .../admin/studio/nitro-scale32-generator.tsx | 7 +++ src/lib/furni/nitro-quality.ts | 31 ++++++----- src/lib/furni/nitro-scale32.test.ts | 55 ++++++++++++++++++- src/lib/furni/nitro-scale32.ts | 53 +++++++++++++----- 5 files changed, 119 insertions(+), 29 deletions(-) diff --git a/src/app/api/admin/studio/nitro-scale32/route.ts b/src/app/api/admin/studio/nitro-scale32/route.ts index 2ff404ef..1aa1b158 100644 --- a/src/app/api/admin/studio/nitro-scale32/route.ts +++ b/src/app/api/admin/studio/nitro-scale32/route.ts @@ -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 { diff --git a/src/components/admin/studio/nitro-scale32-generator.tsx b/src/components/admin/studio/nitro-scale32-generator.tsx index 75927bc6..0dadf360 100644 --- a/src/components/admin/studio/nitro-scale32-generator.tsx +++ b/src/components/admin/studio/nitro-scale32-generator.tsx @@ -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.

+ {preview.skipped ? ( +

+ {preview.skipped} sprite(s) were skipped because the source bundle + has no usable artwork for them. +

+ ) : null}
{[ ["Original 64", preview.before], diff --git a/src/lib/furni/nitro-quality.ts b/src/lib/furni/nitro-quality.ts index 64e66e23..b0e3fd87 100644 --- a/src/lib/furni/nitro-quality.ts +++ b/src/lib/furni/nitro-quality.ts @@ -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"); diff --git a/src/lib/furni/nitro-scale32.test.ts b/src/lib/furni/nitro-scale32.test.ts index d6d2fcb8..06167fa4 100644 --- a/src/lib/furni/nitro-scale32.test.ts +++ b/src/lib/furni/nitro-scale32.test.ts @@ -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).chair_32_a_0_0, + ).toBeDefined(); + expect( + (json.assets as Record).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; + 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 }).frames[ + "chair_chair_32_a_0_0.png" + ], + ).toBeDefined(); + expect( + (result.json.assets as Record).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; + for (const key of Object.keys(frames) as string[]) { + frames[`chair_${key}.png`] = frames[key]; + delete frames[key]; + } + (m.assets as Record).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"); +}); diff --git a/src/lib/furni/nitro-scale32.ts b/src/lib/furni/nitro-scale32.ts index 278c25d2..f05244b9 100644 --- a/src/lib/furni/nitro-scale32.ts +++ b/src/lib/furni/nitro-scale32.ts @@ -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): 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(); 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; + 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(); + 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, }; }