feat(catalog): inspect Nitro scales and preview original sprites
This commit is contained in:
1 parent
2619bec165
commit
08b0b5021e
6 files changed
+544
-3
No files matched your search
@@ -18,6 +18,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { NitroQualityPanel } from "@/components/admin/studio/nitro-quality-panel";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -221,16 +222,16 @@ export function NitroEditorDialog({
|
||||
}, [open, classname, fetchMetadata]);
|
||||
|
||||
const handleTabChange = (newTab: string) => {
|
||||
if (activeTab === "json" && newTab === "visual") {
|
||||
if (activeTab === "json" && newTab !== "json") {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
setMetadata(parsed);
|
||||
setJsonError(null);
|
||||
} catch {
|
||||
toast.error("Invalid JSON — fix it before switching to Visual tab");
|
||||
toast.error("Invalid JSON — fix it before switching tabs");
|
||||
return;
|
||||
}
|
||||
} else if (activeTab === "visual" && newTab === "json") {
|
||||
} else if (activeTab !== "json" && newTab === "json") {
|
||||
setJsonText(JSON.stringify(metadata, null, 2));
|
||||
}
|
||||
setActiveTab(newTab);
|
||||
@@ -430,6 +431,7 @@ export function NitroEditorDialog({
|
||||
<Box className="w-3.5 h-3.5" />
|
||||
Visual
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="quality">32 / 64</TabsTrigger>
|
||||
<TabsTrigger value="json" className="gap-1.5">
|
||||
{"{ }"}
|
||||
JSON
|
||||
@@ -437,6 +439,12 @@ export function NitroEditorDialog({
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="quality"
|
||||
className="min-h-0 overflow-y-auto flex-1 px-4 sm:px-6 py-4"
|
||||
>
|
||||
<NitroQualityPanel classname={classname} />
|
||||
</TabsContent>
|
||||
{/* ── Visual Tab ── */}
|
||||
<TabsContent
|
||||
value="visual"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import sharp from "sharp";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { createNitroBundle, encodePng } from "@/lib/services/swf/nitro-builder";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ read: vi.fn(), guard: vi.fn() }));
|
||||
vi.mock("node:fs", () => ({ promises: { readFile: mocks.read } }));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (options: unknown, handler: unknown) => {
|
||||
mocks.guard(options);
|
||||
return handler;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/furni-asset-dirs", () => ({
|
||||
getFurniAssetDirs: async () => ({ nitroDir: "/nitro" }),
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
const request = (query: string) =>
|
||||
new NextRequest(`http://localhost/api/admin/studio/nitro-quality?${query}`);
|
||||
beforeEach(() => {
|
||||
mocks.read.mockReset().mockResolvedValue(
|
||||
createNitroBundle(
|
||||
{
|
||||
assets: { chair_32_a_0_0: {} },
|
||||
visualizations: [{ size: 32 }],
|
||||
spritesheet: {
|
||||
frames: { chair_32_a_0_0: { frame: { x: 0, y: 0, w: 2, h: 3 } } },
|
||||
},
|
||||
},
|
||||
encodePng(4, 4, Buffer.alloc(64, 255)),
|
||||
"chair",
|
||||
),
|
||||
);
|
||||
});
|
||||
it("requires import permission", () =>
|
||||
expect(mocks.guard).toHaveBeenCalledWith({
|
||||
permission: PERMS.ASSETS_IMPORT,
|
||||
}));
|
||||
it("rejects path traversal before reading disk", async () => {
|
||||
expect((await GET(request("classname=../secret"))).status).toBe(400);
|
||||
expect(mocks.read).not.toHaveBeenCalled();
|
||||
});
|
||||
it("reports both scales from the actual bundle", async () => {
|
||||
const response = await GET(request("classname=chair"));
|
||||
expect(
|
||||
(await response.json()).report.scales.map(
|
||||
(s: { state: string }) => s.state,
|
||||
),
|
||||
).toEqual(["present", "missing"]);
|
||||
});
|
||||
it("extracts the actual sprite dimensions without resizing", async () => {
|
||||
const response = await GET(request("classname=chair&asset=chair_32_a_0_0"));
|
||||
expect(response.status).toBe(200);
|
||||
const meta = await sharp(
|
||||
Buffer.from(await response.arrayBuffer()),
|
||||
).metadata();
|
||||
expect([meta.width, meta.height]).toEqual([2, 3]);
|
||||
});
|
||||
it("rejects an unknown sprite instead of returning a different one", async () => {
|
||||
expect((await GET(request("classname=chair&asset=other"))).status).toBe(404);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiOk } from "@/lib/api-response";
|
||||
import {
|
||||
inspectNitroScales,
|
||||
object,
|
||||
resolveNitroFrame,
|
||||
} from "@/lib/furni/nitro-quality";
|
||||
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const classname = request.nextUrl.searchParams.get("classname");
|
||||
if (!validateClassnames([classname]))
|
||||
return apiError("Invalid classname", 400);
|
||||
const base = String(classname).split("*")[0],
|
||||
{ nitroDir } = await getFurniAssetDirs();
|
||||
try {
|
||||
const { json, png } = parseNitroBundle(
|
||||
await fs.readFile(path.join(nitroDir, `${base}.nitro`)),
|
||||
);
|
||||
const image = await sharp(png).metadata();
|
||||
if (!image.width || !image.height)
|
||||
return apiError("Invalid bundle texture", 422);
|
||||
const asset = request.nextUrl.searchParams.get("asset");
|
||||
if (asset) {
|
||||
if (!Object.hasOwn(object(json.assets), asset))
|
||||
return apiError("Sprite not found", 404);
|
||||
const resolved = resolveNitroFrame(json, asset),
|
||||
rect = object(resolved.frame.frame);
|
||||
let pipeline = sharp(png).extract({
|
||||
left: Number(rect.x),
|
||||
top: Number(rect.y),
|
||||
width: Number(rect.w),
|
||||
height: Number(rect.h),
|
||||
});
|
||||
if (resolved.frame.rotated) pipeline = pipeline.rotate(270);
|
||||
if (resolved.flipH) pipeline = pipeline.flop();
|
||||
if (resolved.flipV) pipeline = pipeline.flip();
|
||||
const buffer = await pipeline.png().toBuffer();
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
return apiOk({
|
||||
report: inspectNitroScales(json, image.width, image.height),
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
return apiError("Nitro file not found", 404);
|
||||
return apiError("Cannot inspect this Nitro bundle or sprite", 422);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { NitroQuality, ScaleReport } from "@/lib/furni/nitro-quality";
|
||||
|
||||
function ScalePanel({
|
||||
report,
|
||||
classname,
|
||||
revision,
|
||||
}: {
|
||||
report: ScaleReport;
|
||||
classname: string;
|
||||
revision: number;
|
||||
}) {
|
||||
const [asset, setAsset] = useState(report.assets[0] ?? ""),
|
||||
[failed, setFailed] = useState(false);
|
||||
return (
|
||||
<section className="min-w-0 space-y-3 rounded-lg border p-3">
|
||||
<h3 className="font-semibold">
|
||||
Scale {report.size} ·{" "}
|
||||
{report.state === "missing"
|
||||
? "Not included"
|
||||
: report.state === "issues"
|
||||
? "Needs review"
|
||||
: "Present"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{report.assets.length} sprites · {report.animations.length} animation
|
||||
definitions · directions {report.directions.join(", ") || "—"}
|
||||
</p>
|
||||
{report.state === "missing" && (
|
||||
<p className="text-sm">
|
||||
This bundle contains no sprites at this scale. Missing scale 32 does
|
||||
not by itself mean the furniture is unusable.
|
||||
</p>
|
||||
)}
|
||||
{report.assets.length > 0 && (
|
||||
<>
|
||||
<label className="block text-sm">
|
||||
Sprite
|
||||
<select
|
||||
className="mt-1 block w-full rounded border bg-background p-2"
|
||||
value={asset}
|
||||
onChange={(event) => {
|
||||
setAsset(event.target.value);
|
||||
setFailed(false);
|
||||
}}
|
||||
>
|
||||
{report.assets.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30">
|
||||
{failed ? (
|
||||
<p role="alert">This sprite could not be rendered.</p>
|
||||
) : (
|
||||
<img
|
||||
key={`${asset}-${revision}`}
|
||||
src={`/api/admin/studio/nitro-quality?classname=${encodeURIComponent(classname)}&asset=${encodeURIComponent(asset)}&v=${revision}`}
|
||||
alt={asset}
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
maxWidth: "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{report.issues.length > 0 && (
|
||||
<ul className="max-h-40 overflow-auto space-y-2 text-xs">
|
||||
{report.issues.map((issue) => (
|
||||
<li key={`${issue.asset ?? ""}-${issue.message}`}>
|
||||
<strong className="break-all">{issue.asset}</strong>{" "}
|
||||
{issue.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function NitroQualityPanel({ classname }: { classname: string }) {
|
||||
const [report, setReport] = useState<NitroQuality | null>(null),
|
||||
[error, setError] = useState(""),
|
||||
[revision, setRevision] = useState(0);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setReport(null);
|
||||
setError("");
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await adminFetch(
|
||||
`/api/admin/studio/nitro-quality?classname=${encodeURIComponent(classname)}&v=${revision}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || "Inspection failed");
|
||||
if (!controller.signal.aborted) setReport(data.report);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Inspection failed",
|
||||
);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [classname, revision]);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm">
|
||||
Inspect the saved bundle at both scales. Previews show individual
|
||||
sprites, not the assembled furniture or animation playback.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRevision((value) => value + 1)}
|
||||
>
|
||||
Recheck
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert">{error}</p>
|
||||
) : !report ? (
|
||||
<p role="status">Checking scales and texture references…</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{report.scales.map((scale) => (
|
||||
<ScalePanel
|
||||
key={`${scale.size}-${revision}`}
|
||||
report={scale}
|
||||
classname={classname}
|
||||
revision={revision}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user