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, }; }