From cd75361b9fe8f0e25f5b3f6b08437a0aef765684 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 5 Sep 2026 17:36:22 +0200 Subject: [PATCH] feat(catalog): generate derived scale 32 with preview and backup restore --- .../import/furni/nitro-editor-dialog.tsx | 16 +- .../admin/import/furni/nitro-editor/route.ts | 26 ++- .../admin/studio/nitro-scale32/route.test.ts | 104 ++++++++++ .../api/admin/studio/nitro-scale32/route.ts | 117 ++++++++++++ .../admin/studio/nitro-quality-panel.tsx | 4 +- .../admin/studio/nitro-scale32-generator.tsx | 131 +++++++++++++ src/lib/furni/nitro-quality.ts | 4 + src/lib/furni/nitro-scale32.test.ts | 179 ++++++++++++++++++ src/lib/furni/nitro-scale32.ts | 179 ++++++++++++++++++ 9 files changed, 749 insertions(+), 11 deletions(-) create mode 100644 src/app/api/admin/studio/nitro-scale32/route.test.ts create mode 100644 src/app/api/admin/studio/nitro-scale32/route.ts create mode 100644 src/components/admin/studio/nitro-scale32-generator.tsx create mode 100644 src/lib/furni/nitro-scale32.test.ts create mode 100644 src/lib/furni/nitro-scale32.ts diff --git a/src/app/admin/import/furni/nitro-editor-dialog.tsx b/src/app/admin/import/furni/nitro-editor-dialog.tsx index 5ca3a9a1..4b3991b4 100644 --- a/src/app/admin/import/furni/nitro-editor-dialog.tsx +++ b/src/app/admin/import/furni/nitro-editor-dialog.tsx @@ -19,6 +19,7 @@ import { import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; 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 { Button } from "@/components/ui/button"; import { @@ -140,6 +141,7 @@ export function NitroEditorDialog({ const [jsonText, setJsonText] = useState(""); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); + const [bundleRevision, setBundleRevision] = useState(0); const [activeTab, setActiveTab] = useState("visual"); const [flags, setFlags] = useState({ canstandon: false, @@ -443,7 +445,19 @@ export function NitroEditorDialog({ value="quality" className="min-h-0 overflow-y-auto flex-1 px-4 sm:px-6 py-4" > - + { + setBundleRevision((value) => value + 1); + setMetadata(updated as NitroMetadata); + setOriginalMetadata( + structuredClone(updated) as NitroMetadata, + ); + setJsonText(JSON.stringify(updated, null, 2)); + }} + /> + {/* ── Visual Tab ── */} { + const existingBuffer = await fs.readFile(nitroPath); + const { png, json: currentMetadata } = parseNitroBundle(existingBuffer); + if ( + JSON.stringify(currentMetadata.catalogStudio) !== + JSON.stringify(metadata.catalogStudio) + ) + return false; + 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 const dims = metadata.logic?.model?.dimensions || { x: 1, y: 1, z: 0 }; diff --git a/src/app/api/admin/studio/nitro-scale32/route.test.ts b/src/app/api/admin/studio/nitro-scale32/route.test.ts new file mode 100644 index 00000000..21348a96 --- /dev/null +++ b/src/app/api/admin/studio/nitro-scale32/route.test.ts @@ -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) => f(), +})); +vi.mock("@/lib/services/catalog-git-queue", () => ({ + withCatalogExport: (f: () => Promise) => 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(); +}); diff --git a/src/app/api/admin/studio/nitro-scale32/route.ts b/src/app/api/admin/studio/nitro-scale32/route.ts new file mode 100644 index 00000000..f06e337b --- /dev/null +++ b/src/app/api/admin/studio/nitro-scale32/route.ts @@ -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, + ); + } + }, +); diff --git a/src/components/admin/studio/nitro-quality-panel.tsx b/src/components/admin/studio/nitro-quality-panel.tsx index 26c3916e..ba02698c 100644 --- a/src/components/admin/studio/nitro-quality-panel.tsx +++ b/src/components/admin/studio/nitro-quality-panel.tsx @@ -23,7 +23,9 @@ function ScalePanel({ ? "Not included" : report.state === "issues" ? "Needs review" - : "Present"} + : report.derived + ? "Derived from 64" + : "Present"}

{report.assets.length} sprites · {report.animations.length} animation diff --git a/src/components/admin/studio/nitro-scale32-generator.tsx b/src/components/admin/studio/nitro-scale32-generator.tsx new file mode 100644 index 00000000..75927bc6 --- /dev/null +++ b/src/components/admin/studio/nitro-scale32-generator.tsx @@ -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) => 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 ( +

+

Generate scale 32 from 64

+

+ 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. +

+ {disabled && ( +

Save or reset your pending edits first.

+ )} + + {preview && ( + <> +

+ {preview.count} sprite entries will be added. Check positioning and + animations in the client after saving. +

+
+ {[ + ["Original 64", preview.before], + ["Derived 32", preview.after], + ].map(([label, src]) => ( +
+
{label}
+
+ {label} +
+
+ ))} +
+ + + + )} + {undo && ( +
+

+ Saved. A copy of the original bundle is preserved. +

+ +
+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/lib/furni/nitro-quality.ts b/src/lib/furni/nitro-quality.ts index 20722aef..68626e68 100644 --- a/src/lib/furni/nitro-quality.ts +++ b/src/lib/furni/nitro-quality.ts @@ -4,6 +4,7 @@ export interface ScaleIssue { } export interface ScaleReport { size: 32 | 64; + derived?: boolean; state: "missing" | "issues" | "present"; assets: string[]; animations: string[]; @@ -178,6 +179,9 @@ export function inspectNitroScales( ].sort(); return { size, + derived: + size === 32 && + object(object(metadata.catalogStudio).scale32).derivedFrom === 64, state: issues.length ? "issues" : names.length ? "present" : "missing", assets: names, animations, diff --git a/src/lib/furni/nitro-scale32.test.ts b/src/lib/furni/nitro-scale32.test.ts new file mode 100644 index 00000000..10dc2d58 --- /dev/null +++ b/src/lib/furni/nitro-scale32.test.ts @@ -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; + 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).chair_32_a_0_0, + ).toMatchObject({ x: -2, y: 4 }); + expect( + (json.assets as Record).chair_32_a_2_0, + ).toMatchObject({ + source: "chair_32_a_0_0", + flipH: true, + }); + expect( + (json.spritesheet as { frames: Record }) + .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 }) + .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]); +}); diff --git a/src/lib/furni/nitro-scale32.ts b/src/lib/furni/nitro-scale32.ts new file mode 100644 index 00000000..25629590 --- /dev/null +++ b/src/lib/furni/nitro-scale32.ts @@ -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, + }; +}