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.
276 lines
8.2 KiB
TypeScript
276 lines
8.2 KiB
TypeScript
"use client";
|
||
import { useEffect, useState } from "react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { adminFetch } from "@/lib/admin-fetch";
|
||
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,
|
||
classname,
|
||
revision,
|
||
}: {
|
||
report: ScaleReport;
|
||
classname: string;
|
||
revision: number;
|
||
}) {
|
||
const [asset, setAsset] = useState(report.assets[0] ?? ""),
|
||
[failed, setFailed] = useState(false);
|
||
return (
|
||
<section className="min-w-0 space-y-3 rounded-lg border p-3">
|
||
<h3 className="font-semibold">
|
||
Scale {report.size} ·{" "}
|
||
{report.state === "missing"
|
||
? "Not included"
|
||
: report.state === "issues"
|
||
? "Needs review"
|
||
: report.derived
|
||
? "Derived from 64"
|
||
: "Present"}
|
||
</h3>
|
||
<p className="text-xs text-muted-foreground">
|
||
{report.assets.length} sprites · {report.animations.length} animation
|
||
definitions · directions {report.directions.join(", ") || "—"}
|
||
</p>
|
||
{report.state === "missing" && (
|
||
<p className="text-sm">
|
||
This bundle contains no sprites at this scale. Missing scale 32 does
|
||
not by itself mean the furniture is unusable.
|
||
</p>
|
||
)}
|
||
{report.assets.length > 0 && (
|
||
<>
|
||
<label className="block text-sm">
|
||
Sprite
|
||
<select
|
||
className="mt-1 block w-full rounded border bg-background p-2"
|
||
value={asset}
|
||
onChange={(event) => {
|
||
setAsset(event.target.value);
|
||
setFailed(false);
|
||
}}
|
||
>
|
||
{report.assets.map((name) => (
|
||
<option key={name} value={name}>
|
||
{name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30">
|
||
{failed ? (
|
||
<p role="alert">This sprite could not be rendered.</p>
|
||
) : (
|
||
<img
|
||
key={`${asset}-${revision}`}
|
||
src={`/api/admin/studio/nitro-quality?classname=${encodeURIComponent(classname)}&asset=${encodeURIComponent(asset)}&v=${revision}`}
|
||
alt={asset}
|
||
style={{
|
||
imageRendering: "pixelated",
|
||
maxWidth: "none",
|
||
flexShrink: 0,
|
||
}}
|
||
onError={() => setFailed(true)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
{report.issues.length > 0 && (
|
||
<ul className="max-h-40 overflow-auto space-y-2 text-xs">
|
||
{report.issues.map((issue) => (
|
||
<li key={`${issue.asset ?? ""}-${issue.message}`}>
|
||
<strong className="break-all">{issue.asset}</strong>{" "}
|
||
{issue.message}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
export function NitroQualityPanel({ classname }: { classname: string }) {
|
||
const [report, setReport] = useState<NitroQuality | null>(null),
|
||
[error, setError] = useState(""),
|
||
[revision, setRevision] = useState(0);
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
setReport(null);
|
||
setError("");
|
||
void (async () => {
|
||
try {
|
||
const response = await adminFetch(
|
||
`/api/admin/studio/nitro-quality?classname=${encodeURIComponent(classname)}&v=${revision}`,
|
||
{ signal: controller.signal },
|
||
);
|
||
const data = await response.json();
|
||
if (!response.ok) throw Error(data.error || "Inspection failed");
|
||
if (!controller.signal.aborted) setReport(data.report);
|
||
} catch (error) {
|
||
if (!controller.signal.aborted)
|
||
setError(
|
||
error instanceof Error ? error.message : "Inspection failed",
|
||
);
|
||
}
|
||
})();
|
||
return () => controller.abort();
|
||
}, [classname, revision]);
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="text-sm">
|
||
Inspect the saved bundle at both scales. Previews show individual
|
||
sprites, not the assembled furniture or animation playback.
|
||
</p>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setRevision((value) => value + 1)}
|
||
>
|
||
Recheck
|
||
</Button>
|
||
</div>
|
||
{error ? (
|
||
<p role="alert">{error}</p>
|
||
) : !report ? (
|
||
<p role="status">Checking scales and texture references…</p>
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|