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
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:
1 parent
420210ffa0
commit
17de94d984
22 files changed
+1118
-125
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user