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