Files
EpicNext-Cms/src/lib/furni/nitro-quality.ts
T
openhands 17de94d984
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
feat(nitro): convert imported SWF bundles to WebP Lossless
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.
2026-09-27 15:42:21 +02:00

370 lines
11 KiB
TypeScript

import {
encodeWebpLossless,
isLosslessWebp,
} from "@/lib/services/swf/nitro-builder";
export interface ScaleIssue {
asset?: string;
message: string;
}
export interface ScaleReport {
size: 32 | 64;
derived?: boolean;
state: "missing" | "issues" | "present";
assets: string[];
animations: string[];
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)
? (value as Record<string, unknown>)
: {};
export function resolveNitroFrame(
metadata: Record<string, unknown>,
name: string,
): {
name: string;
asset: Record<string, unknown>;
frame: Record<string, unknown>;
flipH: boolean;
flipV: boolean;
} {
const assets = object(metadata.assets),
frames = object(object(metadata.spritesheet).frames),
seen = new Set<string>();
let current = name,
flipH = false,
flipV = false;
for (let depth = 0; depth < 100; depth++) {
if (seen.has(current)) throw Error("Cyclic sprite reference");
seen.add(current);
const asset = object(assets[current]);
flipH = flipH !== Boolean(asset.flipH);
flipV = flipV !== Boolean(asset.flipV);
if (typeof asset.source === "string" && asset.source !== current) {
current = asset.source;
continue;
}
const candidates =
typeof metadata.name === "string"
? [current, `${metadata.name}_${current}`]
: [current];
for (const candidate of candidates) {
for (const key of [candidate, `${candidate}.png`]) {
const frame = object(frames[key]);
if (Object.keys(frame).length)
return {
name: key,
asset: object(assets[name]),
frame,
flipH,
flipV,
};
}
}
throw Error(`Missing texture frame: ${current}`);
}
throw Error("Sprite reference chain is too long");
}
export function inspectNitroScales(
metadata: Record<string, unknown>,
width: number,
height: number,
): NitroQuality {
const assets = object(metadata.assets),
visualizations = Array.isArray(metadata.visualizations)
? metadata.visualizations
: [];
return {
scales: ([32, 64] as const).map((size) => {
const names = Object.keys(assets)
.filter((name) => name.includes(`_${size}_`))
.sort();
const viz = object(visualizations.find((v) => object(v).size === size)),
issues: ScaleIssue[] = [];
if (names.length && !Object.keys(viz).length)
issues.push({
message: "Sprites exist but the scale visualization is missing",
});
if (!names.length && Object.keys(viz).length)
issues.push({
message:
"Visualization exists but no sprites were found for this scale",
});
for (const name of names) {
const asset = object(assets[name]);
if (
["x", "y"].some(
(key) =>
asset[key] !== undefined &&
(typeof asset[key] !== "number" || !Number.isFinite(asset[key])),
)
)
issues.push({ asset: name, message: "Invalid sprite offset" });
try {
const resolved = resolveNitroFrame(metadata, name),
rect = object(resolved.frame.frame);
if (resolved.name.includes(`_${size === 32 ? 64 : 32}_`))
issues.push({
asset: name,
message:
"Sprite references the other scale; this is not native artwork at this scale",
});
const { x, y, w, h } = rect;
if (
![x, y, w, h].every(
(n) => typeof n === "number" && Number.isInteger(n),
) ||
Number(x) < 0 ||
Number(y) < 0 ||
Number(w) <= 0 ||
Number(h) <= 0 ||
Number(x) + Number(w) > width ||
Number(y) + Number(h) > height
)
issues.push({
asset: name,
message: "Texture frame is outside the image bounds",
});
} catch (error) {
issues.push({ asset: name, message: (error as Error).message });
}
}
const animations = Object.keys(object(viz.animations));
for (const [id, animation] of Object.entries(object(viz.animations))) {
const anim = object(animation);
for (const key of ["transitionTo", "transitionFrom"])
if (
anim[key] !== undefined &&
Number(anim[key]) >= 0 &&
!animations.includes(String(anim[key]))
)
issues.push({
message: `Animation ${id}: missing transition ${anim[key]}`,
});
}
for (const [animationId, animation] of Object.entries(
object(viz.animations),
)) {
for (const [layerId, layer] of Object.entries(
object(object(animation).layers),
)) {
const layerName = /^\d+$/.test(layerId)
? String.fromCharCode(97 + Number(layerId))
: layerId;
const frameIds = new Set<number>();
for (const sequence of Object.values(
object(object(layer).frameSequences),
)) {
for (const frame of Object.values(
object(object(sequence).frames),
)) {
const id = object(frame).id;
if (typeof id === "number" && Number.isInteger(id) && id >= 0)
frameIds.add(id);
}
}
for (const frameId of frameIds)
if (
!names.some(
(name) =>
name.includes(`_${size}_${layerName}_`) &&
name.endsWith(`_${frameId}`),
)
)
issues.push({
message: `Animation ${animationId}: no sprite found for layer ${layerId}, frame ${frameId}`,
});
}
}
const directions = [
...new Set(
names
.map(
(name) => name.match(new RegExp(`_${size}_[^_]+_([0-7])_`))?.[1],
)
.filter((v): v is string => v !== undefined),
),
].sort();
return {
size,
derived:
size === 32 &&
object(object(metadata.catalogStudio).scale32).derivedFrom === 64,
state: issues.length ? "issues" : names.length ? "present" : "missing",
assets: names,
animations,
directions,
issues,
};
}),
};
}
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,
};
}