feat(catalog): inspect Nitro scales and preview original sprites
CI / check (push) Successful in 1m47s
CI / deploy (push) Successful in 1m30s
CI / e2e (push) Successful in 22s

This commit is contained in:
Simo committed 2026-09-05 17:22:27 +02:00
1 parent 2619bec165
commit 08b0b5021e
6 files changed
+544 -3

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
import { expect, it } from "vitest";
import { inspectNitroScales, resolveNitroFrame } from "./nitro-quality";
const metadata = () => ({
assets: { chair_64_a_0_0: { x: 2, y: 3 } },
spritesheet: {
frames: { chair_64_a_0_0: { frame: { x: 0, y: 0, w: 10, h: 10 } } },
},
visualizations: [
{
size: 64,
animations: {
0: {
layers: {
0: { frameSequences: { 0: { frames: { 0: { id: 0 } } } } },
},
},
},
},
],
});
it("distinguishes missing 32 from valid authored 64 without generating data", () => {
const data = metadata(),
before = JSON.stringify(data),
report = inspectNitroScales(data, 10, 10);
expect(report.scales.map((s) => s.state)).toEqual(["missing", "present"]);
expect(JSON.stringify(data)).toBe(before);
});
it("detects texture bounds and invalid offsets", () => {
const data = metadata();
data.assets.chair_64_a_0_0.x = NaN;
const report = inspectNitroScales(data, 5, 5).scales[1];
expect(report.issues.map((i) => i.message)).toEqual([
"Invalid sprite offset",
"Texture frame is outside the image bounds",
]);
});
it("resolves referenced sprites and mirrors", () => {
const data = {
...metadata(),
assets: {
...metadata().assets,
chair_32_a_0_0: { source: "chair_64_a_0_0", flipH: true },
},
};
expect(resolveNitroFrame(data, "chair_32_a_0_0")).toMatchObject({
name: "chair_64_a_0_0",
flipH: true,
});
});
it("detects cycles instead of hanging", () => {
expect(() =>
resolveNitroFrame(
{ assets: { a: { source: "b" }, b: { source: "a" } } },
"a",
),
).toThrow("Cyclic");
});
it("detects missing animation frames", () => {
const data = metadata();
data.visualizations[0].animations[0].layers[0].frameSequences[0].frames[0].id = 9;
expect(
inspectNitroScales(data, 10, 10).scales[1].issues[0].message,
).toContain("frame 9");
});
it("does not invent sprites from visualization metadata alone", () => {
expect(
inspectNitroScales({ visualizations: [{ size: 32 }] }, 10, 10).scales[0]
.state,
).toBe("issues");
});
+189
View File
@@ -0,0 +1,189 @@
export interface ScaleIssue {
asset?: string;
message: string;
}
export interface ScaleReport {
size: 32 | 64;
state: "missing" | "issues" | "present";
assets: string[];
animations: string[];
directions: string[];
issues: ScaleIssue[];
}
export interface NitroQuality {
scales: ScaleReport[];
}
export const object = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
export function resolveNitroFrame(
metadata: Record<string, unknown>,
name: string,
): {
name: string;
asset: Record<string, unknown>;
frame: Record<string, unknown>;
flipH: boolean;
flipV: boolean;
} {
const assets = object(metadata.assets),
frames = object(object(metadata.spritesheet).frames),
seen = new Set<string>();
let current = name,
flipH = false,
flipV = false;
for (let depth = 0; depth < 100; depth++) {
if (seen.has(current)) throw Error("Cyclic sprite reference");
seen.add(current);
const asset = object(assets[current]);
flipH = flipH !== Boolean(asset.flipH);
flipV = flipV !== Boolean(asset.flipV);
if (typeof asset.source === "string" && asset.source !== current) {
current = asset.source;
continue;
}
const frame = object(frames[current]);
if (Object.keys(frame).length)
return {
name: current,
asset: object(assets[name]),
frame,
flipH,
flipV,
};
throw Error(`Missing texture frame: ${current}`);
}
throw Error("Sprite reference chain is too long");
}
export function inspectNitroScales(
metadata: Record<string, unknown>,
width: number,
height: number,
): NitroQuality {
const assets = object(metadata.assets),
visualizations = Array.isArray(metadata.visualizations)
? metadata.visualizations
: [];
return {
scales: ([32, 64] as const).map((size) => {
const names = Object.keys(assets)
.filter((name) => name.includes(`_${size}_`))
.sort();
const viz = object(visualizations.find((v) => object(v).size === size)),
issues: ScaleIssue[] = [];
if (names.length && !Object.keys(viz).length)
issues.push({
message: "Sprites exist but the scale visualization is missing",
});
if (!names.length && Object.keys(viz).length)
issues.push({
message:
"Visualization exists but no sprites were found for this scale",
});
for (const name of names) {
const asset = object(assets[name]);
if (
["x", "y"].some(
(key) =>
asset[key] !== undefined &&
(typeof asset[key] !== "number" || !Number.isFinite(asset[key])),
)
)
issues.push({ asset: name, message: "Invalid sprite offset" });
try {
const resolved = resolveNitroFrame(metadata, name),
rect = object(resolved.frame.frame);
if (resolved.name.includes(`_${size === 32 ? 64 : 32}_`))
issues.push({
asset: name,
message:
"Sprite references the other scale; this is not native artwork at this scale",
});
const { x, y, w, h } = rect;
if (
![x, y, w, h].every(
(n) => typeof n === "number" && Number.isInteger(n),
) ||
Number(x) < 0 ||
Number(y) < 0 ||
Number(w) <= 0 ||
Number(h) <= 0 ||
Number(x) + Number(w) > width ||
Number(y) + Number(h) > height
)
issues.push({
asset: name,
message: "Texture frame is outside the image bounds",
});
} catch (error) {
issues.push({ asset: name, message: (error as Error).message });
}
}
const animations = Object.keys(object(viz.animations));
for (const [id, animation] of Object.entries(object(viz.animations))) {
const anim = object(animation);
for (const key of ["transitionTo", "transitionFrom"])
if (
anim[key] !== undefined &&
Number(anim[key]) >= 0 &&
!animations.includes(String(anim[key]))
)
issues.push({
message: `Animation ${id}: missing transition ${anim[key]}`,
});
}
for (const [animationId, animation] of Object.entries(
object(viz.animations),
)) {
for (const [layerId, layer] of Object.entries(
object(object(animation).layers),
)) {
const layerName = /^\d+$/.test(layerId)
? String.fromCharCode(97 + Number(layerId))
: layerId;
const frameIds = new Set<number>();
for (const sequence of Object.values(
object(object(layer).frameSequences),
)) {
for (const frame of Object.values(
object(object(sequence).frames),
)) {
const id = object(frame).id;
if (typeof id === "number" && Number.isInteger(id) && id >= 0)
frameIds.add(id);
}
}
for (const frameId of frameIds)
if (
!names.some(
(name) =>
name.includes(`_${size}_${layerName}_`) &&
name.endsWith(`_${frameId}`),
)
)
issues.push({
message: `Animation ${animationId}: no sprite found for layer ${layerId}, frame ${frameId}`,
});
}
}
const directions = [
...new Set(
names
.map(
(name) => name.match(new RegExp(`_${size}_[^_]+_([0-7])_`))?.[1],
)
.filter((v): v is string => v !== undefined),
),
].sort();
return {
size,
state: issues.length ? "issues" : names.length ? "present" : "missing",
assets: names,
animations,
directions,
issues,
};
}),
};
}