feat(nitro): convert imported SWF bundles to WebP Lossless
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 31s
CI / tests-unit (push) Successful in 1m59s
CI / tests-integration (push) Successful in 2m19s
CI / tests-ui (push) Successful in 2m56s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m18s

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.
This commit is contained in:
openhands committed 2026-09-27 15:42:21 +02:00
1 parent 420210ffa0
commit 17de94d984
22 files changed
+1118 -125

No files matched your search

+117
View File
@@ -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);
});
});
+168
View File
@@ -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<string, unknown> =>
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<string, unknown>;
jsonFileName: string;
texture: Buffer;
textureFileName: string;
textureFormat: "png" | "webp";
compressedSizes: { json: number; texture: number };
bundleBytes: number;
}): Promise<CompressionReport> {
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,
};
}
+29 -13
View File
@@ -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<string, boolean>();
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),