From 08b0b5021e188d4171e6452324bffe6dfdb33587 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 5 Sep 2026 17:22:27 +0200 Subject: [PATCH] feat(catalog): inspect Nitro scales and preview original sprites --- .../import/furni/nitro-editor-dialog.tsx | 14 +- .../admin/studio/nitro-quality/route.test.ts | 64 ++++++ .../api/admin/studio/nitro-quality/route.ts | 62 ++++++ .../admin/studio/nitro-quality-panel.tsx | 147 ++++++++++++++ src/lib/furni/nitro-quality.test.ts | 71 +++++++ src/lib/furni/nitro-quality.ts | 189 ++++++++++++++++++ 6 files changed, 544 insertions(+), 3 deletions(-) create mode 100644 src/app/api/admin/studio/nitro-quality/route.test.ts create mode 100644 src/app/api/admin/studio/nitro-quality/route.ts create mode 100644 src/components/admin/studio/nitro-quality-panel.tsx create mode 100644 src/lib/furni/nitro-quality.test.ts create mode 100644 src/lib/furni/nitro-quality.ts diff --git a/src/app/admin/import/furni/nitro-editor-dialog.tsx b/src/app/admin/import/furni/nitro-editor-dialog.tsx index a4f0f8e973..5ca3a9a19c 100644 --- a/src/app/admin/import/furni/nitro-editor-dialog.tsx +++ b/src/app/admin/import/furni/nitro-editor-dialog.tsx @@ -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({ Visual + 32 / 64 {"{ }"} JSON @@ -437,6 +439,12 @@ export function NitroEditorDialog({ + + + {/* ── Visual Tab ── */} ({ 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); +}); diff --git a/src/app/api/admin/studio/nitro-quality/route.ts b/src/app/api/admin/studio/nitro-quality/route.ts new file mode 100644 index 0000000000..cdd2086843 --- /dev/null +++ b/src/app/api/admin/studio/nitro-quality/route.ts @@ -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); + } + }, +); diff --git a/src/components/admin/studio/nitro-quality-panel.tsx b/src/components/admin/studio/nitro-quality-panel.tsx new file mode 100644 index 0000000000..26c3916e5b --- /dev/null +++ b/src/components/admin/studio/nitro-quality-panel.tsx @@ -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 ( +
+

+ Scale {report.size} ·{" "} + {report.state === "missing" + ? "Not included" + : report.state === "issues" + ? "Needs review" + : "Present"} +

+

+ {report.assets.length} sprites · {report.animations.length} animation + definitions · directions {report.directions.join(", ") || "—"} +

+ {report.state === "missing" && ( +

+ This bundle contains no sprites at this scale. Missing scale 32 does + not by itself mean the furniture is unusable. +

+ )} + {report.assets.length > 0 && ( + <> + +
+ {failed ? ( +

This sprite could not be rendered.

+ ) : ( + {asset} setFailed(true)} + /> + )} +
+ + )} + {report.issues.length > 0 && ( +
    + {report.issues.map((issue) => ( +
  • + {issue.asset}{" "} + {issue.message} +
  • + ))} +
+ )} +
+ ); +} +export function NitroQualityPanel({ classname }: { classname: string }) { + const [report, setReport] = useState(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 ( +
+
+

+ Inspect the saved bundle at both scales. Previews show individual + sprites, not the assembled furniture or animation playback. +

+ +
+ {error ? ( +

{error}

+ ) : !report ? ( +

Checking scales and texture references…

+ ) : ( +
+ {report.scales.map((scale) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/lib/furni/nitro-quality.test.ts b/src/lib/furni/nitro-quality.test.ts new file mode 100644 index 0000000000..e82a908134 --- /dev/null +++ b/src/lib/furni/nitro-quality.test.ts @@ -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"); +}); diff --git a/src/lib/furni/nitro-quality.ts b/src/lib/furni/nitro-quality.ts new file mode 100644 index 0000000000..20722aefc5 --- /dev/null +++ b/src/lib/furni/nitro-quality.ts @@ -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 => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +export function resolveNitroFrame( + metadata: Record, + name: string, +): { + name: string; + asset: Record; + frame: Record; + flipH: boolean; + flipV: boolean; +} { + const assets = object(metadata.assets), + frames = object(object(metadata.spritesheet).frames), + seen = new Set(); + 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, + 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(); + 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, + }; + }), + }; +}