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);
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user