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