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

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