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);
+ });
+});