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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user