feat(catalog): generate derived scale 32 with preview and backup restore
This commit is contained in:
1 parent
08b0b5021e
commit
cd75361b9f
9 files changed
+749
-11
No files matched your search
@@ -19,6 +19,7 @@ import {
|
|||||||
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 { NitroQualityPanel } from "@/components/admin/studio/nitro-quality-panel";
|
||||||
|
import { NitroScale32Generator } from "@/components/admin/studio/nitro-scale32-generator";
|
||||||
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 {
|
||||||
@@ -140,6 +141,7 @@ export function NitroEditorDialog({
|
|||||||
const [jsonText, setJsonText] = useState("");
|
const [jsonText, setJsonText] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [bundleRevision, setBundleRevision] = useState(0);
|
||||||
const [activeTab, setActiveTab] = useState<string>("visual");
|
const [activeTab, setActiveTab] = useState<string>("visual");
|
||||||
const [flags, setFlags] = useState({
|
const [flags, setFlags] = useState({
|
||||||
canstandon: false,
|
canstandon: false,
|
||||||
@@ -443,7 +445,19 @@ export function NitroEditorDialog({
|
|||||||
value="quality"
|
value="quality"
|
||||||
className="min-h-0 overflow-y-auto flex-1 px-4 sm:px-6 py-4"
|
className="min-h-0 overflow-y-auto flex-1 px-4 sm:px-6 py-4"
|
||||||
>
|
>
|
||||||
<NitroQualityPanel classname={classname} />
|
<NitroScale32Generator
|
||||||
|
classname={classname}
|
||||||
|
disabled={hasChanges || saving}
|
||||||
|
onChanged={(updated) => {
|
||||||
|
setBundleRevision((value) => value + 1);
|
||||||
|
setMetadata(updated as NitroMetadata);
|
||||||
|
setOriginalMetadata(
|
||||||
|
structuredClone(updated) as NitroMetadata,
|
||||||
|
);
|
||||||
|
setJsonText(JSON.stringify(updated, null, 2));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NitroQualityPanel key={bundleRevision} classname={classname} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{/* ── Visual Tab ── */}
|
{/* ── Visual Tab ── */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
|
|||||||
@@ -105,15 +105,23 @@ export const PUT = withAdmin(
|
|||||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath))
|
if (!existsSync(/*turbopackIgnore: true*/ nitroPath))
|
||||||
return apiError("Nitro file not found", 404);
|
return apiError("Nitro file not found", 404);
|
||||||
|
|
||||||
// 1. Read existing .nitro to extract PNG
|
const saved = await withFurniDataLock(async () => {
|
||||||
const existingBuffer = await fs.readFile(
|
const existingBuffer = await fs.readFile(nitroPath);
|
||||||
/*turbopackIgnore: true*/ nitroPath,
|
const { png, json: currentMetadata } = parseNitroBundle(existingBuffer);
|
||||||
);
|
if (
|
||||||
const { png } = parseNitroBundle(existingBuffer);
|
JSON.stringify(currentMetadata.catalogStudio) !==
|
||||||
|
JSON.stringify(metadata.catalogStudio)
|
||||||
// 2. Rebuild .nitro with updated JSON + same PNG
|
)
|
||||||
const newBundle = createNitroBundle(metadata, png, base);
|
return false;
|
||||||
await fs.writeFile(nitroPath, newBundle);
|
const newBundle = createNitroBundle(metadata, png, base);
|
||||||
|
await fs.writeFile(nitroPath, newBundle);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (!saved)
|
||||||
|
return apiError(
|
||||||
|
"The bundle changed. Reopen the editor before saving.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
|
||||||
// 3. Extract dimensions from metadata
|
// 3. Extract dimensions from metadata
|
||||||
const dims = metadata.logic?.model?.dimensions || { x: 1, y: 1, z: 0 };
|
const dims = metadata.logic?.model?.dimensions || { x: 1, y: 1, z: 0 };
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
read: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
rename: vi.fn(),
|
||||||
|
mkdir: vi.fn(),
|
||||||
|
rm: vi.fn(),
|
||||||
|
generate: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("node:fs", () => ({
|
||||||
|
promises: {
|
||||||
|
readFile: mocks.read,
|
||||||
|
writeFile: mocks.write,
|
||||||
|
rename: mocks.rename,
|
||||||
|
mkdir: mocks.mkdir,
|
||||||
|
rm: mocks.rm,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/api-handler", () => ({
|
||||||
|
withAdmin: (_: unknown, h: unknown) => h,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/furni-asset-dirs", () => ({
|
||||||
|
getFurniAssetDirs: async () => ({ nitroDir: "/nitro" }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/furni-data", () => ({
|
||||||
|
withFurniDataLock: (f: () => Promise<unknown>) => f(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||||
|
withCatalogExport: (f: () => Promise<unknown>) => f(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/furni/nitro-scale32", () => ({
|
||||||
|
generateScale32: mocks.generate,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/swf/nitro-builder", () => ({
|
||||||
|
parseNitroBundle: () => ({ json: { assets: {} } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
const original = Buffer.from("original"),
|
||||||
|
generated = Buffer.from("generated"),
|
||||||
|
hash = (b: Buffer) => createHash("sha256").update(b).digest("hex");
|
||||||
|
const request = (body: object) =>
|
||||||
|
new NextRequest("http://localhost/api/admin/studio/nitro-scale32", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ classname: "chair", ...body }),
|
||||||
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.read.mockResolvedValue(original);
|
||||||
|
mocks.generate.mockResolvedValue({
|
||||||
|
bundle: generated,
|
||||||
|
before: "before",
|
||||||
|
after: "after",
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("preview does not write anything", async () => {
|
||||||
|
expect((await POST(request({ action: "preview" }))).status).toBe(200);
|
||||||
|
expect(mocks.write).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("rejects stale source before generation or writes", async () => {
|
||||||
|
expect(
|
||||||
|
(await POST(request({ action: "apply", expectedHash: hash(generated) })))
|
||||||
|
.status,
|
||||||
|
).toBe(409);
|
||||||
|
expect(mocks.generate).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.write).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("backs up the original before replacing the bundle", async () => {
|
||||||
|
expect(
|
||||||
|
(await POST(request({ action: "apply", expectedHash: hash(original) })))
|
||||||
|
.status,
|
||||||
|
).toBe(200);
|
||||||
|
expect(mocks.write.mock.calls[0][1]).toEqual(original);
|
||||||
|
expect(mocks.write.mock.calls[1][1]).toEqual(generated);
|
||||||
|
expect(mocks.rename).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
it("restores the exact backup after checking the current hash", async () => {
|
||||||
|
mocks.read.mockResolvedValueOnce(generated).mockResolvedValueOnce(original);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await POST(
|
||||||
|
request({
|
||||||
|
action: "restore",
|
||||||
|
expectedHash: hash(generated),
|
||||||
|
backupHash: hash(original),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
expect(mocks.write.mock.calls[0][1]).toEqual(original);
|
||||||
|
});
|
||||||
|
it("does not replace the live file when backup writing fails", async () => {
|
||||||
|
mocks.write.mockRejectedValueOnce(Error("disk full"));
|
||||||
|
expect(
|
||||||
|
(await POST(request({ action: "apply", expectedHash: hash(original) })))
|
||||||
|
.status,
|
||||||
|
).toBe(422);
|
||||||
|
expect(mocks.rename).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
|
import { apiError, apiOk } from "@/lib/api-response";
|
||||||
|
import { generateScale32 } from "@/lib/furni/nitro-scale32";
|
||||||
|
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||||
|
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||||
|
import { withFurniDataLock } from "@/lib/services/furni-data";
|
||||||
|
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||||
|
|
||||||
|
const hash = (data: Buffer) => createHash("sha256").update(data).digest("hex");
|
||||||
|
export const POST = withAdmin(
|
||||||
|
{ permission: PERMS.ASSETS_IMPORT },
|
||||||
|
async (request) => {
|
||||||
|
const body = await request.json();
|
||||||
|
if (
|
||||||
|
!validateClassnames([body.classname]) ||
|
||||||
|
!["preview", "apply", "restore"].includes(body.action)
|
||||||
|
)
|
||||||
|
return apiError("Invalid request", 400);
|
||||||
|
if (
|
||||||
|
body.action !== "preview" &&
|
||||||
|
!/^[a-f0-9]{64}$/.test(body.expectedHash || "")
|
||||||
|
)
|
||||||
|
return apiError("Preview the current bundle first", 400);
|
||||||
|
if (
|
||||||
|
body.action === "restore" &&
|
||||||
|
!/^[a-f0-9]{64}$/.test(body.backupHash || "")
|
||||||
|
)
|
||||||
|
return apiError("Invalid backup", 400);
|
||||||
|
const { nitroDir } = await getFurniAssetDirs(),
|
||||||
|
base = body.classname.split("*")[0],
|
||||||
|
file = path.join(nitroDir, `${base}.nitro`);
|
||||||
|
const execute = () =>
|
||||||
|
withFurniDataLock(async () => {
|
||||||
|
const original = await fs.readFile(file),
|
||||||
|
originalHash = hash(original);
|
||||||
|
if (body.action !== "preview" && originalHash !== body.expectedHash)
|
||||||
|
return apiError(
|
||||||
|
"The bundle changed. Reopen the editor and preview again.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
const backupDir = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"storage",
|
||||||
|
"nitro-scale32-backups",
|
||||||
|
);
|
||||||
|
if (body.action === "restore") {
|
||||||
|
const backup = await fs.readFile(
|
||||||
|
path.join(backupDir, `${base}.${body.backupHash}.nitro`),
|
||||||
|
);
|
||||||
|
if (hash(backup) !== body.backupHash)
|
||||||
|
return apiError("Backup integrity check failed", 422);
|
||||||
|
const temp = `${file}.${randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(temp, backup, { flag: "wx" });
|
||||||
|
await fs.rename(temp, file);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(temp, { force: true });
|
||||||
|
}
|
||||||
|
return apiOk({
|
||||||
|
hash: hash(backup),
|
||||||
|
metadata: parseNitroBundle(backup).json,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const generated = await generateScale32(original);
|
||||||
|
if (body.action === "preview")
|
||||||
|
return apiOk({
|
||||||
|
hash: originalHash,
|
||||||
|
before: generated.before,
|
||||||
|
after: generated.after,
|
||||||
|
count: generated.count,
|
||||||
|
});
|
||||||
|
await fs.mkdir(backupDir, { recursive: true });
|
||||||
|
const backupFile = path.join(
|
||||||
|
backupDir,
|
||||||
|
`${base}.${originalHash}.nitro`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await fs.writeFile(backupFile, original, { flag: "wx" });
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||||
|
if (hash(await fs.readFile(backupFile)) !== originalHash)
|
||||||
|
throw Error("Backup integrity check failed");
|
||||||
|
}
|
||||||
|
const temp = `${file}.${randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(temp, generated.bundle, { flag: "wx" });
|
||||||
|
await fs.rename(temp, file);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(temp, { force: true });
|
||||||
|
}
|
||||||
|
return apiOk({
|
||||||
|
hash: hash(generated.bundle),
|
||||||
|
backupHash: originalHash,
|
||||||
|
metadata: parseNitroBundle(generated.bundle).json,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await (body.action === "preview"
|
||||||
|
? execute()
|
||||||
|
: withCatalogExport(execute));
|
||||||
|
} catch (error) {
|
||||||
|
return apiError(
|
||||||
|
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||||
|
? "Bundle or backup not found"
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Scale generation failed",
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -23,7 +23,9 @@ function ScalePanel({
|
|||||||
? "Not included"
|
? "Not included"
|
||||||
: report.state === "issues"
|
: report.state === "issues"
|
||||||
? "Needs review"
|
? "Needs review"
|
||||||
: "Present"}
|
: report.derived
|
||||||
|
? "Derived from 64"
|
||||||
|
: "Present"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{report.assets.length} sprites · {report.animations.length} animation
|
{report.assets.length} sprites · {report.animations.length} animation
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
|
export function NitroScale32Generator({
|
||||||
|
classname,
|
||||||
|
disabled,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
classname: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onChanged: (metadata: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const [preview, setPreview] = useState<{
|
||||||
|
hash: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
count: number;
|
||||||
|
} | null>(null),
|
||||||
|
[undo, setUndo] = useState<{ hash: string; backupHash: string } | null>(
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
[busy, setBusy] = useState(false),
|
||||||
|
[error, setError] = useState("");
|
||||||
|
async function run(action: "preview" | "apply" | "restore") {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await adminFetch("/api/admin/studio/nitro-scale32", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
classname,
|
||||||
|
action,
|
||||||
|
expectedHash: action === "restore" ? undo?.hash : preview?.hash,
|
||||||
|
backupHash: undo?.backupHash,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw Error(data.error || "Generation failed");
|
||||||
|
if (action === "preview") setPreview(data);
|
||||||
|
else {
|
||||||
|
setPreview(null);
|
||||||
|
setUndo(
|
||||||
|
action === "apply"
|
||||||
|
? { hash: data.hash, backupHash: data.backupHash }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
onChanged(data.metadata);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Generation failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="space-y-3 rounded-lg border p-3">
|
||||||
|
<h3 className="font-semibold">Generate scale 32 from 64</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Adds derived artwork using nearest-neighbor reduction. Existing scale 32
|
||||||
|
is preserved. Preview uses the saved bundle and shows one sprite at its
|
||||||
|
original pixel size.
|
||||||
|
</p>
|
||||||
|
{disabled && (
|
||||||
|
<p className="text-sm">Save or reset your pending edits first.</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onClick={() => run("preview")}
|
||||||
|
>
|
||||||
|
{busy ? "Working…" : "Preview generation"}
|
||||||
|
</Button>
|
||||||
|
{preview && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm">
|
||||||
|
{preview.count} sprite entries will be added. Check positioning and
|
||||||
|
animations in the client after saving.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
["Original 64", preview.before],
|
||||||
|
["Derived 32", preview.after],
|
||||||
|
].map(([label, src]) => (
|
||||||
|
<figure key={label} className="min-w-0">
|
||||||
|
<figcaption>{label}</figcaption>
|
||||||
|
<div className="overflow-auto p-3">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={label}
|
||||||
|
style={{ imageRendering: "pixelated", maxWidth: "none" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button disabled={busy || disabled} onClick={() => run("apply")}>
|
||||||
|
Save with backup
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setPreview(null)}
|
||||||
|
>
|
||||||
|
Discard preview
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{undo && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">
|
||||||
|
Saved. A copy of the original bundle is preserved.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onClick={() => run("restore")}
|
||||||
|
>
|
||||||
|
Restore original bundle
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export interface ScaleIssue {
|
|||||||
}
|
}
|
||||||
export interface ScaleReport {
|
export interface ScaleReport {
|
||||||
size: 32 | 64;
|
size: 32 | 64;
|
||||||
|
derived?: boolean;
|
||||||
state: "missing" | "issues" | "present";
|
state: "missing" | "issues" | "present";
|
||||||
assets: string[];
|
assets: string[];
|
||||||
animations: string[];
|
animations: string[];
|
||||||
@@ -178,6 +179,9 @@ export function inspectNitroScales(
|
|||||||
].sort();
|
].sort();
|
||||||
return {
|
return {
|
||||||
size,
|
size,
|
||||||
|
derived:
|
||||||
|
size === 32 &&
|
||||||
|
object(object(metadata.catalogStudio).scale32).derivedFrom === 64,
|
||||||
state: issues.length ? "issues" : names.length ? "present" : "missing",
|
state: issues.length ? "issues" : names.length ? "present" : "missing",
|
||||||
assets: names,
|
assets: names,
|
||||||
animations,
|
animations,
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createNitroBundle,
|
||||||
|
parseNitroBundle,
|
||||||
|
} from "@/lib/services/swf/nitro-builder";
|
||||||
|
import { generateScale32 } from "./nitro-scale32";
|
||||||
|
|
||||||
|
const metadata = () => ({
|
||||||
|
name: "chair",
|
||||||
|
assets: {
|
||||||
|
chair_64_a_0_0: { x: -5, y: 7 },
|
||||||
|
chair_64_a_2_0: { source: "chair_64_a_0_0", x: 5, y: 7, flipH: true },
|
||||||
|
},
|
||||||
|
visualizations: [
|
||||||
|
{
|
||||||
|
size: 64,
|
||||||
|
layerCount: 1,
|
||||||
|
layers: { 0: { x: 6, y: -4, z: 3 } },
|
||||||
|
animations: {
|
||||||
|
0: {
|
||||||
|
layers: {
|
||||||
|
0: {
|
||||||
|
frameRepeat: 4,
|
||||||
|
frameSequences: {
|
||||||
|
0: { frames: { 0: { id: 0, x: 6, y: 4, randomX: 8 } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
logic: { model: { dimensions: { x: 2, y: 1, z: 1 } } },
|
||||||
|
spritesheet: {
|
||||||
|
frames: {
|
||||||
|
chair_64_a_0_0: {
|
||||||
|
frame: { x: 0, y: 0, w: 6, h: 8 },
|
||||||
|
rotated: false,
|
||||||
|
trimmed: false,
|
||||||
|
spriteSourceSize: { x: 0, y: 0, w: 6, h: 8 },
|
||||||
|
sourceSize: { w: 6, h: 8 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: { size: { w: 6, h: 8 }, scale: "1" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
async function bundle(json = metadata()) {
|
||||||
|
return createNitroBundle(
|
||||||
|
json,
|
||||||
|
await sharp({
|
||||||
|
create: { width: 6, height: 8, channels: 4, background: "#ff0000" },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer(),
|
||||||
|
"chair",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
describe("scale 32 generation", () => {
|
||||||
|
it("preserves originals and adds half-size artwork and offsets without changing timing or geometry", async () => {
|
||||||
|
const source = await bundle();
|
||||||
|
const result = await generateScale32(source);
|
||||||
|
const { json, png } = parseNitroBundle(result.bundle);
|
||||||
|
const m = json as ReturnType<typeof metadata>;
|
||||||
|
expect(m.assets.chair_64_a_0_0).toEqual(metadata().assets.chair_64_a_0_0);
|
||||||
|
expect(m.logic).toEqual(metadata().logic);
|
||||||
|
expect(
|
||||||
|
(json.assets as Record<string, unknown>).chair_32_a_0_0,
|
||||||
|
).toMatchObject({ x: -2, y: 4 });
|
||||||
|
expect(
|
||||||
|
(json.assets as Record<string, unknown>).chair_32_a_2_0,
|
||||||
|
).toMatchObject({
|
||||||
|
source: "chair_32_a_0_0",
|
||||||
|
flipH: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(json.spritesheet as { frames: Record<string, { frame: unknown }> })
|
||||||
|
.frames.chair_32_a_0_0.frame,
|
||||||
|
).toMatchObject({ w: 3, h: 4 });
|
||||||
|
expect(m.visualizations[1]).toMatchObject({
|
||||||
|
size: 32,
|
||||||
|
layers: { 0: { x: 3, y: -2, z: 3 } },
|
||||||
|
animations: {
|
||||||
|
0: {
|
||||||
|
layers: {
|
||||||
|
0: {
|
||||||
|
frameRepeat: 4,
|
||||||
|
frameSequences: {
|
||||||
|
0: { frames: { 0: { id: 0, x: 3, y: 2, randomX: 4 } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await sharp(png)
|
||||||
|
.extract({ left: 0, top: 0, width: 6, height: 8 })
|
||||||
|
.raw()
|
||||||
|
.toBuffer(),
|
||||||
|
).toEqual(await sharp(parseNitroBundle(source).png).raw().toBuffer());
|
||||||
|
});
|
||||||
|
it("never overwrites a native or partial scale32", async () => {
|
||||||
|
const m = metadata();
|
||||||
|
m.visualizations.push({ ...m.visualizations[0], size: 32 });
|
||||||
|
await expect(generateScale32(await bundle(m))).rejects.toThrow("already");
|
||||||
|
});
|
||||||
|
it("rejects missing sprite references", async () => {
|
||||||
|
const m = metadata();
|
||||||
|
m.assets.chair_64_a_2_0.source = "missing";
|
||||||
|
await expect(generateScale32(await bundle(m))).rejects.toThrow("Missing");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("restores trimmed sprite geometry before reducing it", async () => {
|
||||||
|
const png = await sharp({
|
||||||
|
create: { width: 2, height: 2, channels: 4, background: "#00ff00" },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const source = createNitroBundle(
|
||||||
|
{
|
||||||
|
name: "trim",
|
||||||
|
assets: { trim_64_a_0_0: { x: 4, y: 6 } },
|
||||||
|
visualizations: [{ size: 64 }],
|
||||||
|
spritesheet: {
|
||||||
|
frames: {
|
||||||
|
trim_64_a_0_0: {
|
||||||
|
frame: { x: 0, y: 0, w: 2, h: 2 },
|
||||||
|
trimmed: true,
|
||||||
|
sourceSize: { w: 6, h: 8 },
|
||||||
|
spriteSourceSize: { x: 2, y: 4, w: 2, h: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
png,
|
||||||
|
"trim",
|
||||||
|
);
|
||||||
|
const generated = await generateScale32(source);
|
||||||
|
const { json } = parseNitroBundle(generated.bundle);
|
||||||
|
expect(
|
||||||
|
(json.spritesheet as { frames: Record<string, { sourceSize: unknown }> })
|
||||||
|
.frames.trim_32_a_0_0.sourceSize,
|
||||||
|
).toEqual({ w: 3, h: 4 });
|
||||||
|
const pixels = await sharp(
|
||||||
|
Buffer.from(generated.after.split(",")[1], "base64"),
|
||||||
|
)
|
||||||
|
.raw()
|
||||||
|
.toBuffer();
|
||||||
|
expect([...pixels.subarray((2 * 3 + 1) * 4, (2 * 3 + 1) * 4 + 4)]).toEqual([
|
||||||
|
0, 255, 0, 255,
|
||||||
|
]);
|
||||||
|
expect(pixels[3]).toBe(0);
|
||||||
|
});
|
||||||
|
it("unrotates atlas artwork before generating the smaller frame", async () => {
|
||||||
|
const source = createNitroBundle(
|
||||||
|
{
|
||||||
|
name: "rot",
|
||||||
|
assets: { rot_64_a_0_0: {} },
|
||||||
|
visualizations: [{ size: 64 }],
|
||||||
|
spritesheet: {
|
||||||
|
frames: {
|
||||||
|
rot_64_a_0_0: { frame: { x: 0, y: 0, w: 2, h: 6 }, rotated: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
await sharp({
|
||||||
|
create: { width: 2, height: 6, channels: 4, background: "#ff0000" },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer(),
|
||||||
|
"rot",
|
||||||
|
);
|
||||||
|
const generated = await generateScale32(source);
|
||||||
|
const size = await sharp(
|
||||||
|
Buffer.from(generated.after.split(",")[1], "base64"),
|
||||||
|
).metadata();
|
||||||
|
expect([size.width, size.height]).toEqual([3, 1]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
import {
|
||||||
|
buildSpritesheet,
|
||||||
|
createNitroBundle,
|
||||||
|
parseNitroBundle,
|
||||||
|
} from "@/lib/services/swf/nitro-builder";
|
||||||
|
import { object, resolveNitroFrame } from "./nitro-quality";
|
||||||
|
|
||||||
|
const scaleName = (name: string) =>
|
||||||
|
name.replace(/_64_(?=[^_]+_[0-7]_\d+$)/, "_32_");
|
||||||
|
const isScale = (name: string, size: number) =>
|
||||||
|
new RegExp(`_${size}_[^_]+_[0-7]_\\d+$`).test(name);
|
||||||
|
function halfOffsets(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(halfOffsets);
|
||||||
|
if (!value || typeof value !== "object") return value;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, v]) => [
|
||||||
|
key,
|
||||||
|
["x", "y", "randomX", "randomY"].includes(key) && typeof v === "number"
|
||||||
|
? Math.round(v / 2)
|
||||||
|
: halfOffsets(v),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export async function generateScale32(
|
||||||
|
input: Buffer,
|
||||||
|
): Promise<{ bundle: Buffer; before: string; after: string; count: number }> {
|
||||||
|
const { json, png } = parseNitroBundle(input);
|
||||||
|
const assets = object(json.assets),
|
||||||
|
sheet = object(json.spritesheet),
|
||||||
|
frames = object(sheet.frames);
|
||||||
|
const visualizations = Array.isArray(json.visualizations)
|
||||||
|
? json.visualizations
|
||||||
|
: [];
|
||||||
|
if (
|
||||||
|
Object.keys(assets).some((n) => isScale(n, 32)) ||
|
||||||
|
Object.keys(frames).some((n) => isScale(n, 32)) ||
|
||||||
|
visualizations.some((v) => object(v).size === 32)
|
||||||
|
)
|
||||||
|
throw Error("Scale 32 already exists; original artwork is preserved");
|
||||||
|
const viz = visualizations.find((v) => object(v).size === 64);
|
||||||
|
const names = Object.keys(assets).filter((n) => isScale(n, 64));
|
||||||
|
if (!viz || !names.length)
|
||||||
|
throw Error("No supported scale 64 furniture sprites were found");
|
||||||
|
if (names.length > 10000) throw Error("Too many sprites to generate safely");
|
||||||
|
const dimensions = await sharp(png).metadata();
|
||||||
|
if (!dimensions.width || !dimensions.height) throw Error("Invalid texture");
|
||||||
|
const images: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
pixels: Buffer;
|
||||||
|
}[] = [];
|
||||||
|
let before = "";
|
||||||
|
let pixels = 0;
|
||||||
|
for (const name of names) {
|
||||||
|
const asset = object(assets[name]);
|
||||||
|
for (const key of ["x", "y"])
|
||||||
|
if (
|
||||||
|
asset[key] !== undefined &&
|
||||||
|
(typeof asset[key] !== "number" || !Number.isFinite(asset[key]))
|
||||||
|
)
|
||||||
|
throw Error("Invalid sprite offset");
|
||||||
|
resolveNitroFrame(json, name);
|
||||||
|
if (typeof asset.source === "string" && asset.source !== name) {
|
||||||
|
if (!names.includes(asset.source))
|
||||||
|
throw Error("A sprite references artwork outside scale 64");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const frame = object(frames[name]),
|
||||||
|
rect = object(frame.frame);
|
||||||
|
let sprite = await sharp(png)
|
||||||
|
.extract({
|
||||||
|
left: Number(rect.x),
|
||||||
|
top: Number(rect.y),
|
||||||
|
width: Number(rect.w),
|
||||||
|
height: Number(rect.h),
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
if (frame.rotated)
|
||||||
|
sprite = await sharp(sprite).rotate(270).png().toBuffer();
|
||||||
|
const size = await sharp(sprite).metadata();
|
||||||
|
if (frame.trimmed) {
|
||||||
|
const original = object(frame.sourceSize),
|
||||||
|
offset = object(frame.spriteSourceSize);
|
||||||
|
sprite = await sharp(sprite)
|
||||||
|
.extend({
|
||||||
|
left: Number(offset.x),
|
||||||
|
top: Number(offset.y),
|
||||||
|
right: Number(original.w) - Number(offset.x) - Number(size.width),
|
||||||
|
bottom: Number(original.h) - Number(offset.y) - Number(size.height),
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
const full = await sharp(sprite).metadata();
|
||||||
|
const width = Math.max(1, Math.round(Number(full.width) / 2)),
|
||||||
|
height = Math.max(1, Math.round(Number(full.height) / 2));
|
||||||
|
pixels += width * height;
|
||||||
|
if (pixels > 16_000_000)
|
||||||
|
throw Error("Generated sprites exceed the texture budget");
|
||||||
|
if (!before) before = `data:image/png;base64,${sprite.toString("base64")}`;
|
||||||
|
images.push({
|
||||||
|
id: images.length,
|
||||||
|
name: scaleName(name),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pixels: await sharp(sprite)
|
||||||
|
.resize(width, height, { kernel: "nearest" })
|
||||||
|
.ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!images.length) throw Error("No independent texture frames were found");
|
||||||
|
const atlas = buildSpritesheet(images);
|
||||||
|
const width = Math.max(dimensions.width, atlas.width),
|
||||||
|
height = dimensions.height + atlas.height;
|
||||||
|
if (width * height > 64_000_000)
|
||||||
|
throw Error("Combined texture exceeds the texture budget");
|
||||||
|
const combined = await sharp({
|
||||||
|
create: {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.composite([
|
||||||
|
{ input: png, left: 0, top: 0 },
|
||||||
|
{ input: atlas.png, left: 0, top: dimensions.height },
|
||||||
|
])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
for (const name of names) {
|
||||||
|
const asset = object(halfOffsets(assets[name]));
|
||||||
|
if (typeof asset.source === "string")
|
||||||
|
asset.source = scaleName(asset.source);
|
||||||
|
assets[scaleName(name)] = asset;
|
||||||
|
}
|
||||||
|
for (const [name, rect] of atlas.frames) {
|
||||||
|
frames[name] = {
|
||||||
|
frame: { ...rect, y: rect.y + dimensions.height },
|
||||||
|
rotated: false,
|
||||||
|
trimmed: false,
|
||||||
|
spriteSourceSize: { x: 0, y: 0, w: rect.w, h: rect.h },
|
||||||
|
sourceSize: { w: rect.w, h: rect.h },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
json.assets = assets;
|
||||||
|
json.visualizations = [
|
||||||
|
...visualizations,
|
||||||
|
{ ...object(halfOffsets(viz)), size: 32 },
|
||||||
|
];
|
||||||
|
json.spritesheet = {
|
||||||
|
...sheet,
|
||||||
|
frames,
|
||||||
|
meta: { ...object(sheet.meta), size: { w: width, h: height } },
|
||||||
|
};
|
||||||
|
json.catalogStudio = {
|
||||||
|
...object(json.catalogStudio),
|
||||||
|
scale32: { derivedFrom: 64, method: "nearest", version: 1 },
|
||||||
|
};
|
||||||
|
const first = images[0];
|
||||||
|
const preview = await sharp(first.pixels, {
|
||||||
|
raw: { width: first.width, height: first.height, channels: 4 },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
return {
|
||||||
|
bundle: createNitroBundle(json, combined, String(json.name || "furniture")),
|
||||||
|
before,
|
||||||
|
after: `data:image/png;base64,${preview.toString("base64")}`,
|
||||||
|
count: names.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user