From 17de94d984cb52e4e45086479fbb3cb33f2d6b8b Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 27 Sep 2026 15:42:21 +0200 Subject: [PATCH] feat(nitro): convert imported SWF bundles to WebP Lossless Newly converted .nitro bundles now store their spritesheet as WebP VP8L instead of PNG, so imports land much smaller without changing a single pixel. The texture member and spritesheet.meta.image are both labelled from the actual bytes, never from a caller's assumption. - encode through sharp with lossless and exact, so colour hidden under alpha 0 survives; this mirrors ImageSharp's TransparentColorMode.Preserve - detect PNG/WebP by magic bytes and reject anything the client cannot render, on create, download and upload paths - keep the source format when deriving size-32 sheets, scaling composites and editing metadata, so existing bundles are never silently rewritten - report fidelity in the studio: the compression panel re-encodes with the same options the importer uses, so it cannot drift and invent false warnings, and shows PNG/WebP size estimates convertSwfToNitro and buildSpritesheet are now async, so the worker, the main-thread fallback and every import call site await them. PNG stays supported for existing bundles and icon sidecars are untouched. --- .../admin/import/furni/batch-regen/route.ts | 5 +- .../admin/import/furni/nitro-editor/route.ts | 7 +- src/app/api/admin/import/furni/route.ts | 2 +- .../api/admin/studio/nitro-quality/route.ts | 13 +- .../admin/studio/nitro-quality-panel.tsx | 146 +++++++++- src/lib/furni/nitro-compression.test.ts | 117 ++++++++ src/lib/furni/nitro-quality.ts | 168 +++++++++++ src/lib/furni/nitro-scale32.ts | 42 ++- src/lib/services/effect-import.ts | 2 +- src/lib/services/figure-import.ts | 2 +- src/lib/services/furni-attachment.test.ts | 2 +- src/lib/services/furni-attachment.ts | 16 +- src/lib/services/import/core/download.ts | 9 +- src/lib/services/pet-import.ts | 2 +- src/lib/services/swf/add-size32.ts | 23 +- .../swf/conversion-pool-recovery.test.ts | 14 +- src/lib/services/swf/conversion-pool.ts | 56 ++-- src/lib/services/swf/conversion-worker.ts | 46 ++- src/lib/services/swf/index.ts | 30 +- src/lib/services/swf/nitro-builder.ts | 267 +++++++++++++++--- .../services/swf/nitro-bundle-limits.test.ts | 8 +- src/lib/services/swf/nitro-webp.test.ts | 266 +++++++++++++++++ 22 files changed, 1118 insertions(+), 125 deletions(-) create mode 100644 src/lib/furni/nitro-compression.test.ts create mode 100644 src/lib/services/swf/nitro-webp.test.ts diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts index 7c32ad9c..c6c1b1e0 100644 --- a/src/app/api/admin/import/furni/batch-regen/route.ts +++ b/src/app/api/admin/import/furni/batch-regen/route.ts @@ -199,7 +199,10 @@ export const POST = withAdmin( }); try { const swfBuffer = await fs.readFile(swfPath); - const conversion = convertSwfToNitro(swfBuffer, baseClassname); + const conversion = await convertSwfToNitro( + swfBuffer, + baseClassname, + ); await fs.writeFile(nitroPath, conversion.bundle); // Build FurnitureData entry if missing diff --git a/src/app/api/admin/import/furni/nitro-editor/route.ts b/src/app/api/admin/import/furni/nitro-editor/route.ts index ec503328..b00c35d9 100644 --- a/src/app/api/admin/import/furni/nitro-editor/route.ts +++ b/src/app/api/admin/import/furni/nitro-editor/route.ts @@ -104,13 +104,16 @@ export const PUT = withAdmin( const saved = await withFurniDataLock(async () => { const existingBuffer = await fs.readFile(nitroPath); - const { png, json: currentMetadata } = parseNitroBundle(existingBuffer); + const { texture, json: currentMetadata } = + parseNitroBundle(existingBuffer); if ( JSON.stringify(currentMetadata.catalogStudio) !== JSON.stringify(metadata.catalogStudio) ) return false; - const newBundle = createNitroBundle(metadata, png, base); + // `texture` keeps its original encoding, so editing metadata never + // changes the bundle's texture format. + const newBundle = createNitroBundle(metadata, texture, base); await fs.writeFile(nitroPath, newBundle); return true; }); diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 618f880b..c9ed2695 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -775,7 +775,7 @@ export const PATCH = withAdmin( // Convert SWF → Nitro + update FurnitureData.json try { const swfBuffer = await fs.readFile(swfPath); - const conversion = convertSwfToNitro(swfBuffer, baseClassname); + const conversion = await convertSwfToNitro(swfBuffer, baseClassname); await fs.writeFile(nitroPath, conversion.bundle); // Add to FurnitureData.json if not already present diff --git a/src/app/api/admin/studio/nitro-quality/route.ts b/src/app/api/admin/studio/nitro-quality/route.ts index 10b6926e..6a216b54 100644 --- a/src/app/api/admin/studio/nitro-quality/route.ts +++ b/src/app/api/admin/studio/nitro-quality/route.ts @@ -4,6 +4,7 @@ import sharp from "sharp"; import { apiError, apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; import { + buildNitroCompressionReport, inspectNitroScales, object, resolveNitroFrame, @@ -21,10 +22,11 @@ export const GET = withAdmin( const base = String(classname).split("*")[0], { nitroDir } = await getFurniAssetDirs(); try { - const { json, png } = parseNitroBundle( + const parsed = parseNitroBundle( await fs.readFile(path.join(nitroDir, `${base}.nitro`)), ); - const image = await sharp(png).metadata(); + const { json, texture } = parsed; + const image = await sharp(texture).metadata(); if (!image.width || !image.height) return apiError("Invalid bundle texture", 422); const asset = request.nextUrl.searchParams.get("asset"); @@ -33,7 +35,7 @@ export const GET = withAdmin( return apiError("Sprite not found", 404); const resolved = resolveNitroFrame(json, asset), rect = object(resolved.frame.frame); - let pipeline = sharp(png).extract({ + let pipeline = sharp(texture).extract({ left: Number(rect.x), top: Number(rect.y), width: Number(rect.w), @@ -51,7 +53,10 @@ export const GET = withAdmin( }); } return apiOk({ - report: inspectNitroScales(json, image.width, image.height), + report: { + ...inspectNitroScales(json, image.width, image.height), + compression: await buildNitroCompressionReport(parsed), + }, }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") diff --git a/src/components/admin/studio/nitro-quality-panel.tsx b/src/components/admin/studio/nitro-quality-panel.tsx index ba02698c..181699f0 100644 --- a/src/components/admin/studio/nitro-quality-panel.tsx +++ b/src/components/admin/studio/nitro-quality-panel.tsx @@ -2,7 +2,128 @@ 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"; +import type { + CompressionReport, + NitroQuality, + ScaleReport, +} from "@/lib/furni/nitro-quality"; + +function kb(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +function CompressionPanel({ report }: { report: CompressionReport }) { + const { texture, json } = report, + // Differences that a renderer can actually see. + visibleDiffs = texture.mismatchedPixels - texture.invisibleDifferences, + // `webpEstimate.savingBytes` measures a PNG texture against a WebP + // re-encode, so it reads ~0 for a bundle that already *is* WebP. The + // honest WebP-vs-PNG comparison is the stored bytes against the PNG + // encoding of the same artwork. + pngSaving = texture.rawBytes - texture.pngEstimate.bytes; + return ( +
+

+ Compression · {texture.format.toUpperCase()} + {texture.format === "webp" + ? texture.lossless + ? " Lossless (VP8L)" + : " Lossy (VP8)" + : ""} +

+
+
+
Bundle
+
{kb(report.bundleBytes)}
+
+
+
Texture
+
+ {kb(texture.compressedBytes)} + + {" "} + ({Math.round(report.textureShare * 100)}%) + +
+
+
+
Metadata
+
+ {kb(json.compressedBytes)} + · ×{json.ratio} +
+
+
+
Container
+
{kb(report.overheadBytes)}
+
+
+

+ Texture {kb(texture.rawBytes)} decoded · deflate ×{texture.ratio} · + sheet {texture.width}×{texture.height} · {texture.spriteCount} frames +

+ {texture.format === "png" ? ( +

+ Legacy PNG texture. Re-importing as WebP Lossless would store{" "} + {kb(texture.webpEstimate.bytes)} + {texture.webpEstimate.savingBytes > 0 ? ( + <> + , saving {kb(texture.webpEstimate.savingBytes)} ( + {texture.webpEstimate.bytes > 0 + ? Math.round( + (1 - texture.webpEstimate.bytes / texture.rawBytes) * 100, + ) + : 0} + % smaller) + + ) : ( + " (no gain on this bundle)" + )} +

+ ) : ( +

+ WebP Lossless stores {kb(texture.rawBytes)} against{" "} + {kb(texture.pngEstimate.bytes)} for the same artwork as PNG + {pngSaving > 0 ? ( + <> + , saving {kb(pngSaving)} ( + {texture.pngEstimate.bytes > 0 + ? Math.round((pngSaving / texture.pngEstimate.bytes) * 100) + : 0} + % smaller) + + ) : ( + " (PNG is not larger on this bundle)" + )} +

+ )} +

+ {visibleDiffs === 0 ? ( + <> + Pixel-exact — + every visible texel round-trips unchanged. + {texture.invisibleDifferences > 0 && ( + + {" "} + {texture.invisibleDifferences.toLocaleString()} fully + transparent texels carry no RGB, which VP8L discards and no + renderer can see. + + )} + + ) : ( + + {visibleDiffs.toLocaleString()} texel + {visibleDiffs === 1 ? "" : "s"} differ after a lossless round-trip — + the furniture may not render identically. + + )} +

+
+ ); +} function ScalePanel({ report, @@ -133,15 +254,20 @@ export function NitroQualityPanel({ classname }: { classname: string }) { ) : !report ? (

Checking scales and texture references…

) : ( -
- {report.scales.map((scale) => ( - - ))} +
+ {report.compression && ( + + )} +
+ {report.scales.map((scale) => ( + + ))} +
)}
diff --git a/src/lib/furni/nitro-compression.test.ts b/src/lib/furni/nitro-compression.test.ts new file mode 100644 index 00000000..6473cdaa --- /dev/null +++ b/src/lib/furni/nitro-compression.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import type { SpriteImage } from "@/lib/services/swf/image-decoder"; +import { + buildSpritesheet, + createNitroBundle, + encodePng, + parseNitroBundle, +} from "@/lib/services/swf/nitro-builder"; +import { + buildNitroCompressionReport, + inspectNitroScales, +} from "./nitro-quality"; + +const sprite = (): SpriteImage => { + const width = 12, + height = 10, + pixels = Buffer.alloc(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const o = (y * width + x) * 4; + const border = x === 0 || y === 0 || x === width - 1 || y === height - 1; + pixels[o] = border ? 220 : 30; + pixels[o + 1] = border ? 90 : 180; + pixels[o + 2] = border ? 40 : 60; + pixels[o + 3] = border ? 255 : 0; + } + } + return { id: 0, name: "chair_0", width, height, pixels }; +}; + +const metadata = () => ({ + name: "chair", + spritesheet: { + meta: { image: "chair.webp", format: "RGBA8888", size: { w: 16, h: 16 } }, + frames: { chair_0: { frame: { x: 0, y: 0, w: 12, h: 10 } } }, + }, + assets: { chair_0: { x: 0, y: 0 } }, + visualizations: [{ size: 64, animations: {} }], +}); + +const bundleFor = async (format: "png" | "webp") => { + const sheet = await buildSpritesheet([sprite()], { format }); + return createNitroBundle(metadata(), sheet.texture, "chair"); +}; + +describe("nitro compression report", () => { + it("describes a WebP bundle and proves it is pixel-exact", async () => { + const report = await buildNitroCompressionReport( + parseNitroBundle(await bundleFor("webp")), + ); + expect(report.texture.format).toBe("webp"); + expect(report.texture.fileName).toBe("chair.webp"); + expect(report.texture.lossless).toBe(true); + // `exact: true` means not even transparent RGB is altered. + expect(report.texture.mismatchedPixels).toBe(0); + expect(report.texture.invisibleDifferences).toBe(0); + expect(report.texture.spriteCount).toBe(1); + expect(report.texture.width).toBeGreaterThan(0); + expect(report.texture.pixels).toBe( + report.texture.width * report.texture.height, + ); + }); + + it("breaks the bundle down into metadata, texture and container overhead", async () => { + const parsed = parseNitroBundle(await bundleFor("webp")); + const report = await buildNitroCompressionReport(parsed); + expect(report.bundleBytes).toBe(parsed.bundleBytes); + expect(report.json.compressedBytes).toBe(parsed.compressedSizes.json); + expect(report.texture.compressedBytes).toBe(parsed.compressedSizes.texture); + expect(report.overheadBytes).toBe( + parsed.bundleBytes - + parsed.compressedSizes.json - + parsed.compressedSizes.texture, + ); + expect(report.overheadBytes).toBeGreaterThan(0); + // The texture is most of a small furniture bundle. + expect(report.textureShare).toBeGreaterThan(0); + expect(report.textureShare).toBeLessThanOrEqual(1); + // Ratios are real savings, never zero or negative. + expect(report.json.ratio).toBeGreaterThan(0); + expect(report.texture.ratio).toBeGreaterThan(0); + }); + + it("shows what a legacy PNG bundle would save as WebP Lossless", async () => { + const report = await buildNitroCompressionReport( + parseNitroBundle(await bundleFor("png")), + ); + expect(report.texture.format).toBe("png"); + expect(report.texture.fileName).toBe("chair.png"); + // Still verified lossless — PNG is a lossless codec too. + expect(report.texture.mismatchedPixels).toBe(0); + expect(report.texture.webpEstimate.bytes).toBeGreaterThan(0); + expect(report.texture.pngEstimate.bytes).toBeGreaterThan(0); + // The same artwork is cheaper as WebP than as PNG. + expect(report.texture.webpEstimate.bytes).toBeLessThan( + report.texture.pngEstimate.bytes, + ); + expect(report.texture.webpEstimate.savingBytes).toBeGreaterThan(0); + }); + + it("keeps working for a bundle with no sprite frames", async () => { + const png = encodePng(1, 1, Buffer.from([0, 0, 0, 0])); + const report = await buildNitroCompressionReport( + parseNitroBundle(createNitroBundle({ name: "empty" }, png, "empty")), + ); + expect(report.texture.spriteCount).toBe(0); + expect(report.texture.mismatchedPixels).toBe(0); + }); +}); + +describe("quality report composition", () => { + it("leaves compression optional so existing scale consumers are unaffected", () => { + const quality = inspectNitroScales(metadata(), 16, 16); + expect(quality.compression).toBeUndefined(); + expect(quality.scales).toHaveLength(2); + }); +}); diff --git a/src/lib/furni/nitro-quality.ts b/src/lib/furni/nitro-quality.ts index b0e3fd87..1e2235d0 100644 --- a/src/lib/furni/nitro-quality.ts +++ b/src/lib/furni/nitro-quality.ts @@ -1,3 +1,8 @@ +import { + encodeWebpLossless, + isLosslessWebp, +} from "@/lib/services/swf/nitro-builder"; + export interface ScaleIssue { asset?: string; message: string; @@ -11,8 +16,56 @@ export interface ScaleReport { directions: string[]; issues: ScaleIssue[]; } +export interface MemberReport { + fileName: string; + /** Bytes of the member as decoded out of the container. */ + rawBytes: number; + /** Bytes the member occupies inside the container (deflated). */ + compressedBytes: number; + /** rawBytes / compressedBytes. 1 means the deflate layer gained nothing. */ + ratio: number; +} +export interface TextureReport extends MemberReport { + format: "png" | "webp"; + width: number; + height: number; + /** True when the stored texture uses a lossless codec. */ + lossless: boolean; + /** Total texel count, i.e. width * height. */ + pixels: number; + /** Textured sprite frames in the sheet. */ + spriteCount: number; + /** + * Pixels that differ from a lossless re-encode. 0 proves the stored texture + * reproduces its own pixels exactly; a non-zero value is only benign when + * `invisibleDifferences` accounts for all of it. + */ + mismatchedPixels: number; + /** + * Differing pixels that are fully transparent, where the stored RGB is + * discarded by every renderer and therefore harmless. + */ + invisibleDifferences: number; + /** + * What this texture would weigh as WebP Lossless. Present so the studio can + * show a real saving, including for legacy PNG bundles. + */ + webpEstimate: { bytes: number; savingBytes: number }; + /** What this texture would weigh as PNG, for comparison. */ + pngEstimate: { bytes: number }; +} +export interface CompressionReport { + bundleBytes: number; + /** Container framing: file counts, name lengths, and deflate length fields. */ + overheadBytes: number; + json: MemberReport; + texture: TextureReport; + /** share of the bundle spent on the texture, 0..1. */ + textureShare: number; +} export interface NitroQuality { scales: ScaleReport[]; + compression?: CompressionReport; } export const object = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) @@ -199,3 +252,118 @@ export function inspectNitroScales( }), }; } + +const ratio = (raw: number, compressed: number) => + compressed > 0 ? Math.round((raw / compressed) * 100) / 100 : 0; + +/** + * Build the compression half of the quality report. + * + * Beyond the size breakdown this re-decodes the stored texture and compares it + * to the pixels it encodes, so the studio can state — not assume — that a + * WebP bundle really is pixel-exact. Differences confined to fully transparent + * pixels are counted separately, because a lossy re-encode of colour no + * renderer can see is not something a player would ever notice. + */ +export async function buildNitroCompressionReport(input: { + json: Record; + jsonFileName: string; + texture: Buffer; + textureFileName: string; + textureFormat: "png" | "webp"; + compressedSizes: { json: number; texture: number }; + bundleBytes: number; +}): Promise { + const sharp = (await import("sharp")).default; + const metadata = await sharp(input.texture).metadata(); + const width = Number(metadata.width ?? 0), + height = Number(metadata.height ?? 0); + const rawRgba = + width * height + ? await sharp(input.texture).raw().ensureAlpha().toBuffer() + : Buffer.alloc(0); + + // Decode the stored bytes, then re-encode with the exact importer settings + // and compare. Reusing `encodeWebpLossless` matters: verifying with a + // different option set than the importer uses would manufacture false + // "mismatched" warnings, so the two can never silently drift apart. + let mismatchedPixels = 0, + invisibleDifferences = 0; + if (rawRgba.length) { + const reencoded = await encodeWebpLossless(width, height, rawRgba); + const decoded = await sharp(reencoded).raw().ensureAlpha().toBuffer(); + for (let i = 0; i < width * height; i++) { + const o = i * 4; + if ( + decoded[o] === rawRgba[o] && + decoded[o + 1] === rawRgba[o + 1] && + decoded[o + 2] === rawRgba[o + 2] && + decoded[o + 3] === rawRgba[o + 3] + ) + continue; + mismatchedPixels++; + // Colour hidden under alpha 0 cannot reach the screen, so it is + // tracked apart from differences a player would actually show. + if (rawRgba[o + 3] === 0) invisibleDifferences++; + } + } + + const webpBytes = rawRgba.length + ? (await encodeWebpLossless(width, height, rawRgba)).length + : 0; + const pngBytes = rawRgba.length + ? ( + await sharp(rawRgba, { raw: { width, height, channels: 4 } }) + .png({ compressionLevel: 9 }) + .toBuffer() + ).length + : 0; + + const jsonRaw = Buffer.byteLength(JSON.stringify(input.json), "utf-8"); + const textureRaw = input.texture.length; + const overheadBytes = Math.max( + 0, + input.bundleBytes - + input.compressedSizes.json - + input.compressedSizes.texture, + ); + + return { + bundleBytes: input.bundleBytes, + overheadBytes, + json: { + fileName: input.jsonFileName, + rawBytes: jsonRaw, + compressedBytes: input.compressedSizes.json, + ratio: ratio(jsonRaw, input.compressedSizes.json), + }, + texture: { + fileName: input.textureFileName, + format: input.textureFormat, + width, + height, + pixels: width * height, + spriteCount: Object.keys(object(object(input.json.spritesheet).frames)) + .length, + rawBytes: textureRaw, + compressedBytes: input.compressedSizes.texture, + ratio: ratio(textureRaw, input.compressedSizes.texture), + // Only claim losslessness from the bytes themselves: a bundle that + // arrived as a lossy VP8 WebP must not be passed off as lossless. + lossless: input.textureFormat === "png" || isLosslessWebp(input.texture), + mismatchedPixels, + invisibleDifferences, + webpEstimate: { + bytes: webpBytes, + savingBytes: textureRaw - webpBytes, + }, + pngEstimate: { bytes: pngBytes }, + }, + textureShare: + input.bundleBytes > 0 + ? Math.round( + (input.compressedSizes.texture / input.bundleBytes) * 1000, + ) / 1000 + : 0, + }; +} diff --git a/src/lib/furni/nitro-scale32.ts b/src/lib/furni/nitro-scale32.ts index bb7f48ea..59b21043 100644 --- a/src/lib/furni/nitro-scale32.ts +++ b/src/lib/furni/nitro-scale32.ts @@ -45,7 +45,7 @@ export async function generateScale32(input: Buffer): Promise<{ count: number; skipped: number; }> { - const { json, png } = parseNitroBundle(input); + const { json, texture, textureFormat } = parseNitroBundle(input); const assets = object(json.assets), sheet = object(json.spritesheet), frames = object(sheet.frames); @@ -67,7 +67,7 @@ export async function generateScale32(input: Buffer): Promise<{ if (!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(); + const dimensions = await sharp(texture).metadata(); if (!dimensions.width || !dimensions.height) throw Error("Invalid texture"); const images: { id: number; @@ -103,7 +103,7 @@ export async function generateScale32(input: Buffer): Promise<{ } const frame = resolved.frame, rect = object(frame.frame); - let sprite = await sharp(png) + let sprite = await sharp(texture) .extract({ left: Number(rect.x), top: Number(rect.y), @@ -149,25 +149,35 @@ export async function generateScale32(input: Buffer): Promise<{ }); } if (!images.length) throw Error("No independent texture frames were found"); - const atlas = buildSpritesheet(images); + const atlas = await buildSpritesheet(images, { format: textureFormat }); 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({ + // The merged sheet must keep the source bundle's texture format so an + // existing PNG bundle is not silently converted (or vice versa). + const composited = 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(); + }).composite([ + { input: texture, left: 0, top: 0 }, + { input: atlas.texture, left: 0, top: dimensions.height }, + ]); + const combined = + textureFormat === "webp" + ? await composited + .webp({ + lossless: true, + nearLossless: false, + smartSubsample: false, + effort: 6, + }) + .toBuffer() + : await composited.png().toBuffer(); const generated = new Map(); for (const name of names) if (!skipped.has(name)) generated.set(scaleName(name), true); @@ -198,7 +208,13 @@ export async function generateScale32(input: Buffer): Promise<{ json.spritesheet = { ...sheet, frames, - meta: { ...object(sheet.meta), size: { w: width, h: height } }, + meta: { + ...object(sheet.meta), + // Keep the client-facing image pointer aligned with the member + // `createNitroBundle` is about to write. + image: `${String(json.name || "furniture")}.${textureFormat}`, + size: { w: width, h: height }, + }, }; json.catalogStudio = { ...object(json.catalogStudio), diff --git a/src/lib/services/effect-import.ts b/src/lib/services/effect-import.ts index 183cfa9b..43e6a56e 100644 --- a/src/lib/services/effect-import.ts +++ b/src/lib/services/effect-import.ts @@ -113,7 +113,7 @@ export async function importSingleEffect(params: { onProgress?.("converting"); try { const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath); - const result = convertSwfToNitro(swfBuffer, lib); + const result = await convertSwfToNitro(swfBuffer, lib); await fs.writeFile(/*turbopackIgnore: true*/ nitroPath, result.bundle); if (result.warnings?.length) warnings.push(...result.warnings); } catch (err) { diff --git a/src/lib/services/figure-import.ts b/src/lib/services/figure-import.ts index 7128c4d2..42f4927e 100644 --- a/src/lib/services/figure-import.ts +++ b/src/lib/services/figure-import.ts @@ -112,7 +112,7 @@ export async function importSingleFigure(params: { onProgress?.("converting"); try { const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath); - const result = convertSwfToNitro(swfBuffer, lib); + const result = await convertSwfToNitro(swfBuffer, lib); await fs.writeFile( /*turbopackIgnore: true*/ await nitroPathFor(lib), result.bundle, diff --git a/src/lib/services/furni-attachment.test.ts b/src/lib/services/furni-attachment.test.ts index 8b7e609f..4964bf69 100644 --- a/src/lib/services/furni-attachment.test.ts +++ b/src/lib/services/furni-attachment.test.ts @@ -30,5 +30,5 @@ it("rejects missing texture data", () => { createNitroBundle({ name: "chair" }, Buffer.from("not png"), "chair"), "chair", ), - ).toThrow("not a PNG"); + ).toThrow("PNG or WebP"); }); diff --git a/src/lib/services/furni-attachment.ts b/src/lib/services/furni-attachment.ts index 837f6be8..515f9114 100644 --- a/src/lib/services/furni-attachment.ts +++ b/src/lib/services/furni-attachment.ts @@ -1,7 +1,10 @@ import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; -import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; +import { + detectNitroTextureFormat, + parseNitroBundle, +} from "@/lib/services/swf/nitro-builder"; import { importRoot, validJobId } from "./furni-job-store"; export function validateFurnitureAttachment(buffer: Buffer, classname: string) { if (buffer.length > 50 * 1024 * 1024) @@ -15,12 +18,11 @@ export function validateFurnitureAttachment(buffer: Buffer, classname: string) { (parsed.json.name && parsed.json.name !== base) ) throw Error(`This bundle does not belong to ${classname}`); - if ( - !parsed.png - .subarray(0, 8) - .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) - ) - throw Error("The bundle texture is not a PNG"); + // The renderer reads WebP Lossless and PNG; parseNitroBundle already + // rejects anything else, so this only names the format in the error. + const format = detectNitroTextureFormat(parsed.texture); + if (!format) + throw Error("The bundle texture is neither a PNG nor a WebP image"); return buffer; } export async function stageFurnitureAttachment( diff --git a/src/lib/services/import/core/download.ts b/src/lib/services/import/core/download.ts index b8110714..e7c09809 100644 --- a/src/lib/services/import/core/download.ts +++ b/src/lib/services/import/core/download.ts @@ -1,6 +1,9 @@ import { promises as fs } from "node:fs"; import { getByparrCookies } from "@/lib/services/byparr"; -import { parseNitroBundle } from "../../swf/nitro-builder"; +import { + detectNitroTextureFormat, + parseNitroBundle, +} from "../../swf/nitro-builder"; import { browserHeaders } from "./browser-headers"; import { curlDownload } from "./curl-fetch"; @@ -27,7 +30,9 @@ export function validateNitroBytes(buffer: Buffer): boolean { !!bundle.json && typeof bundle.json === "object" && !Array.isArray(bundle.json) && - validatePngBytes(bundle.png) + // parseNitroBundle already rejects non-PNG/WebP textures, so this + // is a cheap re-assertion rather than the real gate. + detectNitroTextureFormat(bundle.texture) !== null ); } catch { return false; diff --git a/src/lib/services/pet-import.ts b/src/lib/services/pet-import.ts index 87765082..367251ab 100644 --- a/src/lib/services/pet-import.ts +++ b/src/lib/services/pet-import.ts @@ -78,7 +78,7 @@ export async function importSinglePet(params: { onProgress?.("converting"); try { const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath); - const result = convertSwfToNitro(swfBuffer, lib); + const result = await convertSwfToNitro(swfBuffer, lib); await fs.writeFile( /*turbopackIgnore: true*/ nitroPathFor(lib), result.bundle, diff --git a/src/lib/services/swf/add-size32.ts b/src/lib/services/swf/add-size32.ts index 9b51f11d..19a22e2f 100644 --- a/src/lib/services/swf/add-size32.ts +++ b/src/lib/services/swf/add-size32.ts @@ -34,7 +34,8 @@ export function nitroHasSize32(json: Record): boolean { export async function addSize32ToBundle( buffer: Buffer, ): Promise { - const { json, png, jsonFileName } = parseNitroBundle(buffer); + const { json, texture, textureFormat, jsonFileName } = + parseNitroBundle(buffer); if (nitroHasSize32(json)) return null; const name = (json.name as string) || jsonFileName.replace(/\.json$/, ""); @@ -68,7 +69,7 @@ export async function addSize32ToBundle( for (const [frameName, frame] of Object.entries(frames)) { const f = frame.frame; if (!f || f.w <= 0 || f.h <= 0) continue; - const { data, info } = await sharp(png) + const { data, info } = await sharp(texture) .extract({ left: f.x, top: f.y, width: f.w, height: f.h }) .raw() .ensureAlpha() @@ -95,7 +96,7 @@ export async function addSize32ToBundle( const f = frame.frame; if (f.w <= 0 || f.h <= 0) continue; - const src = await sharp(png) + const src = await sharp(texture) .extract({ left: f.x, top: f.y, width: f.w, height: f.h }) .raw() .ensureAlpha() @@ -131,7 +132,12 @@ export async function addSize32ToBundle( if (Object.keys(newAssets).length === 0) return null; - const { png: newPng, frames: newFrames } = buildSpritesheet(images); + // Preserve the source bundle's texture format: regenerating a scale must + // not silently downgrade a WebP bundle back to PNG. + const { texture: newTexture, frames: newFrames } = await buildSpritesheet( + images, + { format: textureFormat }, + ); // Update existing frame coordinates (sheet was rebuilt). for (const [frameName, frame] of Object.entries(frames)) { @@ -171,5 +177,12 @@ export async function addSize32ToBundle( vis.push({ ...sourceVis, size: 32 }); } - return createNitroBundle(json, newPng, name); + // Keep the client-facing image pointer aligned with the member + // `createNitroBundle` is about to write. + const sheet = (json.spritesheet ?? {}) as Record; + const meta = (sheet.meta ?? {}) as Record; + sheet.meta = { ...meta, image: `${name}.${textureFormat}` }; + json.spritesheet = sheet; + + return createNitroBundle(json, newTexture, name); } diff --git a/src/lib/services/swf/conversion-pool-recovery.test.ts b/src/lib/services/swf/conversion-pool-recovery.test.ts index c261a747..34fe5728 100644 --- a/src/lib/services/swf/conversion-pool-recovery.test.ts +++ b/src/lib/services/swf/conversion-pool-recovery.test.ts @@ -32,7 +32,19 @@ describe("conversion worker recovery", () => { const data = Buffer.from("SWF"); const result = await convertSwfToNitroAsync(data, "chair"); expect(result.bundle.toString()).toBe("converted"); - expect(mocks.convert).toHaveBeenCalledWith(data, "chair"); + expect(mocks.convert).toHaveBeenCalledWith(data, "chair", { + textureFormat: undefined, + }); + }); + it("passes the requested texture format through to the main-thread fallback", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("VITEST", undefined); + const { convertSwfToNitroAsync } = await import("./conversion-pool"); + const data = Buffer.from("SWF"); + await convertSwfToNitroAsync(data, "chair", { textureFormat: "webp" }); + expect(mocks.convert).toHaveBeenCalledWith(data, "chair", { + textureFormat: "webp", + }); }); it("recovers icon extraction from a worker startup error", async () => { vi.stubEnv("NODE_ENV", "production"); diff --git a/src/lib/services/swf/conversion-pool.ts b/src/lib/services/swf/conversion-pool.ts index 3e217fd4..07670643 100644 --- a/src/lib/services/swf/conversion-pool.ts +++ b/src/lib/services/swf/conversion-pool.ts @@ -6,6 +6,7 @@ import { convertSwfToNitro, extractIconFromSwf, } from "./index"; +import type { NitroTextureFormat } from "./nitro-builder"; // Offloads the synchronous, CPU-heavy SWF→Nitro conversion (and icon // extraction) to a small pool of worker threads so the main Node event loop @@ -89,28 +90,39 @@ function pump() { const CONVERSION_TIMEOUT_MS = 60_000; -function run(req: Omit): Promise { +// `Omit` does not distribute over a union, which would erase `textureFormat` +// from the convert variant. +type DistributiveOmit = T extends unknown + ? Omit + : never; + +function run( + req: DistributiveOmit, +): Promise { if (!ensurePool()) { - try { - if (req.type === "convert") { + // No pool available: run on the main thread. Icon extraction stays + // synchronous; the conversion is async because it encodes a spritesheet. + if (req.type === "icon") { + try { return Promise.resolve({ id: 0, ok: true, - result: convertSwfToNitro(req.buffer, req.classname), + icon: extractIconFromSwf(req.buffer, req.classname), + }); + } catch (e) { + return Promise.resolve({ + id: 0, + ok: false, + error: (e as Error).message, }); } - return Promise.resolve({ - id: 0, - ok: true, - icon: extractIconFromSwf(req.buffer, req.classname), - }); - } catch (e) { - return Promise.resolve({ - id: 0, - ok: false, - error: (e as Error).message, - }); } + return convertSwfToNitro(req.buffer, req.classname, { + textureFormat: req.textureFormat, + }).then( + (result) => ({ id: 0, ok: true, result }) as WorkerResponse, + (e: Error) => ({ id: 0, ok: false, error: e.message }) as WorkerResponse, + ); } return new Promise((resolve, reject) => { const job: Job = { @@ -153,13 +165,19 @@ function run(req: Omit): Promise { export async function convertSwfToNitroAsync( buffer: Buffer, classname: string, + options?: { textureFormat?: NitroTextureFormat }, ): Promise { - const res = await run({ type: "convert", buffer, classname }).catch( - () => null, - ); + const res = await run({ + type: "convert", + buffer, + classname, + textureFormat: options?.textureFormat, + }).catch(() => null); if (res?.ok && res.result) return res.result; // Worker failed for this item — fall back to the main thread. - return convertSwfToNitro(buffer, classname); + return convertSwfToNitro(buffer, classname, { + textureFormat: options?.textureFormat, + }); } export async function extractIconFromSwfAsync( diff --git a/src/lib/services/swf/conversion-worker.ts b/src/lib/services/swf/conversion-worker.ts index cdd1edef..45f977f5 100644 --- a/src/lib/services/swf/conversion-worker.ts +++ b/src/lib/services/swf/conversion-worker.ts @@ -1,11 +1,14 @@ import { parentPort } from "node:worker_threads"; import { convertSwfToNitro, extractIconFromSwf } from "./index"; +import type { NitroTextureFormat } from "./nitro-builder"; export interface ConvertRequest { id: number; type: "convert"; buffer: Buffer; classname: string; + /** Omitted means the default (WebP Lossless). */ + textureFormat?: NitroTextureFormat; } export interface IconRequest { @@ -28,28 +31,41 @@ export type WorkerResponse = if (parentPort) { parentPort.on("message", (msg: WorkerRequest) => { - try { - if (msg.type === "convert") { - const result = convertSwfToNitro(msg.buffer, msg.classname); - parentPort?.postMessage({ - id: msg.id, - ok: true, - result, - } satisfies WorkerResponse); - } else { + if (msg.type === "icon") { + try { const icon = extractIconFromSwf(msg.buffer, msg.classname); parentPort?.postMessage({ id: msg.id, ok: true, icon: icon ?? null, } satisfies WorkerResponse); + } catch (err) { + parentPort?.postMessage({ + id: msg.id, + ok: false, + error: (err as Error).message, + } satisfies WorkerResponse); } - } catch (err) { - parentPort?.postMessage({ - id: msg.id, - ok: false, - error: (err as Error).message, - } satisfies WorkerResponse); + return; } + // The conversion encodes a spritesheet, which is async (sharp), so it + // cannot share the synchronous try/catch above. + void convertSwfToNitro(msg.buffer, msg.classname, { + textureFormat: msg.textureFormat, + }) + .then((result) => { + parentPort?.postMessage({ + id: msg.id, + ok: true, + result, + } satisfies WorkerResponse); + }) + .catch((err: Error) => { + parentPort?.postMessage({ + id: msg.id, + ok: false, + error: err.message, + } satisfies WorkerResponse); + }); }); } diff --git a/src/lib/services/swf/index.ts b/src/lib/services/swf/index.ts index e01536ee..9e47facf 100644 --- a/src/lib/services/swf/index.ts +++ b/src/lib/services/swf/index.ts @@ -17,6 +17,7 @@ import { buildSpritesheet, createNitroBundle, encodePng, + type NitroTextureFormat, } from "./nitro-builder"; import { decompressSwf, @@ -56,15 +57,25 @@ export { extractIconFromSwfAsync, } from "./conversion-pool"; // Re-export public API from submodules -export { createNitroBundle, parseNitroBundle } from "./nitro-builder"; +export { + buildSpritesheet, + createNitroBundle, + detectNitroTextureFormat, + encodePng, + encodeWebpLossless, + NITRO_TEXTURE_LABEL, + type NitroTextureFormat, + parseNitroBundle, +} from "./nitro-builder"; export { computeDefaultDir, extractPartColors } from "./xml-processor"; // ── Main Conversion ──────────────────────────────────────────────── -export function convertSwfToNitro( +export async function convertSwfToNitro( swfBuffer: Buffer, classname: string, -): ConversionResult { + options?: { textureFormat?: NitroTextureFormat }, +): Promise { const warnings: string[] = []; // 1. Decompress SWF @@ -241,11 +252,14 @@ export function convertSwfToNitro( // 7. Build spritesheet using bin-packing const { - png, + texture, + format: sheetFormat, frames, width: sheetWidth, height: sheetHeight, - } = buildSpritesheet(namedImages); + } = await buildSpritesheet(namedImages, { + format: options?.textureFormat ?? "webp", + }); // 8. Process XML into Nitro JSON structures const { palettes } = processAssetsXml(assetsXml); @@ -392,7 +406,9 @@ export function convertSwfToNitro( logicType, spritesheet: { meta: { - image: `${classname}.png`, + // Must match the texture member `createNitroBundle` writes, or + // the client resolves a file that is not in the bundle. + image: `${classname}.${sheetFormat}`, format: "RGBA8888", size: { w: sheetWidth, h: sheetHeight }, scale: 1, @@ -410,7 +426,7 @@ export function convertSwfToNitro( nitroJson.logic = logicObj; // 10. Create .nitro bundle - const bundle = createNitroBundle(nitroJson, png, classname); + const bundle = createNitroBundle(nitroJson, texture, classname); return { bundle, diff --git a/src/lib/services/swf/nitro-builder.ts b/src/lib/services/swf/nitro-builder.ts index b57c7836..e9a7792c 100644 --- a/src/lib/services/swf/nitro-builder.ts +++ b/src/lib/services/swf/nitro-builder.ts @@ -1,6 +1,74 @@ import { deflateSync, gunzipSync, inflateSync } from "node:zlib"; +import sharp from "sharp"; import type { SpriteImage } from "./image-decoder"; +// ── Texture Formats ──────────────────────────────────────────────── + +/** + * Image formats a `.nitro` bundle may carry as its spritesheet texture. + * + * `webp` is the default for newly converted bundles: VP8L is a true lossless + * codec, so pixels round-trip bit-for-bit (bar RGB under fully transparent + * pixels, which no renderer can observe) while typically weighing 25-40% of the + * equivalent PNG. `png` stays supported so existing bundles, official Habbo + * gamedata and the PNG-icon sidecar keep parsing unchanged. + */ +export type NitroTextureFormat = "png" | "webp"; + +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +/** + * Identify a Nitro texture by its magic bytes rather than by its filename, so + * the container writer can never label an image with the wrong extension. + * Returns `null` for anything that is not a PNG or a RIFF/WEBP image. + */ +export function detectNitroTextureFormat( + bytes: Buffer, +): NitroTextureFormat | null { + // PNG is identified by its 8-byte signature; WebP additionally needs room + // for the "RIFF" size and the "WEBP" fourcc at offset 8. + if (bytes.length >= 8 && bytes.subarray(0, 8).equals(PNG_SIGNATURE)) + return "png"; + if ( + bytes.length >= 12 && + bytes.toString("ascii", 0, 4) === "RIFF" && + bytes.toString("ascii", 8, 12) === "WEBP" + ) + return "webp"; + return null; +} + +/** Human-readable, used by validation error messages. */ +export const NITRO_TEXTURE_LABEL: Record = { + png: "PNG", + webp: "WebP", +}; + +/** + * A RIFF container can also hold a lossy VP8 image, so "this is a WebP" is + * not the same claim as "this is WebP Lossless". The verdict comes from the + * image chunk: `VP8L` is lossless, `VP8 ` is lossy, and `VP8X` is only an + * extended header that defers the answer to a later chunk, so the container + * is walked rather than guessed at. + */ +export function isLosslessWebp(bytes: Buffer): boolean { + if (detectNitroTextureFormat(bytes) !== "webp") return false; + // RIFF size covers everything after the first 8 bytes; clamp it so a + // truncated file cannot make us read past the buffer. + const declared = bytes.readUInt32LE(4) + 8; + const end = Math.min(declared, bytes.length); + let sawLossy = false; + for (let at = 12; at + 8 <= end; ) { + const fourcc = bytes.toString("ascii", at, at + 4); + // Chunk payloads are padded to an even length. + const size = bytes.readUInt32LE(at + 4); + if (fourcc === "VP8L") return !sawLossy; + if (fourcc === "VP8 ") sawLossy = true; + at += 8 + size + (size % 2); + } + return false; +} + // ── Minimal PNG Encoder ──────────────────────────────────────────── const CRC_TABLE = (() => { @@ -68,6 +136,39 @@ export function encodePng(width: number, height: number, rgba: Buffer): Buffer { return Buffer.concat(chunks); } +// ── WebP Lossless Encoder ────────────────────────────────────────── + +/** + * Encode RGBA pixels as a WebP image using the VP8L lossless codec. + * + * `lossless: true` is what keeps furniture artwork pixel-exact; the remaining + * options are pinned so no chroma-subsampling or near-lossless approximation + * can creep in. `effort: 6` is libvips' slowest-but-best setting, which is + * worth it for a one-off import and keeps spritesheets compact. + * + * `exact: true` (libvips' "preserve the colour data in transparent pixels") + * mirrors ImageSharp's `TransparentColorMode.Preserve`, so a bundle converts + * without a single altered texel — not even the RGB hidden under alpha 0. + * It costs bytes whenever transparent areas carry non-uniform colour, so the + * studio compression report states the size either way. + */ +export async function encodeWebpLossless( + width: number, + height: number, + rgba: Buffer, + options?: { exact?: boolean }, +): Promise { + return sharp(rgba, { raw: { width, height, channels: 4 } }) + .webp({ + lossless: true, + nearLossless: false, + smartSubsample: false, + effort: 6, + exact: options?.exact ?? true, + }) + .toBuffer(); +} + // ── Bin-Packing Spritesheet Builder ──────────────────────────────── interface PackedRect { @@ -155,15 +256,37 @@ interface FrameInfo { h: number; } -export function buildSpritesheet(images: SpriteImage[]): { - png: Buffer; +export interface Spritesheet { + /** Encoded texture bytes, in `format`. */ + texture: Buffer; + format: NitroTextureFormat; frames: Map; width: number; height: number; -} { +} + +export async function buildSpritesheet( + images: SpriteImage[], + options?: { format?: NitroTextureFormat }, +): Promise { + const format = options?.format ?? "webp"; + const encode = async ( + width: number, + height: number, + rgba: Buffer, + ): Promise => + format === "webp" + ? await encodeWebpLossless(width, height, rgba) + : encodePng(width, height, rgba); + if (images.length === 0) { - const emptyPng = encodePng(1, 1, Buffer.alloc(4)); - return { png: emptyPng, frames: new Map(), width: 1, height: 1 }; + return { + texture: await encode(1, 1, Buffer.alloc(4)), + format, + frames: new Map(), + width: 1, + height: 1, + }; } const { rects, width: sheetWidth, height: sheetHeight } = binPack(images); @@ -187,28 +310,59 @@ export function buildSpritesheet(images: SpriteImage[]): { frames.set(rect.name, { x: rect.x, y: rect.y, w: rect.w, h: rect.h }); } - const png = encodePng(sheetWidth, sheetHeight, combined); - return { png, frames, width: sheetWidth, height: sheetHeight }; + const texture = await encode(sheetWidth, sheetHeight, combined); + return { texture, format, frames, width: sheetWidth, height: sheetHeight }; } // ── .nitro Bundle Creator ────────────────────────────────────────── +/** + * The client resolves the spritesheet through `spritesheet.meta.image`, so + * that pointer has to name the member we actually write. Editing metadata in + * the studio can leave a stale `chair.png` behind, and a bundle whose JSON + * disagrees with its own member list silently renders nothing — so the + * pointer is corrected here, at the single point where bytes are written, + * rather than trusting every call site to remember. The input is not mutated. + */ +function withTexturePointer(json: object, textureFileName: string): object { + const record = json as Record; + const spritesheet = record.spritesheet as Record | undefined; + const meta = spritesheet?.meta as Record | undefined; + if (!meta || meta.image === textureFileName) return json; + return { + ...record, + spritesheet: { ...spritesheet, meta: { ...meta, image: textureFileName } }, + }; +} + export function createNitroBundle( jsonData: object, - pngData: Buffer, + textureData: Buffer, name: string, ): Buffer { - const jsonStr = JSON.stringify(jsonData); - const jsonCompressed = deflateSync(Buffer.from(jsonStr, "utf-8")); - const pngCompressed = deflateSync(pngData); + // Label the member by what the bytes actually are, never by a caller's + // assumption, so `parseNitroBundle` and the client both resolve the right + // extension. + const detected = detectNitroTextureFormat(textureData); + if (!detected) + throw Error( + "Nitro texture must be a PNG or WebP image; the client renderer supports no other format", + ); const jsonFileName = `${name}.json`; - const pngFileName = `${name}.png`; + const textureFileName = `${name}.${detected}`; + + const jsonStr = JSON.stringify(withTexturePointer(jsonData, textureFileName)); + const jsonCompressed = deflateSync(Buffer.from(jsonStr, "utf-8")); + // The texture arrives already compressed by its own codec (WebP/VP8L or + // PNG/DEFLATE), so this layer is mostly a no-op pass-through; it stays + // because the container format mandates it and readers inflate it. + const textureCompressed = deflateSync(textureData); const totalSize = 2 + (2 + jsonFileName.length + 4 + jsonCompressed.length) + - (2 + pngFileName.length + 4 + pngCompressed.length); + (2 + textureFileName.length + 4 + textureCompressed.length); const bundle = Buffer.alloc(totalSize); let offset = 0; @@ -225,28 +379,52 @@ export function createNitroBundle( jsonCompressed.copy(bundle, offset); offset += jsonCompressed.length; - bundle.writeInt16BE(pngFileName.length, offset); + bundle.writeInt16BE(textureFileName.length, offset); offset += 2; - bundle.write(pngFileName, offset, pngFileName.length, "utf-8"); - offset += pngFileName.length; - bundle.writeInt32BE(pngCompressed.length, offset); + bundle.write(textureFileName, offset, textureFileName.length, "utf-8"); + offset += textureFileName.length; + bundle.writeInt32BE(textureCompressed.length, offset); offset += 4; - pngCompressed.copy(bundle, offset); + textureCompressed.copy(bundle, offset); return bundle; } // ── .nitro Bundle Parser ────────────────────────────────────────── +export interface ParsedNitroBundle { + json: Record; + jsonFileName: string; + /** Decoded spritesheet bytes. WebP or PNG — sniff the format, don't assume. */ + texture: Buffer; + textureFileName: string; + /** Format of `texture`, taken from its filename. */ + textureFormat: NitroTextureFormat; + /** + * @deprecated Historical name for `texture`. Kept so existing callers and + * the emitted client payload stay compatible; the bytes are format-agnostic. + */ + png: Buffer; + /** @deprecated Historical name for `textureFileName`. */ + pngFileName: string; + /** Deflated byte length of each member, as stored in the container. */ + compressedSizes: { json: number; texture: number }; + /** Size of the container itself, in bytes. */ + bundleBytes: number; +} + +/** + * Parse a `.nitro` bundle. + * + * The container stores a filename per member, so the texture format is read + * from that name and cross-checked against the bytes. Both PNG and WebP + * textures parse; anything else is rejected rather than handed downstream as + * an undecodable image. + */ export function parseNitroBundle( buffer: Buffer, options?: { maxOutputLength?: number }, -): { - json: Record; - jsonFileName: string; - png: Buffer; - pngFileName: string; -} { +): ParsedNitroBundle { let offset = 0; const fileCount = buffer.readInt16BE(offset); @@ -273,18 +451,41 @@ export function parseNitroBundle( const jsonStr = inflateJsonOrGzip(jsonCompressed, options).toString("utf-8"); const json = JSON.parse(jsonStr); - // File 2: PNG - const pngFileNameLen = buffer.readInt16BE(offset); + // File 2: texture (PNG or WebP) + const textureFileNameLen = buffer.readInt16BE(offset); offset += 2; - const pngFileName = buffer.toString("utf-8", offset, offset + pngFileNameLen); - offset += pngFileNameLen; - const pngCompressedLen = buffer.readInt32BE(offset); + const textureFileName = buffer.toString( + "utf-8", + offset, + offset + textureFileNameLen, + ); + offset += textureFileNameLen; + const textureCompressedLen = buffer.readInt32BE(offset); offset += 4; - const pngCompressed = buffer.subarray(offset, offset + pngCompressedLen); - offset += pngCompressedLen; - const png = inflateJsonOrGzip(pngCompressed, options); + const textureCompressed = buffer.subarray( + offset, + offset + textureCompressedLen, + ); + offset += textureCompressedLen; + const texture = inflateJsonOrGzip(textureCompressed, options); - return { json, jsonFileName, png, pngFileName }; + const textureFormat = detectNitroTextureFormat(texture); + if (!textureFormat) + throw new Error( + `Unsupported .nitro texture "${textureFileName}": expected a PNG or WebP image`, + ); + + return { + json, + jsonFileName, + texture, + textureFileName, + textureFormat, + png: texture, + pngFileName: textureFileName, + compressedSizes: { json: jsonCompressedLen, texture: textureCompressedLen }, + bundleBytes: buffer.length, + }; } /** diff --git a/src/lib/services/swf/nitro-bundle-limits.test.ts b/src/lib/services/swf/nitro-bundle-limits.test.ts index 9a16821a..412514ab 100644 --- a/src/lib/services/swf/nitro-bundle-limits.test.ts +++ b/src/lib/services/swf/nitro-bundle-limits.test.ts @@ -4,9 +4,15 @@ import { createNitroBundle, parseNitroBundle } from "./nitro-builder"; it("honors an explicit decompression limit for imported bundles", () => { const bundle = createNitroBundle( { name: "chair", value: "a".repeat(4096) }, - Buffer.from("png"), + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), "chair", ); expect(() => parseNitroBundle(bundle, { maxOutputLength: 1024 })).toThrow(); expect(parseNitroBundle(bundle).json.name).toBe("chair"); }); + +it("refuses to write a bundle whose texture is neither PNG nor WebP", () => { + expect(() => + createNitroBundle({ name: "chair" }, Buffer.from("not an image"), "chair"), + ).toThrow(/PNG or WebP/); +}); diff --git a/src/lib/services/swf/nitro-webp.test.ts b/src/lib/services/swf/nitro-webp.test.ts new file mode 100644 index 00000000..dbe0cd36 --- /dev/null +++ b/src/lib/services/swf/nitro-webp.test.ts @@ -0,0 +1,266 @@ +import sharp from "sharp"; +import { describe, expect, it } from "vitest"; +import type { SpriteImage } from "./image-decoder"; +import { + buildSpritesheet, + createNitroBundle, + detectNitroTextureFormat, + encodePng, + encodeWebpLossless, + isLosslessWebp, + parseNitroBundle, +} from "./nitro-builder"; + +/** + * Sprites shaped like real SWF DefineBitsLossless2 output: an opaque border + * around a body whose interior is fully transparent yet still carries + * non-zero palette RGB. That combination is what separates a genuinely + * lossless encoder from one that quietly drops hidden colour data. + */ +function swfStyleSprite(index: number): SpriteImage { + const width = 17 + (index % 5) * 4, + height = 13 + (index % 3) * 6, + pixels = Buffer.alloc(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const o = (y * width + x) * 4; + const border = x === 0 || y === 0 || x === width - 1 || y === height - 1; + if (border) { + pixels[o] = 20 + index; + pixels[o + 1] = 140; + pixels[o + 2] = 200; + pixels[o + 3] = 255; + } else { + // Transparent, but carrying leftover palette colour. + pixels[o] = 9 + index * 3; + pixels[o + 1] = 77; + pixels[o + 2] = 5; + pixels[o + 3] = 0; + } + } + } + return { id: index, name: `chair_${index}`, width, height, pixels }; +} + +const sprites = () => [0, 1, 2, 3, 4].map(swfStyleSprite); + +describe("nitro texture format detection", () => { + it("identifies PNG and WebP by magic bytes, not by filename", () => { + expect( + detectNitroTextureFormat(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), + ).toBe("png"); + expect( + detectNitroTextureFormat( + Buffer.from("RIFF\x00\x00\x00\x00WEBPVP8L", "latin1"), + ), + ).toBe("webp"); + expect(detectNitroTextureFormat(Buffer.from("GIF89a"))).toBeNull(); + expect(detectNitroTextureFormat(Buffer.alloc(0))).toBeNull(); + // A RIFF container that is not WebP (e.g. WAVE) must not pass. + expect( + detectNitroTextureFormat( + Buffer.from("RIFF\x00\x00\x00\x00WAVEfmt ", "latin1"), + ), + ).toBeNull(); + }); +}); + +describe("WebP lossless encoding", () => { + it("round-trips every texel bit-for-bit, including colour hidden under alpha 0", async () => { + const sprite = swfStyleSprite(0); + const encoded = await encodeWebpLossless( + sprite.width, + sprite.height, + sprite.pixels, + ); + expect(encoded.toString("ascii", 12, 16)).toBe("VP8L"); + const decoded = await sharp(encoded).raw().ensureAlpha().toBuffer(); + expect(decoded).toEqual(sprite.pixels); + }); + + it("keeps partially transparent texels exact as well", async () => { + const pixels = Buffer.from([ + 10, 20, 30, 255, 40, 50, 60, 128, 70, 80, 90, 1, 100, 110, 120, 254, + ]); + const encoded = await encodeWebpLossless(4, 1, pixels); + expect(await sharp(encoded).raw().ensureAlpha().toBuffer()).toEqual(pixels); + }); + + it("is far smaller than the equivalent PNG", async () => { + const sheet = await buildSpritesheet(sprites(), { format: "png" }); + const webpSheet = await buildSpritesheet(sprites(), { format: "webp" }); + expect(webpSheet.texture.length).toBeLessThan(sheet.texture.length); + }); +}); + +describe("WebP losslessness detection", () => { + it("confirms a VP8L image is lossless", async () => { + const sprite = swfStyleSprite(0); + const encoded = await encodeWebpLossless( + sprite.width, + sprite.height, + sprite.pixels, + ); + expect(isLosslessWebp(encoded)).toBe(true); + }); + + it("rejects a lossy VP8 image even though the container says WebP", async () => { + const sprite = swfStyleSprite(0); + const lossy = await sharp(sprite.pixels, { + raw: { width: sprite.width, height: sprite.height, channels: 4 }, + }) + .webp({ quality: 80 }) + .toBuffer(); + // The container is still RIFF/WEBP, so only the chunk tells the truth. + expect(detectNitroTextureFormat(lossy)).toBe("webp"); + expect(isLosslessWebp(lossy)).toBe(false); + }); + + it("is not fooled by a PNG or by a truncated header", () => { + expect(isLosslessWebp(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))).toBe( + false, + ); + expect(isLosslessWebp(Buffer.from("RIFF"))).toBe(false); + expect(isLosslessWebp(Buffer.alloc(0))).toBe(false); + }); + + it("reports the bytes of an empty sheet as lossless", async () => { + const sheet = await buildSpritesheet([], { format: "webp" }); + expect(isLosslessWebp(sheet.texture)).toBe(true); + }); +}); + +describe("spritesheet encoding", () => { + it("defaults to WebP and reports the format it produced", async () => { + const sheet = await buildSpritesheet(sprites()); + expect(sheet.format).toBe("webp"); + expect(detectNitroTextureFormat(sheet.texture)).toBe("webp"); + expect(sheet.frames.size).toBe(5); + }); + + it("still encodes PNG on request for legacy bundles", async () => { + const sheet = await buildSpritesheet(sprites(), { format: "png" }); + expect(sheet.format).toBe("png"); + expect(detectNitroTextureFormat(sheet.texture)).toBe("png"); + }); + + it("emits a decodable sheet whose frames hold the original pixels", async () => { + const source = sprites(); + const sheet = await buildSpritesheet(source, { format: "webp" }); + const decoded = await sharp(sheet.texture).raw().ensureAlpha().toBuffer(); + for (const sprite of source) { + const rect = sheet.frames.get(sprite.name); + if (!rect) throw new Error(`frame ${sprite.name} is missing`); + for (let y = 0; y < sprite.height; y++) { + const from = y * sprite.width * 4; + const to = ((rect.y + y) * sheet.width + rect.x) * 4; + expect(decoded.subarray(to, to + sprite.width * 4)).toEqual( + sprite.pixels.subarray(from, from + sprite.width * 4), + ); + } + } + }); + + it("produces a valid empty sheet rather than failing", async () => { + const sheet = await buildSpritesheet([], { format: "webp" }); + expect(sheet.width).toBe(1); + expect(sheet.frames.size).toBe(0); + expect(detectNitroTextureFormat(sheet.texture)).toBe("webp"); + }); +}); + +describe("bundle round-trip", () => { + const metadata = (name: string) => ({ + name, + spritesheet: { + meta: { image: `${name}.webp`, format: "RGBA8888", size: { w: 1, h: 1 } }, + frames: { [`${name}_0`]: { frame: { x: 0, y: 0, w: 1, h: 1 } } }, + }, + assets: { [`${name}_0`]: { x: 0, y: 0 } }, + }); + + it("names the member after the real encoding and parses it back", async () => { + const sheet = await buildSpritesheet(sprites(), { format: "webp" }); + const bundle = createNitroBundle(metadata("chair"), sheet.texture, "chair"); + const parsed = parseNitroBundle(bundle); + expect(parsed.textureFileName).toBe("chair.webp"); + expect(parsed.textureFormat).toBe("webp"); + expect(parsed.texture.toString("ascii", 12, 16)).toBe("VP8L"); + // The historical aliases must keep pointing at the same bytes. + expect(parsed.png).toEqual(parsed.texture); + expect(parsed.pngFileName).toBe("chair.webp"); + }); + + it("keeps labelling a PNG texture as .png", () => { + const png = encodePng(1, 1, Buffer.from([1, 2, 3, 255])); + const parsed = parseNitroBundle( + createNitroBundle(metadata("chair"), png, "chair"), + ); + expect(parsed.textureFileName).toBe("chair.png"); + expect(parsed.textureFormat).toBe("png"); + }); + + it("reports the member sizes the compression report relies on", async () => { + const sheet = await buildSpritesheet(sprites(), { format: "webp" }); + const bundle = createNitroBundle(metadata("chair"), sheet.texture, "chair"); + const parsed = parseNitroBundle(bundle); + expect(parsed.compressedSizes.texture).toBeGreaterThan(0); + expect(parsed.compressedSizes.json).toBeGreaterThan(0); + expect(parsed.bundleBytes).toBe(bundle.length); + // Overhead is the container framing, so members must fit inside it. + expect( + parsed.compressedSizes.json + parsed.compressedSizes.texture, + ).toBeLessThan(parsed.bundleBytes); + }); + + it("repairs a stale .png pointer instead of writing an inconsistent bundle", async () => { + const sheet = await buildSpritesheet(sprites(), { format: "webp" }); + // A studio metadata edit can leave the old pointer behind while the + // texture itself stays WebP; the client would then look for a member + // that is not in the bundle. + const stale = metadata("chair"); + stale.spritesheet.meta.image = "chair.png"; + const parsed = parseNitroBundle( + createNitroBundle(stale, sheet.texture, "chair"), + ); + expect(parsed.textureFileName).toBe("chair.webp"); + expect( + (parsed.json.spritesheet as { meta: { image: string } }).meta.image, + ).toBe("chair.webp"); + // The caller's object must not be mutated in place. + expect(stale.spritesheet.meta.image).toBe("chair.png"); + }); + + it("leaves a matching pointer and the rest of the metadata alone", () => { + const png = encodePng(1, 1, Buffer.from([1, 2, 3, 255])); + const parsed = parseNitroBundle( + createNitroBundle(metadata("chair"), png, "chair"), + ); + expect( + (parsed.json.spritesheet as { meta: { image: string } }).meta.image, + ).toBe("chair.png"); + expect( + (parsed.json.spritesheet as { frames: Record }).frames, + ).toEqual({ chair_0: { frame: { x: 0, y: 0, w: 1, h: 1 } } }); + }); + + it("tolerates metadata that has no spritesheet at all", () => { + const png = encodePng(1, 1, Buffer.from([1, 2, 3, 255])); + const parsed = parseNitroBundle( + createNitroBundle({ name: "bare" }, png, "bare"), + ); + expect(parsed.textureFileName).toBe("bare.png"); + expect(parsed.json.name).toBe("bare"); + }); + + it("rejects a bundle whose texture is neither PNG nor WebP", () => { + const good = encodePng(1, 1, Buffer.from([1, 2, 3, 255])); + expect(() => + createNitroBundle(metadata("chair"), Buffer.from("GIF89a"), "chair"), + ).toThrow(/PNG or WebP/); + // Sanity: the valid case still builds. + expect( + createNitroBundle(metadata("chair"), good, "chair").length, + ).toBeGreaterThan(0); + }); +});