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

@@ -18,6 +18,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { NitroQualityPanel } from "@/components/admin/studio/nitro-quality-panel";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -221,16 +222,16 @@ export function NitroEditorDialog({
}, [open, classname, fetchMetadata]); }, [open, classname, fetchMetadata]);
const handleTabChange = (newTab: string) => { const handleTabChange = (newTab: string) => {
if (activeTab === "json" && newTab === "visual") { if (activeTab === "json" && newTab !== "json") {
try { try {
const parsed = JSON.parse(jsonText); const parsed = JSON.parse(jsonText);
setMetadata(parsed); setMetadata(parsed);
setJsonError(null); setJsonError(null);
} catch { } catch {
toast.error("Invalid JSON — fix it before switching to Visual tab"); toast.error("Invalid JSON — fix it before switching tabs");
return; return;
} }
} else if (activeTab === "visual" && newTab === "json") { } else if (activeTab !== "json" && newTab === "json") {
setJsonText(JSON.stringify(metadata, null, 2)); setJsonText(JSON.stringify(metadata, null, 2));
} }
setActiveTab(newTab); setActiveTab(newTab);
@@ -430,6 +431,7 @@ export function NitroEditorDialog({
<Box className="w-3.5 h-3.5" /> <Box className="w-3.5 h-3.5" />
Visual Visual
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="quality">32 / 64</TabsTrigger>
<TabsTrigger value="json" className="gap-1.5"> <TabsTrigger value="json" className="gap-1.5">
{"{ }"} {"{ }"}
JSON JSON
@@ -437,6 +439,12 @@ export function NitroEditorDialog({
</TabsList> </TabsList>
</div> </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 ── */} {/* ── Visual Tab ── */}
<TabsContent <TabsContent
value="visual" 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);
}
},
);
File diff suppressed because it is too large. Load diff
+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");
});
File diff suppressed because it is too large. Load diff