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

@@ -2,7 +2,128 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
import type { NitroQuality, ScaleReport } from "@/lib/furni/nitro-quality";
import type {
CompressionReport,
NitroQuality,
ScaleReport,
} from "@/lib/furni/nitro-quality";
function kb(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function CompressionPanel({ report }: { report: CompressionReport }) {
const { texture, json } = report,
// Differences that a renderer can actually see.
visibleDiffs = texture.mismatchedPixels - texture.invisibleDifferences,
// `webpEstimate.savingBytes` measures a PNG texture against a WebP
// re-encode, so it reads ~0 for a bundle that already *is* WebP. The
// honest WebP-vs-PNG comparison is the stored bytes against the PNG
// encoding of the same artwork.
pngSaving = texture.rawBytes - texture.pngEstimate.bytes;
return (
<section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">
Compression · {texture.format.toUpperCase()}
{texture.format === "webp"
? texture.lossless
? " Lossless (VP8L)"
: " Lossy (VP8)"
: ""}
</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Bundle</dt>
<dd className="font-medium">{kb(report.bundleBytes)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Texture</dt>
<dd className="font-medium">
{kb(texture.compressedBytes)}
<span className="text-muted-foreground">
{" "}
({Math.round(report.textureShare * 100)}%)
</span>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Metadata</dt>
<dd className="font-medium">
{kb(json.compressedBytes)}
<span className="text-muted-foreground"> · ×{json.ratio}</span>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Container</dt>
<dd className="font-medium">{kb(report.overheadBytes)}</dd>
</div>
</dl>
<p className="text-xs text-muted-foreground">
Texture {kb(texture.rawBytes)} decoded · deflate ×{texture.ratio} ·
sheet {texture.width}×{texture.height} · {texture.spriteCount} frames
</p>
{texture.format === "png" ? (
<p className="text-xs">
Legacy PNG texture. Re-importing as WebP Lossless would store{" "}
<strong>{kb(texture.webpEstimate.bytes)}</strong>
{texture.webpEstimate.savingBytes > 0 ? (
<>
, saving <strong>{kb(texture.webpEstimate.savingBytes)}</strong> (
{texture.webpEstimate.bytes > 0
? Math.round(
(1 - texture.webpEstimate.bytes / texture.rawBytes) * 100,
)
: 0}
% smaller)
</>
) : (
" (no gain on this bundle)"
)}
</p>
) : (
<p className="text-xs">
WebP Lossless stores {kb(texture.rawBytes)} against{" "}
{kb(texture.pngEstimate.bytes)} for the same artwork as PNG
{pngSaving > 0 ? (
<>
, saving <strong>{kb(pngSaving)}</strong> (
{texture.pngEstimate.bytes > 0
? Math.round((pngSaving / texture.pngEstimate.bytes) * 100)
: 0}
% smaller)
</>
) : (
" (PNG is not larger on this bundle)"
)}
</p>
)}
<p className="text-xs">
{visibleDiffs === 0 ? (
<>
<span className="font-medium text-foreground">Pixel-exact</span> —
every visible texel round-trips unchanged.
{texture.invisibleDifferences > 0 && (
<span className="text-muted-foreground">
{" "}
{texture.invisibleDifferences.toLocaleString()} fully
transparent texels carry no RGB, which VP8L discards and no
renderer can see.
</span>
)}
</>
) : (
<strong className="text-destructive">
{visibleDiffs.toLocaleString()} texel
{visibleDiffs === 1 ? "" : "s"} differ after a lossless round-trip —
the furniture may not render identically.
</strong>
)}
</p>
</section>
);
}
function ScalePanel({
report,
@@ -133,15 +254,20 @@ export function NitroQualityPanel({ classname }: { classname: string }) {
) : !report ? (
<p role="status">Checking scales and texture references…</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{report.scales.map((scale) => (
<ScalePanel
key={`${scale.size}-${revision}`}
report={scale}
classname={classname}
revision={revision}
/>
))}
<div className="space-y-3">
{report.compression && (
<CompressionPanel report={report.compression} />
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{report.scales.map((scale) => (
<ScalePanel
key={`${scale.size}-${revision}`}
report={scale}
classname={classname}
revision={revision}
/>
))}
</div>
</div>
)}
</div>