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.
This commit is contained in:
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,
|
before: generated.before,
|
||||||
after: generated.after,
|
after: generated.after,
|
||||||
count: generated.count,
|
count: generated.count,
|
||||||
|
skipped: generated.skipped,
|
||||||
});
|
});
|
||||||
await fs.mkdir(backupDir, { recursive: true });
|
await fs.mkdir(backupDir, { recursive: true });
|
||||||
const backupFile = path.join(
|
const backupFile = path.join(
|
||||||
@@ -97,6 +98,7 @@ export const POST = withAdmin(
|
|||||||
hash: hash(generated.bundle),
|
hash: hash(generated.bundle),
|
||||||
backupHash: originalHash,
|
backupHash: originalHash,
|
||||||
metadata: parseNitroBundle(generated.bundle).json,
|
metadata: parseNitroBundle(generated.bundle).json,
|
||||||
|
skipped: generated.skipped,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function NitroScale32Generator({
|
|||||||
before: string;
|
before: string;
|
||||||
after: string;
|
after: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
skipped?: number;
|
||||||
} | null>(null),
|
} | null>(null),
|
||||||
[undo, setUndo] = useState<{ hash: string; backupHash: string } | null>(
|
[undo, setUndo] = useState<{ hash: string; backupHash: string } | null>(
|
||||||
null,
|
null,
|
||||||
@@ -78,6 +79,12 @@ export function NitroScale32Generator({
|
|||||||
{preview.count} sprite entries will be added. Check positioning and
|
{preview.count} sprite entries will be added. Check positioning and
|
||||||
animations in the client after saving.
|
animations in the client after saving.
|
||||||
</p>
|
</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">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{[
|
{[
|
||||||
["Original 64", preview.before],
|
["Original 64", preview.before],
|
||||||
|
|||||||
@@ -44,20 +44,23 @@ export function resolveNitroFrame(
|
|||||||
current = asset.source;
|
current = asset.source;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const frameName = Object.hasOwn(frames, current)
|
const candidates =
|
||||||
? current
|
typeof metadata.name === "string"
|
||||||
: typeof metadata.name === "string"
|
? [current, `${metadata.name}_${current}`]
|
||||||
? `${metadata.name}_${current}`
|
: [current];
|
||||||
: current;
|
for (const candidate of candidates) {
|
||||||
const frame = object(frames[frameName]);
|
for (const key of [candidate, `${candidate}.png`]) {
|
||||||
if (Object.keys(frame).length)
|
const frame = object(frames[key]);
|
||||||
return {
|
if (Object.keys(frame).length)
|
||||||
name: frameName,
|
return {
|
||||||
asset: object(assets[name]),
|
name: key,
|
||||||
frame,
|
asset: object(assets[name]),
|
||||||
flipH,
|
frame,
|
||||||
flipV,
|
flipH,
|
||||||
};
|
flipV,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
throw Error(`Missing texture frame: ${current}`);
|
throw Error(`Missing texture frame: ${current}`);
|
||||||
}
|
}
|
||||||
throw Error("Sprite reference chain is too long");
|
throw Error("Sprite reference chain is too long");
|
||||||
|
|||||||
@@ -105,10 +105,18 @@ describe("scale 32 generation", () => {
|
|||||||
m.visualizations.push({ ...m.visualizations[0], size: 32 });
|
m.visualizations.push({ ...m.visualizations[0], size: 32 });
|
||||||
await expect(generateScale32(await bundle(m))).rejects.toThrow("already");
|
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();
|
const m = metadata();
|
||||||
m.assets.chair_64_a_2_0.source = "missing";
|
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 () => {
|
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,
|
.chair_chair_32_a_0_0,
|
||||||
).toBeDefined();
|
).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");
|
||||||
|
});
|
||||||
@@ -6,10 +6,14 @@ import {
|
|||||||
} from "@/lib/services/swf/nitro-builder";
|
} from "@/lib/services/swf/nitro-builder";
|
||||||
import { object, resolveNitroFrame } from "./nitro-quality";
|
import { object, resolveNitroFrame } from "./nitro-quality";
|
||||||
|
|
||||||
const scaleName = (name: string) =>
|
const scaleName = (name: string) => {
|
||||||
name.replace(/_64_(?=[^_]+_[0-7]_\d+$)/, "_32_");
|
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) =>
|
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 {
|
function halfOffsets(value: unknown): unknown {
|
||||||
if (Array.isArray(value)) return value.map(halfOffsets);
|
if (Array.isArray(value)) return value.map(halfOffsets);
|
||||||
if (!value || typeof value !== "object") return value;
|
if (!value || typeof value !== "object") return value;
|
||||||
@@ -34,9 +38,13 @@ function emptyScaleDeclaration(viz: Record<string, unknown>): boolean {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export async function generateScale32(
|
export async function generateScale32(input: Buffer): Promise<{
|
||||||
input: Buffer,
|
bundle: Buffer;
|
||||||
): Promise<{ bundle: Buffer; before: string; after: string; count: number }> {
|
before: string;
|
||||||
|
after: string;
|
||||||
|
count: number;
|
||||||
|
skipped: number;
|
||||||
|
}> {
|
||||||
const { json, png } = parseNitroBundle(input);
|
const { json, png } = parseNitroBundle(input);
|
||||||
const assets = object(json.assets),
|
const assets = object(json.assets),
|
||||||
sheet = object(json.spritesheet),
|
sheet = object(json.spritesheet),
|
||||||
@@ -54,7 +62,9 @@ export async function generateScale32(
|
|||||||
throw Error("Scale 32 already exists; original artwork is preserved");
|
throw Error("Scale 32 already exists; original artwork is preserved");
|
||||||
const viz = visualizations.find((v) => object(v).size === 64);
|
const viz = visualizations.find((v) => object(v).size === 64);
|
||||||
const names = Object.keys(assets).filter((n) => isScale(n, 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");
|
throw Error("No supported scale 64 furniture sprites were found");
|
||||||
if (names.length > 10000) throw Error("Too many sprites to generate safely");
|
if (names.length > 10000) throw Error("Too many sprites to generate safely");
|
||||||
const dimensions = await sharp(png).metadata();
|
const dimensions = await sharp(png).metadata();
|
||||||
@@ -68,6 +78,7 @@ export async function generateScale32(
|
|||||||
}[] = [];
|
}[] = [];
|
||||||
let before = "";
|
let before = "";
|
||||||
let pixels = 0;
|
let pixels = 0;
|
||||||
|
const skipped = new Set<string>();
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const asset = object(assets[name]);
|
const asset = object(assets[name]);
|
||||||
for (const key of ["x", "y"])
|
for (const key of ["x", "y"])
|
||||||
@@ -76,10 +87,18 @@ export async function generateScale32(
|
|||||||
(typeof asset[key] !== "number" || !Number.isFinite(asset[key]))
|
(typeof asset[key] !== "number" || !Number.isFinite(asset[key]))
|
||||||
)
|
)
|
||||||
throw Error("Invalid sprite offset");
|
throw Error("Invalid sprite offset");
|
||||||
const resolved = resolveNitroFrame(json, name);
|
|
||||||
if (typeof asset.source === "string" && asset.source !== name) {
|
if (typeof asset.source === "string" && asset.source !== name) {
|
||||||
if (!names.includes(asset.source))
|
if (!names.includes(asset.source)) {
|
||||||
throw Error("A sprite references artwork outside scale 64");
|
skipped.add(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let resolved: ReturnType<typeof resolveNitroFrame>;
|
||||||
|
try {
|
||||||
|
resolved = resolveNitroFrame(json, name);
|
||||||
|
} catch (error) {
|
||||||
|
skipped.add(name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const frame = resolved.frame,
|
const frame = resolved.frame,
|
||||||
@@ -149,10 +168,17 @@ export async function generateScale32(
|
|||||||
])
|
])
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.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) {
|
for (const name of names) {
|
||||||
|
if (skipped.has(name)) continue;
|
||||||
const asset = object(halfOffsets(assets[name]));
|
const asset = object(halfOffsets(assets[name]));
|
||||||
if (typeof asset.source === "string")
|
if (typeof asset.source === "string") {
|
||||||
asset.source = scaleName(asset.source);
|
const target = scaleName(asset.source);
|
||||||
|
if (!generated.has(target)) continue;
|
||||||
|
asset.source = target;
|
||||||
|
}
|
||||||
assets[scaleName(name)] = asset;
|
assets[scaleName(name)] = asset;
|
||||||
}
|
}
|
||||||
for (const [name, rect] of atlas.frames) {
|
for (const [name, rect] of atlas.frames) {
|
||||||
@@ -188,6 +214,7 @@ export async function generateScale32(
|
|||||||
bundle: createNitroBundle(json, combined, String(json.name || "furniture")),
|
bundle: createNitroBundle(json, combined, String(json.name || "furniture")),
|
||||||
before,
|
before,
|
||||||
after: `data:image/png;base64,${preview.toString("base64")}`,
|
after: `data:image/png;base64,${preview.toString("base64")}`,
|
||||||
count: names.length,
|
count: images.length,
|
||||||
|
skipped: skipped.size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user