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
@@ -199,7 +199,10 @@ export const POST = withAdmin(
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const swfBuffer = await fs.readFile(swfPath);
|
const swfBuffer = await fs.readFile(swfPath);
|
||||||
const conversion = convertSwfToNitro(swfBuffer, baseClassname);
|
const conversion = await convertSwfToNitro(
|
||||||
|
swfBuffer,
|
||||||
|
baseClassname,
|
||||||
|
);
|
||||||
await fs.writeFile(nitroPath, conversion.bundle);
|
await fs.writeFile(nitroPath, conversion.bundle);
|
||||||
|
|
||||||
// Build FurnitureData entry if missing
|
// Build FurnitureData entry if missing
|
||||||
|
|||||||
@@ -104,13 +104,16 @@ export const PUT = withAdmin(
|
|||||||
|
|
||||||
const saved = await withFurniDataLock(async () => {
|
const saved = await withFurniDataLock(async () => {
|
||||||
const existingBuffer = await fs.readFile(nitroPath);
|
const existingBuffer = await fs.readFile(nitroPath);
|
||||||
const { png, json: currentMetadata } = parseNitroBundle(existingBuffer);
|
const { texture, json: currentMetadata } =
|
||||||
|
parseNitroBundle(existingBuffer);
|
||||||
if (
|
if (
|
||||||
JSON.stringify(currentMetadata.catalogStudio) !==
|
JSON.stringify(currentMetadata.catalogStudio) !==
|
||||||
JSON.stringify(metadata.catalogStudio)
|
JSON.stringify(metadata.catalogStudio)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
const newBundle = createNitroBundle(metadata, png, base);
|
// `texture` keeps its original encoding, so editing metadata never
|
||||||
|
// changes the bundle's texture format.
|
||||||
|
const newBundle = createNitroBundle(metadata, texture, base);
|
||||||
await fs.writeFile(nitroPath, newBundle);
|
await fs.writeFile(nitroPath, newBundle);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -775,7 +775,7 @@ export const PATCH = withAdmin(
|
|||||||
// Convert SWF → Nitro + update FurnitureData.json
|
// Convert SWF → Nitro + update FurnitureData.json
|
||||||
try {
|
try {
|
||||||
const swfBuffer = await fs.readFile(swfPath);
|
const swfBuffer = await fs.readFile(swfPath);
|
||||||
const conversion = convertSwfToNitro(swfBuffer, baseClassname);
|
const conversion = await convertSwfToNitro(swfBuffer, baseClassname);
|
||||||
await fs.writeFile(nitroPath, conversion.bundle);
|
await fs.writeFile(nitroPath, conversion.bundle);
|
||||||
|
|
||||||
// Add to FurnitureData.json if not already present
|
// Add to FurnitureData.json if not already present
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import sharp from "sharp";
|
|||||||
import { apiError, apiOk } from "@/lib/api";
|
import { apiError, apiOk } from "@/lib/api";
|
||||||
import { withAdmin } from "@/lib/api-handler";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
import {
|
import {
|
||||||
|
buildNitroCompressionReport,
|
||||||
inspectNitroScales,
|
inspectNitroScales,
|
||||||
object,
|
object,
|
||||||
resolveNitroFrame,
|
resolveNitroFrame,
|
||||||
@@ -21,10 +22,11 @@ export const GET = withAdmin(
|
|||||||
const base = String(classname).split("*")[0],
|
const base = String(classname).split("*")[0],
|
||||||
{ nitroDir } = await getFurniAssetDirs();
|
{ nitroDir } = await getFurniAssetDirs();
|
||||||
try {
|
try {
|
||||||
const { json, png } = parseNitroBundle(
|
const parsed = parseNitroBundle(
|
||||||
await fs.readFile(path.join(nitroDir, `${base}.nitro`)),
|
await fs.readFile(path.join(nitroDir, `${base}.nitro`)),
|
||||||
);
|
);
|
||||||
const image = await sharp(png).metadata();
|
const { json, texture } = parsed;
|
||||||
|
const image = await sharp(texture).metadata();
|
||||||
if (!image.width || !image.height)
|
if (!image.width || !image.height)
|
||||||
return apiError("Invalid bundle texture", 422);
|
return apiError("Invalid bundle texture", 422);
|
||||||
const asset = request.nextUrl.searchParams.get("asset");
|
const asset = request.nextUrl.searchParams.get("asset");
|
||||||
@@ -33,7 +35,7 @@ export const GET = withAdmin(
|
|||||||
return apiError("Sprite not found", 404);
|
return apiError("Sprite not found", 404);
|
||||||
const resolved = resolveNitroFrame(json, asset),
|
const resolved = resolveNitroFrame(json, asset),
|
||||||
rect = object(resolved.frame.frame);
|
rect = object(resolved.frame.frame);
|
||||||
let pipeline = sharp(png).extract({
|
let pipeline = sharp(texture).extract({
|
||||||
left: Number(rect.x),
|
left: Number(rect.x),
|
||||||
top: Number(rect.y),
|
top: Number(rect.y),
|
||||||
width: Number(rect.w),
|
width: Number(rect.w),
|
||||||
@@ -51,7 +53,10 @@ export const GET = withAdmin(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return apiOk({
|
return apiOk({
|
||||||
report: inspectNitroScales(json, image.width, image.height),
|
report: {
|
||||||
|
...inspectNitroScales(json, image.width, image.height),
|
||||||
|
compression: await buildNitroCompressionReport(parsed),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||||
|
|||||||
@@ -2,7 +2,128 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
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({
|
function ScalePanel({
|
||||||
report,
|
report,
|
||||||
@@ -133,15 +254,20 @@ export function NitroQualityPanel({ classname }: { classname: string }) {
|
|||||||
) : !report ? (
|
) : !report ? (
|
||||||
<p role="status">Checking scales and texture references…</p>
|
<p role="status">Checking scales and texture references…</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="space-y-3">
|
||||||
{report.scales.map((scale) => (
|
{report.compression && (
|
||||||
<ScalePanel
|
<CompressionPanel report={report.compression} />
|
||||||
key={`${scale.size}-${revision}`}
|
)}
|
||||||
report={scale}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
classname={classname}
|
{report.scales.map((scale) => (
|
||||||
revision={revision}
|
<ScalePanel
|
||||||
/>
|
key={`${scale.size}-${revision}`}
|
||||||
))}
|
report={scale}
|
||||||
|
classname={classname}
|
||||||
|
revision={revision}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { SpriteImage } from "@/lib/services/swf/image-decoder";
|
||||||
|
import {
|
||||||
|
buildSpritesheet,
|
||||||
|
createNitroBundle,
|
||||||
|
encodePng,
|
||||||
|
parseNitroBundle,
|
||||||
|
} from "@/lib/services/swf/nitro-builder";
|
||||||
|
import {
|
||||||
|
buildNitroCompressionReport,
|
||||||
|
inspectNitroScales,
|
||||||
|
} from "./nitro-quality";
|
||||||
|
|
||||||
|
const sprite = (): SpriteImage => {
|
||||||
|
const width = 12,
|
||||||
|
height = 10,
|
||||||
|
pixels = Buffer.alloc(width * height * 4);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const o = (y * width + x) * 4;
|
||||||
|
const border = x === 0 || y === 0 || x === width - 1 || y === height - 1;
|
||||||
|
pixels[o] = border ? 220 : 30;
|
||||||
|
pixels[o + 1] = border ? 90 : 180;
|
||||||
|
pixels[o + 2] = border ? 40 : 60;
|
||||||
|
pixels[o + 3] = border ? 255 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: 0, name: "chair_0", width, height, pixels };
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = () => ({
|
||||||
|
name: "chair",
|
||||||
|
spritesheet: {
|
||||||
|
meta: { image: "chair.webp", format: "RGBA8888", size: { w: 16, h: 16 } },
|
||||||
|
frames: { chair_0: { frame: { x: 0, y: 0, w: 12, h: 10 } } },
|
||||||
|
},
|
||||||
|
assets: { chair_0: { x: 0, y: 0 } },
|
||||||
|
visualizations: [{ size: 64, animations: {} }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const bundleFor = async (format: "png" | "webp") => {
|
||||||
|
const sheet = await buildSpritesheet([sprite()], { format });
|
||||||
|
return createNitroBundle(metadata(), sheet.texture, "chair");
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("nitro compression report", () => {
|
||||||
|
it("describes a WebP bundle and proves it is pixel-exact", async () => {
|
||||||
|
const report = await buildNitroCompressionReport(
|
||||||
|
parseNitroBundle(await bundleFor("webp")),
|
||||||
|
);
|
||||||
|
expect(report.texture.format).toBe("webp");
|
||||||
|
expect(report.texture.fileName).toBe("chair.webp");
|
||||||
|
expect(report.texture.lossless).toBe(true);
|
||||||
|
// `exact: true` means not even transparent RGB is altered.
|
||||||
|
expect(report.texture.mismatchedPixels).toBe(0);
|
||||||
|
expect(report.texture.invisibleDifferences).toBe(0);
|
||||||
|
expect(report.texture.spriteCount).toBe(1);
|
||||||
|
expect(report.texture.width).toBeGreaterThan(0);
|
||||||
|
expect(report.texture.pixels).toBe(
|
||||||
|
report.texture.width * report.texture.height,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks the bundle down into metadata, texture and container overhead", async () => {
|
||||||
|
const parsed = parseNitroBundle(await bundleFor("webp"));
|
||||||
|
const report = await buildNitroCompressionReport(parsed);
|
||||||
|
expect(report.bundleBytes).toBe(parsed.bundleBytes);
|
||||||
|
expect(report.json.compressedBytes).toBe(parsed.compressedSizes.json);
|
||||||
|
expect(report.texture.compressedBytes).toBe(parsed.compressedSizes.texture);
|
||||||
|
expect(report.overheadBytes).toBe(
|
||||||
|
parsed.bundleBytes -
|
||||||
|
parsed.compressedSizes.json -
|
||||||
|
parsed.compressedSizes.texture,
|
||||||
|
);
|
||||||
|
expect(report.overheadBytes).toBeGreaterThan(0);
|
||||||
|
// The texture is most of a small furniture bundle.
|
||||||
|
expect(report.textureShare).toBeGreaterThan(0);
|
||||||
|
expect(report.textureShare).toBeLessThanOrEqual(1);
|
||||||
|
// Ratios are real savings, never zero or negative.
|
||||||
|
expect(report.json.ratio).toBeGreaterThan(0);
|
||||||
|
expect(report.texture.ratio).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows what a legacy PNG bundle would save as WebP Lossless", async () => {
|
||||||
|
const report = await buildNitroCompressionReport(
|
||||||
|
parseNitroBundle(await bundleFor("png")),
|
||||||
|
);
|
||||||
|
expect(report.texture.format).toBe("png");
|
||||||
|
expect(report.texture.fileName).toBe("chair.png");
|
||||||
|
// Still verified lossless — PNG is a lossless codec too.
|
||||||
|
expect(report.texture.mismatchedPixels).toBe(0);
|
||||||
|
expect(report.texture.webpEstimate.bytes).toBeGreaterThan(0);
|
||||||
|
expect(report.texture.pngEstimate.bytes).toBeGreaterThan(0);
|
||||||
|
// The same artwork is cheaper as WebP than as PNG.
|
||||||
|
expect(report.texture.webpEstimate.bytes).toBeLessThan(
|
||||||
|
report.texture.pngEstimate.bytes,
|
||||||
|
);
|
||||||
|
expect(report.texture.webpEstimate.savingBytes).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps working for a bundle with no sprite frames", async () => {
|
||||||
|
const png = encodePng(1, 1, Buffer.from([0, 0, 0, 0]));
|
||||||
|
const report = await buildNitroCompressionReport(
|
||||||
|
parseNitroBundle(createNitroBundle({ name: "empty" }, png, "empty")),
|
||||||
|
);
|
||||||
|
expect(report.texture.spriteCount).toBe(0);
|
||||||
|
expect(report.texture.mismatchedPixels).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quality report composition", () => {
|
||||||
|
it("leaves compression optional so existing scale consumers are unaffected", () => {
|
||||||
|
const quality = inspectNitroScales(metadata(), 16, 16);
|
||||||
|
expect(quality.compression).toBeUndefined();
|
||||||
|
expect(quality.scales).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
encodeWebpLossless,
|
||||||
|
isLosslessWebp,
|
||||||
|
} from "@/lib/services/swf/nitro-builder";
|
||||||
|
|
||||||
export interface ScaleIssue {
|
export interface ScaleIssue {
|
||||||
asset?: string;
|
asset?: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -11,8 +16,56 @@ export interface ScaleReport {
|
|||||||
directions: string[];
|
directions: string[];
|
||||||
issues: ScaleIssue[];
|
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 {
|
export interface NitroQuality {
|
||||||
scales: ScaleReport[];
|
scales: ScaleReport[];
|
||||||
|
compression?: CompressionReport;
|
||||||
}
|
}
|
||||||
export const object = (value: unknown): Record<string, unknown> =>
|
export const object = (value: unknown): Record<string, unknown> =>
|
||||||
value && typeof value === "object" && !Array.isArray(value)
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export async function generateScale32(input: Buffer): Promise<{
|
|||||||
count: number;
|
count: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
}> {
|
}> {
|
||||||
const { json, png } = parseNitroBundle(input);
|
const { json, texture, textureFormat } = parseNitroBundle(input);
|
||||||
const assets = object(json.assets),
|
const assets = object(json.assets),
|
||||||
sheet = object(json.spritesheet),
|
sheet = object(json.spritesheet),
|
||||||
frames = object(sheet.frames);
|
frames = object(sheet.frames);
|
||||||
@@ -67,7 +67,7 @@ export async function generateScale32(input: Buffer): Promise<{
|
|||||||
if (!names.length)
|
if (!names.length)
|
||||||
throw Error("No supported scale 64 furniture sprites were found");
|
throw Error("No supported scale 64 furniture sprites were found");
|
||||||
if (names.length > 10000) throw Error("Too many sprites to generate safely");
|
if (names.length > 10000) throw Error("Too many sprites to generate safely");
|
||||||
const dimensions = await sharp(png).metadata();
|
const dimensions = await sharp(texture).metadata();
|
||||||
if (!dimensions.width || !dimensions.height) throw Error("Invalid texture");
|
if (!dimensions.width || !dimensions.height) throw Error("Invalid texture");
|
||||||
const images: {
|
const images: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -103,7 +103,7 @@ export async function generateScale32(input: Buffer): Promise<{
|
|||||||
}
|
}
|
||||||
const frame = resolved.frame,
|
const frame = resolved.frame,
|
||||||
rect = object(frame.frame);
|
rect = object(frame.frame);
|
||||||
let sprite = await sharp(png)
|
let sprite = await sharp(texture)
|
||||||
.extract({
|
.extract({
|
||||||
left: Number(rect.x),
|
left: Number(rect.x),
|
||||||
top: Number(rect.y),
|
top: Number(rect.y),
|
||||||
@@ -149,25 +149,35 @@ export async function generateScale32(input: Buffer): Promise<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!images.length) throw Error("No independent texture frames were found");
|
if (!images.length) throw Error("No independent texture frames were found");
|
||||||
const atlas = buildSpritesheet(images);
|
const atlas = await buildSpritesheet(images, { format: textureFormat });
|
||||||
const width = Math.max(dimensions.width, atlas.width),
|
const width = Math.max(dimensions.width, atlas.width),
|
||||||
height = dimensions.height + atlas.height;
|
height = dimensions.height + atlas.height;
|
||||||
if (width * height > 64_000_000)
|
if (width * height > 64_000_000)
|
||||||
throw Error("Combined texture exceeds the texture budget");
|
throw Error("Combined texture exceeds the texture budget");
|
||||||
const combined = await sharp({
|
// The merged sheet must keep the source bundle's texture format so an
|
||||||
|
// existing PNG bundle is not silently converted (or vice versa).
|
||||||
|
const composited = sharp({
|
||||||
create: {
|
create: {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
channels: 4,
|
channels: 4,
|
||||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
},
|
},
|
||||||
})
|
}).composite([
|
||||||
.composite([
|
{ input: texture, left: 0, top: 0 },
|
||||||
{ input: png, left: 0, top: 0 },
|
{ input: atlas.texture, left: 0, top: dimensions.height },
|
||||||
{ input: atlas.png, left: 0, top: dimensions.height },
|
]);
|
||||||
])
|
const combined =
|
||||||
.png()
|
textureFormat === "webp"
|
||||||
.toBuffer();
|
? await composited
|
||||||
|
.webp({
|
||||||
|
lossless: true,
|
||||||
|
nearLossless: false,
|
||||||
|
smartSubsample: false,
|
||||||
|
effort: 6,
|
||||||
|
})
|
||||||
|
.toBuffer()
|
||||||
|
: await composited.png().toBuffer();
|
||||||
const generated = new Map<string, boolean>();
|
const generated = new Map<string, boolean>();
|
||||||
for (const name of names)
|
for (const name of names)
|
||||||
if (!skipped.has(name)) generated.set(scaleName(name), true);
|
if (!skipped.has(name)) generated.set(scaleName(name), true);
|
||||||
@@ -198,7 +208,13 @@ export async function generateScale32(input: Buffer): Promise<{
|
|||||||
json.spritesheet = {
|
json.spritesheet = {
|
||||||
...sheet,
|
...sheet,
|
||||||
frames,
|
frames,
|
||||||
meta: { ...object(sheet.meta), size: { w: width, h: height } },
|
meta: {
|
||||||
|
...object(sheet.meta),
|
||||||
|
// Keep the client-facing image pointer aligned with the member
|
||||||
|
// `createNitroBundle` is about to write.
|
||||||
|
image: `${String(json.name || "furniture")}.${textureFormat}`,
|
||||||
|
size: { w: width, h: height },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
json.catalogStudio = {
|
json.catalogStudio = {
|
||||||
...object(json.catalogStudio),
|
...object(json.catalogStudio),
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export async function importSingleEffect(params: {
|
|||||||
onProgress?.("converting");
|
onProgress?.("converting");
|
||||||
try {
|
try {
|
||||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||||
const result = convertSwfToNitro(swfBuffer, lib);
|
const result = await convertSwfToNitro(swfBuffer, lib);
|
||||||
await fs.writeFile(/*turbopackIgnore: true*/ nitroPath, result.bundle);
|
await fs.writeFile(/*turbopackIgnore: true*/ nitroPath, result.bundle);
|
||||||
if (result.warnings?.length) warnings.push(...result.warnings);
|
if (result.warnings?.length) warnings.push(...result.warnings);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export async function importSingleFigure(params: {
|
|||||||
onProgress?.("converting");
|
onProgress?.("converting");
|
||||||
try {
|
try {
|
||||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||||
const result = convertSwfToNitro(swfBuffer, lib);
|
const result = await convertSwfToNitro(swfBuffer, lib);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
/*turbopackIgnore: true*/ await nitroPathFor(lib),
|
/*turbopackIgnore: true*/ await nitroPathFor(lib),
|
||||||
result.bundle,
|
result.bundle,
|
||||||
|
|||||||
@@ -30,5 +30,5 @@ it("rejects missing texture data", () => {
|
|||||||
createNitroBundle({ name: "chair" }, Buffer.from("not png"), "chair"),
|
createNitroBundle({ name: "chair" }, Buffer.from("not png"), "chair"),
|
||||||
"chair",
|
"chair",
|
||||||
),
|
),
|
||||||
).toThrow("not a PNG");
|
).toThrow("PNG or WebP");
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
import {
|
||||||
|
detectNitroTextureFormat,
|
||||||
|
parseNitroBundle,
|
||||||
|
} from "@/lib/services/swf/nitro-builder";
|
||||||
import { importRoot, validJobId } from "./furni-job-store";
|
import { importRoot, validJobId } from "./furni-job-store";
|
||||||
export function validateFurnitureAttachment(buffer: Buffer, classname: string) {
|
export function validateFurnitureAttachment(buffer: Buffer, classname: string) {
|
||||||
if (buffer.length > 50 * 1024 * 1024)
|
if (buffer.length > 50 * 1024 * 1024)
|
||||||
@@ -15,12 +18,11 @@ export function validateFurnitureAttachment(buffer: Buffer, classname: string) {
|
|||||||
(parsed.json.name && parsed.json.name !== base)
|
(parsed.json.name && parsed.json.name !== base)
|
||||||
)
|
)
|
||||||
throw Error(`This bundle does not belong to ${classname}`);
|
throw Error(`This bundle does not belong to ${classname}`);
|
||||||
if (
|
// The renderer reads WebP Lossless and PNG; parseNitroBundle already
|
||||||
!parsed.png
|
// rejects anything else, so this only names the format in the error.
|
||||||
.subarray(0, 8)
|
const format = detectNitroTextureFormat(parsed.texture);
|
||||||
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
if (!format)
|
||||||
)
|
throw Error("The bundle texture is neither a PNG nor a WebP image");
|
||||||
throw Error("The bundle texture is not a PNG");
|
|
||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
export async function stageFurnitureAttachment(
|
export async function stageFurnitureAttachment(
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import { getByparrCookies } from "@/lib/services/byparr";
|
import { getByparrCookies } from "@/lib/services/byparr";
|
||||||
import { parseNitroBundle } from "../../swf/nitro-builder";
|
import {
|
||||||
|
detectNitroTextureFormat,
|
||||||
|
parseNitroBundle,
|
||||||
|
} from "../../swf/nitro-builder";
|
||||||
import { browserHeaders } from "./browser-headers";
|
import { browserHeaders } from "./browser-headers";
|
||||||
import { curlDownload } from "./curl-fetch";
|
import { curlDownload } from "./curl-fetch";
|
||||||
|
|
||||||
@@ -27,7 +30,9 @@ export function validateNitroBytes(buffer: Buffer): boolean {
|
|||||||
!!bundle.json &&
|
!!bundle.json &&
|
||||||
typeof bundle.json === "object" &&
|
typeof bundle.json === "object" &&
|
||||||
!Array.isArray(bundle.json) &&
|
!Array.isArray(bundle.json) &&
|
||||||
validatePngBytes(bundle.png)
|
// parseNitroBundle already rejects non-PNG/WebP textures, so this
|
||||||
|
// is a cheap re-assertion rather than the real gate.
|
||||||
|
detectNitroTextureFormat(bundle.texture) !== null
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export async function importSinglePet(params: {
|
|||||||
onProgress?.("converting");
|
onProgress?.("converting");
|
||||||
try {
|
try {
|
||||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||||
const result = convertSwfToNitro(swfBuffer, lib);
|
const result = await convertSwfToNitro(swfBuffer, lib);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
/*turbopackIgnore: true*/ nitroPathFor(lib),
|
/*turbopackIgnore: true*/ nitroPathFor(lib),
|
||||||
result.bundle,
|
result.bundle,
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export function nitroHasSize32(json: Record<string, unknown>): boolean {
|
|||||||
export async function addSize32ToBundle(
|
export async function addSize32ToBundle(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const { json, png, jsonFileName } = parseNitroBundle(buffer);
|
const { json, texture, textureFormat, jsonFileName } =
|
||||||
|
parseNitroBundle(buffer);
|
||||||
if (nitroHasSize32(json)) return null;
|
if (nitroHasSize32(json)) return null;
|
||||||
|
|
||||||
const name = (json.name as string) || jsonFileName.replace(/\.json$/, "");
|
const name = (json.name as string) || jsonFileName.replace(/\.json$/, "");
|
||||||
@@ -68,7 +69,7 @@ export async function addSize32ToBundle(
|
|||||||
for (const [frameName, frame] of Object.entries(frames)) {
|
for (const [frameName, frame] of Object.entries(frames)) {
|
||||||
const f = frame.frame;
|
const f = frame.frame;
|
||||||
if (!f || f.w <= 0 || f.h <= 0) continue;
|
if (!f || f.w <= 0 || f.h <= 0) continue;
|
||||||
const { data, info } = await sharp(png)
|
const { data, info } = await sharp(texture)
|
||||||
.extract({ left: f.x, top: f.y, width: f.w, height: f.h })
|
.extract({ left: f.x, top: f.y, width: f.w, height: f.h })
|
||||||
.raw()
|
.raw()
|
||||||
.ensureAlpha()
|
.ensureAlpha()
|
||||||
@@ -95,7 +96,7 @@ export async function addSize32ToBundle(
|
|||||||
const f = frame.frame;
|
const f = frame.frame;
|
||||||
if (f.w <= 0 || f.h <= 0) continue;
|
if (f.w <= 0 || f.h <= 0) continue;
|
||||||
|
|
||||||
const src = await sharp(png)
|
const src = await sharp(texture)
|
||||||
.extract({ left: f.x, top: f.y, width: f.w, height: f.h })
|
.extract({ left: f.x, top: f.y, width: f.w, height: f.h })
|
||||||
.raw()
|
.raw()
|
||||||
.ensureAlpha()
|
.ensureAlpha()
|
||||||
@@ -131,7 +132,12 @@ export async function addSize32ToBundle(
|
|||||||
|
|
||||||
if (Object.keys(newAssets).length === 0) return null;
|
if (Object.keys(newAssets).length === 0) return null;
|
||||||
|
|
||||||
const { png: newPng, frames: newFrames } = buildSpritesheet(images);
|
// Preserve the source bundle's texture format: regenerating a scale must
|
||||||
|
// not silently downgrade a WebP bundle back to PNG.
|
||||||
|
const { texture: newTexture, frames: newFrames } = await buildSpritesheet(
|
||||||
|
images,
|
||||||
|
{ format: textureFormat },
|
||||||
|
);
|
||||||
|
|
||||||
// Update existing frame coordinates (sheet was rebuilt).
|
// Update existing frame coordinates (sheet was rebuilt).
|
||||||
for (const [frameName, frame] of Object.entries(frames)) {
|
for (const [frameName, frame] of Object.entries(frames)) {
|
||||||
@@ -171,5 +177,12 @@ export async function addSize32ToBundle(
|
|||||||
vis.push({ ...sourceVis, size: 32 });
|
vis.push({ ...sourceVis, size: 32 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return createNitroBundle(json, newPng, name);
|
// Keep the client-facing image pointer aligned with the member
|
||||||
|
// `createNitroBundle` is about to write.
|
||||||
|
const sheet = (json.spritesheet ?? {}) as Record<string, unknown>;
|
||||||
|
const meta = (sheet.meta ?? {}) as Record<string, unknown>;
|
||||||
|
sheet.meta = { ...meta, image: `${name}.${textureFormat}` };
|
||||||
|
json.spritesheet = sheet;
|
||||||
|
|
||||||
|
return createNitroBundle(json, newTexture, name);
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,19 @@ describe("conversion worker recovery", () => {
|
|||||||
const data = Buffer.from("SWF");
|
const data = Buffer.from("SWF");
|
||||||
const result = await convertSwfToNitroAsync(data, "chair");
|
const result = await convertSwfToNitroAsync(data, "chair");
|
||||||
expect(result.bundle.toString()).toBe("converted");
|
expect(result.bundle.toString()).toBe("converted");
|
||||||
expect(mocks.convert).toHaveBeenCalledWith(data, "chair");
|
expect(mocks.convert).toHaveBeenCalledWith(data, "chair", {
|
||||||
|
textureFormat: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("passes the requested texture format through to the main-thread fallback", async () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
vi.stubEnv("VITEST", undefined);
|
||||||
|
const { convertSwfToNitroAsync } = await import("./conversion-pool");
|
||||||
|
const data = Buffer.from("SWF");
|
||||||
|
await convertSwfToNitroAsync(data, "chair", { textureFormat: "webp" });
|
||||||
|
expect(mocks.convert).toHaveBeenCalledWith(data, "chair", {
|
||||||
|
textureFormat: "webp",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
it("recovers icon extraction from a worker startup error", async () => {
|
it("recovers icon extraction from a worker startup error", async () => {
|
||||||
vi.stubEnv("NODE_ENV", "production");
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
convertSwfToNitro,
|
convertSwfToNitro,
|
||||||
extractIconFromSwf,
|
extractIconFromSwf,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
|
import type { NitroTextureFormat } from "./nitro-builder";
|
||||||
|
|
||||||
// Offloads the synchronous, CPU-heavy SWF→Nitro conversion (and icon
|
// Offloads the synchronous, CPU-heavy SWF→Nitro conversion (and icon
|
||||||
// extraction) to a small pool of worker threads so the main Node event loop
|
// extraction) to a small pool of worker threads so the main Node event loop
|
||||||
@@ -89,28 +90,39 @@ function pump() {
|
|||||||
|
|
||||||
const CONVERSION_TIMEOUT_MS = 60_000;
|
const CONVERSION_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
function run(req: Omit<WorkerRequest, "id">): Promise<WorkerResponse> {
|
// `Omit` does not distribute over a union, which would erase `textureFormat`
|
||||||
|
// from the convert variant.
|
||||||
|
type DistributiveOmit<T, K extends keyof never> = T extends unknown
|
||||||
|
? Omit<T, K>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
function run(
|
||||||
|
req: DistributiveOmit<WorkerRequest, "id">,
|
||||||
|
): Promise<WorkerResponse> {
|
||||||
if (!ensurePool()) {
|
if (!ensurePool()) {
|
||||||
try {
|
// No pool available: run on the main thread. Icon extraction stays
|
||||||
if (req.type === "convert") {
|
// synchronous; the conversion is async because it encodes a spritesheet.
|
||||||
|
if (req.type === "icon") {
|
||||||
|
try {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
id: 0,
|
id: 0,
|
||||||
ok: true,
|
ok: true,
|
||||||
result: convertSwfToNitro(req.buffer, req.classname),
|
icon: extractIconFromSwf(req.buffer, req.classname),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: 0,
|
||||||
|
ok: false,
|
||||||
|
error: (e as Error).message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Promise.resolve({
|
|
||||||
id: 0,
|
|
||||||
ok: true,
|
|
||||||
icon: extractIconFromSwf(req.buffer, req.classname),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return Promise.resolve({
|
|
||||||
id: 0,
|
|
||||||
ok: false,
|
|
||||||
error: (e as Error).message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return convertSwfToNitro(req.buffer, req.classname, {
|
||||||
|
textureFormat: req.textureFormat,
|
||||||
|
}).then(
|
||||||
|
(result) => ({ id: 0, ok: true, result }) as WorkerResponse,
|
||||||
|
(e: Error) => ({ id: 0, ok: false, error: e.message }) as WorkerResponse,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return new Promise<WorkerResponse>((resolve, reject) => {
|
return new Promise<WorkerResponse>((resolve, reject) => {
|
||||||
const job: Job = {
|
const job: Job = {
|
||||||
@@ -153,13 +165,19 @@ function run(req: Omit<WorkerRequest, "id">): Promise<WorkerResponse> {
|
|||||||
export async function convertSwfToNitroAsync(
|
export async function convertSwfToNitroAsync(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
classname: string,
|
classname: string,
|
||||||
|
options?: { textureFormat?: NitroTextureFormat },
|
||||||
): Promise<ConversionResult> {
|
): Promise<ConversionResult> {
|
||||||
const res = await run({ type: "convert", buffer, classname }).catch(
|
const res = await run({
|
||||||
() => null,
|
type: "convert",
|
||||||
);
|
buffer,
|
||||||
|
classname,
|
||||||
|
textureFormat: options?.textureFormat,
|
||||||
|
}).catch(() => null);
|
||||||
if (res?.ok && res.result) return res.result;
|
if (res?.ok && res.result) return res.result;
|
||||||
// Worker failed for this item — fall back to the main thread.
|
// Worker failed for this item — fall back to the main thread.
|
||||||
return convertSwfToNitro(buffer, classname);
|
return convertSwfToNitro(buffer, classname, {
|
||||||
|
textureFormat: options?.textureFormat,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractIconFromSwfAsync(
|
export async function extractIconFromSwfAsync(
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { parentPort } from "node:worker_threads";
|
import { parentPort } from "node:worker_threads";
|
||||||
import { convertSwfToNitro, extractIconFromSwf } from "./index";
|
import { convertSwfToNitro, extractIconFromSwf } from "./index";
|
||||||
|
import type { NitroTextureFormat } from "./nitro-builder";
|
||||||
|
|
||||||
export interface ConvertRequest {
|
export interface ConvertRequest {
|
||||||
id: number;
|
id: number;
|
||||||
type: "convert";
|
type: "convert";
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
classname: string;
|
classname: string;
|
||||||
|
/** Omitted means the default (WebP Lossless). */
|
||||||
|
textureFormat?: NitroTextureFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IconRequest {
|
export interface IconRequest {
|
||||||
@@ -28,28 +31,41 @@ export type WorkerResponse =
|
|||||||
|
|
||||||
if (parentPort) {
|
if (parentPort) {
|
||||||
parentPort.on("message", (msg: WorkerRequest) => {
|
parentPort.on("message", (msg: WorkerRequest) => {
|
||||||
try {
|
if (msg.type === "icon") {
|
||||||
if (msg.type === "convert") {
|
try {
|
||||||
const result = convertSwfToNitro(msg.buffer, msg.classname);
|
|
||||||
parentPort?.postMessage({
|
|
||||||
id: msg.id,
|
|
||||||
ok: true,
|
|
||||||
result,
|
|
||||||
} satisfies WorkerResponse);
|
|
||||||
} else {
|
|
||||||
const icon = extractIconFromSwf(msg.buffer, msg.classname);
|
const icon = extractIconFromSwf(msg.buffer, msg.classname);
|
||||||
parentPort?.postMessage({
|
parentPort?.postMessage({
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
ok: true,
|
ok: true,
|
||||||
icon: icon ?? null,
|
icon: icon ?? null,
|
||||||
} satisfies WorkerResponse);
|
} satisfies WorkerResponse);
|
||||||
|
} catch (err) {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
id: msg.id,
|
||||||
|
ok: false,
|
||||||
|
error: (err as Error).message,
|
||||||
|
} satisfies WorkerResponse);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
return;
|
||||||
parentPort?.postMessage({
|
|
||||||
id: msg.id,
|
|
||||||
ok: false,
|
|
||||||
error: (err as Error).message,
|
|
||||||
} satisfies WorkerResponse);
|
|
||||||
}
|
}
|
||||||
|
// The conversion encodes a spritesheet, which is async (sharp), so it
|
||||||
|
// cannot share the synchronous try/catch above.
|
||||||
|
void convertSwfToNitro(msg.buffer, msg.classname, {
|
||||||
|
textureFormat: msg.textureFormat,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
id: msg.id,
|
||||||
|
ok: true,
|
||||||
|
result,
|
||||||
|
} satisfies WorkerResponse);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
id: msg.id,
|
||||||
|
ok: false,
|
||||||
|
error: err.message,
|
||||||
|
} satisfies WorkerResponse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
buildSpritesheet,
|
buildSpritesheet,
|
||||||
createNitroBundle,
|
createNitroBundle,
|
||||||
encodePng,
|
encodePng,
|
||||||
|
type NitroTextureFormat,
|
||||||
} from "./nitro-builder";
|
} from "./nitro-builder";
|
||||||
import {
|
import {
|
||||||
decompressSwf,
|
decompressSwf,
|
||||||
@@ -56,15 +57,25 @@ export {
|
|||||||
extractIconFromSwfAsync,
|
extractIconFromSwfAsync,
|
||||||
} from "./conversion-pool";
|
} from "./conversion-pool";
|
||||||
// Re-export public API from submodules
|
// Re-export public API from submodules
|
||||||
export { createNitroBundle, parseNitroBundle } from "./nitro-builder";
|
export {
|
||||||
|
buildSpritesheet,
|
||||||
|
createNitroBundle,
|
||||||
|
detectNitroTextureFormat,
|
||||||
|
encodePng,
|
||||||
|
encodeWebpLossless,
|
||||||
|
NITRO_TEXTURE_LABEL,
|
||||||
|
type NitroTextureFormat,
|
||||||
|
parseNitroBundle,
|
||||||
|
} from "./nitro-builder";
|
||||||
export { computeDefaultDir, extractPartColors } from "./xml-processor";
|
export { computeDefaultDir, extractPartColors } from "./xml-processor";
|
||||||
|
|
||||||
// ── Main Conversion ────────────────────────────────────────────────
|
// ── Main Conversion ────────────────────────────────────────────────
|
||||||
|
|
||||||
export function convertSwfToNitro(
|
export async function convertSwfToNitro(
|
||||||
swfBuffer: Buffer,
|
swfBuffer: Buffer,
|
||||||
classname: string,
|
classname: string,
|
||||||
): ConversionResult {
|
options?: { textureFormat?: NitroTextureFormat },
|
||||||
|
): Promise<ConversionResult> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
// 1. Decompress SWF
|
// 1. Decompress SWF
|
||||||
@@ -241,11 +252,14 @@ export function convertSwfToNitro(
|
|||||||
|
|
||||||
// 7. Build spritesheet using bin-packing
|
// 7. Build spritesheet using bin-packing
|
||||||
const {
|
const {
|
||||||
png,
|
texture,
|
||||||
|
format: sheetFormat,
|
||||||
frames,
|
frames,
|
||||||
width: sheetWidth,
|
width: sheetWidth,
|
||||||
height: sheetHeight,
|
height: sheetHeight,
|
||||||
} = buildSpritesheet(namedImages);
|
} = await buildSpritesheet(namedImages, {
|
||||||
|
format: options?.textureFormat ?? "webp",
|
||||||
|
});
|
||||||
|
|
||||||
// 8. Process XML into Nitro JSON structures
|
// 8. Process XML into Nitro JSON structures
|
||||||
const { palettes } = processAssetsXml(assetsXml);
|
const { palettes } = processAssetsXml(assetsXml);
|
||||||
@@ -392,7 +406,9 @@ export function convertSwfToNitro(
|
|||||||
logicType,
|
logicType,
|
||||||
spritesheet: {
|
spritesheet: {
|
||||||
meta: {
|
meta: {
|
||||||
image: `${classname}.png`,
|
// Must match the texture member `createNitroBundle` writes, or
|
||||||
|
// the client resolves a file that is not in the bundle.
|
||||||
|
image: `${classname}.${sheetFormat}`,
|
||||||
format: "RGBA8888",
|
format: "RGBA8888",
|
||||||
size: { w: sheetWidth, h: sheetHeight },
|
size: { w: sheetWidth, h: sheetHeight },
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@@ -410,7 +426,7 @@ export function convertSwfToNitro(
|
|||||||
nitroJson.logic = logicObj;
|
nitroJson.logic = logicObj;
|
||||||
|
|
||||||
// 10. Create .nitro bundle
|
// 10. Create .nitro bundle
|
||||||
const bundle = createNitroBundle(nitroJson, png, classname);
|
const bundle = createNitroBundle(nitroJson, texture, classname);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bundle,
|
bundle,
|
||||||
|
|||||||
@@ -1,6 +1,74 @@
|
|||||||
import { deflateSync, gunzipSync, inflateSync } from "node:zlib";
|
import { deflateSync, gunzipSync, inflateSync } from "node:zlib";
|
||||||
|
import sharp from "sharp";
|
||||||
import type { SpriteImage } from "./image-decoder";
|
import type { SpriteImage } from "./image-decoder";
|
||||||
|
|
||||||
|
// ── Texture Formats ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image formats a `.nitro` bundle may carry as its spritesheet texture.
|
||||||
|
*
|
||||||
|
* `webp` is the default for newly converted bundles: VP8L is a true lossless
|
||||||
|
* codec, so pixels round-trip bit-for-bit (bar RGB under fully transparent
|
||||||
|
* pixels, which no renderer can observe) while typically weighing 25-40% of the
|
||||||
|
* equivalent PNG. `png` stays supported so existing bundles, official Habbo
|
||||||
|
* gamedata and the PNG-icon sidecar keep parsing unchanged.
|
||||||
|
*/
|
||||||
|
export type NitroTextureFormat = "png" | "webp";
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify a Nitro texture by its magic bytes rather than by its filename, so
|
||||||
|
* the container writer can never label an image with the wrong extension.
|
||||||
|
* Returns `null` for anything that is not a PNG or a RIFF/WEBP image.
|
||||||
|
*/
|
||||||
|
export function detectNitroTextureFormat(
|
||||||
|
bytes: Buffer,
|
||||||
|
): NitroTextureFormat | null {
|
||||||
|
// PNG is identified by its 8-byte signature; WebP additionally needs room
|
||||||
|
// for the "RIFF" size and the "WEBP" fourcc at offset 8.
|
||||||
|
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(PNG_SIGNATURE))
|
||||||
|
return "png";
|
||||||
|
if (
|
||||||
|
bytes.length >= 12 &&
|
||||||
|
bytes.toString("ascii", 0, 4) === "RIFF" &&
|
||||||
|
bytes.toString("ascii", 8, 12) === "WEBP"
|
||||||
|
)
|
||||||
|
return "webp";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable, used by validation error messages. */
|
||||||
|
export const NITRO_TEXTURE_LABEL: Record<NitroTextureFormat, string> = {
|
||||||
|
png: "PNG",
|
||||||
|
webp: "WebP",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A RIFF container can also hold a lossy VP8 image, so "this is a WebP" is
|
||||||
|
* not the same claim as "this is WebP Lossless". The verdict comes from the
|
||||||
|
* image chunk: `VP8L` is lossless, `VP8 ` is lossy, and `VP8X` is only an
|
||||||
|
* extended header that defers the answer to a later chunk, so the container
|
||||||
|
* is walked rather than guessed at.
|
||||||
|
*/
|
||||||
|
export function isLosslessWebp(bytes: Buffer): boolean {
|
||||||
|
if (detectNitroTextureFormat(bytes) !== "webp") return false;
|
||||||
|
// RIFF size covers everything after the first 8 bytes; clamp it so a
|
||||||
|
// truncated file cannot make us read past the buffer.
|
||||||
|
const declared = bytes.readUInt32LE(4) + 8;
|
||||||
|
const end = Math.min(declared, bytes.length);
|
||||||
|
let sawLossy = false;
|
||||||
|
for (let at = 12; at + 8 <= end; ) {
|
||||||
|
const fourcc = bytes.toString("ascii", at, at + 4);
|
||||||
|
// Chunk payloads are padded to an even length.
|
||||||
|
const size = bytes.readUInt32LE(at + 4);
|
||||||
|
if (fourcc === "VP8L") return !sawLossy;
|
||||||
|
if (fourcc === "VP8 ") sawLossy = true;
|
||||||
|
at += 8 + size + (size % 2);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Minimal PNG Encoder ────────────────────────────────────────────
|
// ── Minimal PNG Encoder ────────────────────────────────────────────
|
||||||
|
|
||||||
const CRC_TABLE = (() => {
|
const CRC_TABLE = (() => {
|
||||||
@@ -68,6 +136,39 @@ export function encodePng(width: number, height: number, rgba: Buffer): Buffer {
|
|||||||
return Buffer.concat(chunks);
|
return Buffer.concat(chunks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WebP Lossless Encoder ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode RGBA pixels as a WebP image using the VP8L lossless codec.
|
||||||
|
*
|
||||||
|
* `lossless: true` is what keeps furniture artwork pixel-exact; the remaining
|
||||||
|
* options are pinned so no chroma-subsampling or near-lossless approximation
|
||||||
|
* can creep in. `effort: 6` is libvips' slowest-but-best setting, which is
|
||||||
|
* worth it for a one-off import and keeps spritesheets compact.
|
||||||
|
*
|
||||||
|
* `exact: true` (libvips' "preserve the colour data in transparent pixels")
|
||||||
|
* mirrors ImageSharp's `TransparentColorMode.Preserve`, so a bundle converts
|
||||||
|
* without a single altered texel — not even the RGB hidden under alpha 0.
|
||||||
|
* It costs bytes whenever transparent areas carry non-uniform colour, so the
|
||||||
|
* studio compression report states the size either way.
|
||||||
|
*/
|
||||||
|
export async function encodeWebpLossless(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
rgba: Buffer,
|
||||||
|
options?: { exact?: boolean },
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return sharp(rgba, { raw: { width, height, channels: 4 } })
|
||||||
|
.webp({
|
||||||
|
lossless: true,
|
||||||
|
nearLossless: false,
|
||||||
|
smartSubsample: false,
|
||||||
|
effort: 6,
|
||||||
|
exact: options?.exact ?? true,
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Bin-Packing Spritesheet Builder ────────────────────────────────
|
// ── Bin-Packing Spritesheet Builder ────────────────────────────────
|
||||||
|
|
||||||
interface PackedRect {
|
interface PackedRect {
|
||||||
@@ -155,15 +256,37 @@ interface FrameInfo {
|
|||||||
h: number;
|
h: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSpritesheet(images: SpriteImage[]): {
|
export interface Spritesheet {
|
||||||
png: Buffer;
|
/** Encoded texture bytes, in `format`. */
|
||||||
|
texture: Buffer;
|
||||||
|
format: NitroTextureFormat;
|
||||||
frames: Map<string, FrameInfo>;
|
frames: Map<string, FrameInfo>;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
export async function buildSpritesheet(
|
||||||
|
images: SpriteImage[],
|
||||||
|
options?: { format?: NitroTextureFormat },
|
||||||
|
): Promise<Spritesheet> {
|
||||||
|
const format = options?.format ?? "webp";
|
||||||
|
const encode = async (
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
rgba: Buffer,
|
||||||
|
): Promise<Buffer> =>
|
||||||
|
format === "webp"
|
||||||
|
? await encodeWebpLossless(width, height, rgba)
|
||||||
|
: encodePng(width, height, rgba);
|
||||||
|
|
||||||
if (images.length === 0) {
|
if (images.length === 0) {
|
||||||
const emptyPng = encodePng(1, 1, Buffer.alloc(4));
|
return {
|
||||||
return { png: emptyPng, frames: new Map(), width: 1, height: 1 };
|
texture: await encode(1, 1, Buffer.alloc(4)),
|
||||||
|
format,
|
||||||
|
frames: new Map(),
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rects, width: sheetWidth, height: sheetHeight } = binPack(images);
|
const { rects, width: sheetWidth, height: sheetHeight } = binPack(images);
|
||||||
@@ -187,28 +310,59 @@ export function buildSpritesheet(images: SpriteImage[]): {
|
|||||||
frames.set(rect.name, { x: rect.x, y: rect.y, w: rect.w, h: rect.h });
|
frames.set(rect.name, { x: rect.x, y: rect.y, w: rect.w, h: rect.h });
|
||||||
}
|
}
|
||||||
|
|
||||||
const png = encodePng(sheetWidth, sheetHeight, combined);
|
const texture = await encode(sheetWidth, sheetHeight, combined);
|
||||||
return { png, frames, width: sheetWidth, height: sheetHeight };
|
return { texture, format, frames, width: sheetWidth, height: sheetHeight };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── .nitro Bundle Creator ──────────────────────────────────────────
|
// ── .nitro Bundle Creator ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client resolves the spritesheet through `spritesheet.meta.image`, so
|
||||||
|
* that pointer has to name the member we actually write. Editing metadata in
|
||||||
|
* the studio can leave a stale `chair.png` behind, and a bundle whose JSON
|
||||||
|
* disagrees with its own member list silently renders nothing — so the
|
||||||
|
* pointer is corrected here, at the single point where bytes are written,
|
||||||
|
* rather than trusting every call site to remember. The input is not mutated.
|
||||||
|
*/
|
||||||
|
function withTexturePointer(json: object, textureFileName: string): object {
|
||||||
|
const record = json as Record<string, unknown>;
|
||||||
|
const spritesheet = record.spritesheet as Record<string, unknown> | undefined;
|
||||||
|
const meta = spritesheet?.meta as Record<string, unknown> | undefined;
|
||||||
|
if (!meta || meta.image === textureFileName) return json;
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
spritesheet: { ...spritesheet, meta: { ...meta, image: textureFileName } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createNitroBundle(
|
export function createNitroBundle(
|
||||||
jsonData: object,
|
jsonData: object,
|
||||||
pngData: Buffer,
|
textureData: Buffer,
|
||||||
name: string,
|
name: string,
|
||||||
): Buffer {
|
): Buffer {
|
||||||
const jsonStr = JSON.stringify(jsonData);
|
// Label the member by what the bytes actually are, never by a caller's
|
||||||
const jsonCompressed = deflateSync(Buffer.from(jsonStr, "utf-8"));
|
// assumption, so `parseNitroBundle` and the client both resolve the right
|
||||||
const pngCompressed = deflateSync(pngData);
|
// extension.
|
||||||
|
const detected = detectNitroTextureFormat(textureData);
|
||||||
|
if (!detected)
|
||||||
|
throw Error(
|
||||||
|
"Nitro texture must be a PNG or WebP image; the client renderer supports no other format",
|
||||||
|
);
|
||||||
|
|
||||||
const jsonFileName = `${name}.json`;
|
const jsonFileName = `${name}.json`;
|
||||||
const pngFileName = `${name}.png`;
|
const textureFileName = `${name}.${detected}`;
|
||||||
|
|
||||||
|
const jsonStr = JSON.stringify(withTexturePointer(jsonData, textureFileName));
|
||||||
|
const jsonCompressed = deflateSync(Buffer.from(jsonStr, "utf-8"));
|
||||||
|
// The texture arrives already compressed by its own codec (WebP/VP8L or
|
||||||
|
// PNG/DEFLATE), so this layer is mostly a no-op pass-through; it stays
|
||||||
|
// because the container format mandates it and readers inflate it.
|
||||||
|
const textureCompressed = deflateSync(textureData);
|
||||||
|
|
||||||
const totalSize =
|
const totalSize =
|
||||||
2 +
|
2 +
|
||||||
(2 + jsonFileName.length + 4 + jsonCompressed.length) +
|
(2 + jsonFileName.length + 4 + jsonCompressed.length) +
|
||||||
(2 + pngFileName.length + 4 + pngCompressed.length);
|
(2 + textureFileName.length + 4 + textureCompressed.length);
|
||||||
|
|
||||||
const bundle = Buffer.alloc(totalSize);
|
const bundle = Buffer.alloc(totalSize);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
@@ -225,28 +379,52 @@ export function createNitroBundle(
|
|||||||
jsonCompressed.copy(bundle, offset);
|
jsonCompressed.copy(bundle, offset);
|
||||||
offset += jsonCompressed.length;
|
offset += jsonCompressed.length;
|
||||||
|
|
||||||
bundle.writeInt16BE(pngFileName.length, offset);
|
bundle.writeInt16BE(textureFileName.length, offset);
|
||||||
offset += 2;
|
offset += 2;
|
||||||
bundle.write(pngFileName, offset, pngFileName.length, "utf-8");
|
bundle.write(textureFileName, offset, textureFileName.length, "utf-8");
|
||||||
offset += pngFileName.length;
|
offset += textureFileName.length;
|
||||||
bundle.writeInt32BE(pngCompressed.length, offset);
|
bundle.writeInt32BE(textureCompressed.length, offset);
|
||||||
offset += 4;
|
offset += 4;
|
||||||
pngCompressed.copy(bundle, offset);
|
textureCompressed.copy(bundle, offset);
|
||||||
|
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── .nitro Bundle Parser ──────────────────────────────────────────
|
// ── .nitro Bundle Parser ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ParsedNitroBundle {
|
||||||
|
json: Record<string, unknown>;
|
||||||
|
jsonFileName: string;
|
||||||
|
/** Decoded spritesheet bytes. WebP or PNG — sniff the format, don't assume. */
|
||||||
|
texture: Buffer;
|
||||||
|
textureFileName: string;
|
||||||
|
/** Format of `texture`, taken from its filename. */
|
||||||
|
textureFormat: NitroTextureFormat;
|
||||||
|
/**
|
||||||
|
* @deprecated Historical name for `texture`. Kept so existing callers and
|
||||||
|
* the emitted client payload stay compatible; the bytes are format-agnostic.
|
||||||
|
*/
|
||||||
|
png: Buffer;
|
||||||
|
/** @deprecated Historical name for `textureFileName`. */
|
||||||
|
pngFileName: string;
|
||||||
|
/** Deflated byte length of each member, as stored in the container. */
|
||||||
|
compressedSizes: { json: number; texture: number };
|
||||||
|
/** Size of the container itself, in bytes. */
|
||||||
|
bundleBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `.nitro` bundle.
|
||||||
|
*
|
||||||
|
* The container stores a filename per member, so the texture format is read
|
||||||
|
* from that name and cross-checked against the bytes. Both PNG and WebP
|
||||||
|
* textures parse; anything else is rejected rather than handed downstream as
|
||||||
|
* an undecodable image.
|
||||||
|
*/
|
||||||
export function parseNitroBundle(
|
export function parseNitroBundle(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
options?: { maxOutputLength?: number },
|
options?: { maxOutputLength?: number },
|
||||||
): {
|
): ParsedNitroBundle {
|
||||||
json: Record<string, unknown>;
|
|
||||||
jsonFileName: string;
|
|
||||||
png: Buffer;
|
|
||||||
pngFileName: string;
|
|
||||||
} {
|
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
const fileCount = buffer.readInt16BE(offset);
|
const fileCount = buffer.readInt16BE(offset);
|
||||||
@@ -273,18 +451,41 @@ export function parseNitroBundle(
|
|||||||
const jsonStr = inflateJsonOrGzip(jsonCompressed, options).toString("utf-8");
|
const jsonStr = inflateJsonOrGzip(jsonCompressed, options).toString("utf-8");
|
||||||
const json = JSON.parse(jsonStr);
|
const json = JSON.parse(jsonStr);
|
||||||
|
|
||||||
// File 2: PNG
|
// File 2: texture (PNG or WebP)
|
||||||
const pngFileNameLen = buffer.readInt16BE(offset);
|
const textureFileNameLen = buffer.readInt16BE(offset);
|
||||||
offset += 2;
|
offset += 2;
|
||||||
const pngFileName = buffer.toString("utf-8", offset, offset + pngFileNameLen);
|
const textureFileName = buffer.toString(
|
||||||
offset += pngFileNameLen;
|
"utf-8",
|
||||||
const pngCompressedLen = buffer.readInt32BE(offset);
|
offset,
|
||||||
|
offset + textureFileNameLen,
|
||||||
|
);
|
||||||
|
offset += textureFileNameLen;
|
||||||
|
const textureCompressedLen = buffer.readInt32BE(offset);
|
||||||
offset += 4;
|
offset += 4;
|
||||||
const pngCompressed = buffer.subarray(offset, offset + pngCompressedLen);
|
const textureCompressed = buffer.subarray(
|
||||||
offset += pngCompressedLen;
|
offset,
|
||||||
const png = inflateJsonOrGzip(pngCompressed, options);
|
offset + textureCompressedLen,
|
||||||
|
);
|
||||||
|
offset += textureCompressedLen;
|
||||||
|
const texture = inflateJsonOrGzip(textureCompressed, options);
|
||||||
|
|
||||||
return { json, jsonFileName, png, pngFileName };
|
const textureFormat = detectNitroTextureFormat(texture);
|
||||||
|
if (!textureFormat)
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported .nitro texture "${textureFileName}": expected a PNG or WebP image`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
json,
|
||||||
|
jsonFileName,
|
||||||
|
texture,
|
||||||
|
textureFileName,
|
||||||
|
textureFormat,
|
||||||
|
png: texture,
|
||||||
|
pngFileName: textureFileName,
|
||||||
|
compressedSizes: { json: jsonCompressedLen, texture: textureCompressedLen },
|
||||||
|
bundleBytes: buffer.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import { createNitroBundle, parseNitroBundle } from "./nitro-builder";
|
|||||||
it("honors an explicit decompression limit for imported bundles", () => {
|
it("honors an explicit decompression limit for imported bundles", () => {
|
||||||
const bundle = createNitroBundle(
|
const bundle = createNitroBundle(
|
||||||
{ name: "chair", value: "a".repeat(4096) },
|
{ name: "chair", value: "a".repeat(4096) },
|
||||||
Buffer.from("png"),
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||||
"chair",
|
"chair",
|
||||||
);
|
);
|
||||||
expect(() => parseNitroBundle(bundle, { maxOutputLength: 1024 })).toThrow();
|
expect(() => parseNitroBundle(bundle, { maxOutputLength: 1024 })).toThrow();
|
||||||
expect(parseNitroBundle(bundle).json.name).toBe("chair");
|
expect(parseNitroBundle(bundle).json.name).toBe("chair");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses to write a bundle whose texture is neither PNG nor WebP", () => {
|
||||||
|
expect(() =>
|
||||||
|
createNitroBundle({ name: "chair" }, Buffer.from("not an image"), "chair"),
|
||||||
|
).toThrow(/PNG or WebP/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { SpriteImage } from "./image-decoder";
|
||||||
|
import {
|
||||||
|
buildSpritesheet,
|
||||||
|
createNitroBundle,
|
||||||
|
detectNitroTextureFormat,
|
||||||
|
encodePng,
|
||||||
|
encodeWebpLossless,
|
||||||
|
isLosslessWebp,
|
||||||
|
parseNitroBundle,
|
||||||
|
} from "./nitro-builder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprites shaped like real SWF DefineBitsLossless2 output: an opaque border
|
||||||
|
* around a body whose interior is fully transparent yet still carries
|
||||||
|
* non-zero palette RGB. That combination is what separates a genuinely
|
||||||
|
* lossless encoder from one that quietly drops hidden colour data.
|
||||||
|
*/
|
||||||
|
function swfStyleSprite(index: number): SpriteImage {
|
||||||
|
const width = 17 + (index % 5) * 4,
|
||||||
|
height = 13 + (index % 3) * 6,
|
||||||
|
pixels = Buffer.alloc(width * height * 4);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const o = (y * width + x) * 4;
|
||||||
|
const border = x === 0 || y === 0 || x === width - 1 || y === height - 1;
|
||||||
|
if (border) {
|
||||||
|
pixels[o] = 20 + index;
|
||||||
|
pixels[o + 1] = 140;
|
||||||
|
pixels[o + 2] = 200;
|
||||||
|
pixels[o + 3] = 255;
|
||||||
|
} else {
|
||||||
|
// Transparent, but carrying leftover palette colour.
|
||||||
|
pixels[o] = 9 + index * 3;
|
||||||
|
pixels[o + 1] = 77;
|
||||||
|
pixels[o + 2] = 5;
|
||||||
|
pixels[o + 3] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: index, name: `chair_${index}`, width, height, pixels };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sprites = () => [0, 1, 2, 3, 4].map(swfStyleSprite);
|
||||||
|
|
||||||
|
describe("nitro texture format detection", () => {
|
||||||
|
it("identifies PNG and WebP by magic bytes, not by filename", () => {
|
||||||
|
expect(
|
||||||
|
detectNitroTextureFormat(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])),
|
||||||
|
).toBe("png");
|
||||||
|
expect(
|
||||||
|
detectNitroTextureFormat(
|
||||||
|
Buffer.from("RIFF\x00\x00\x00\x00WEBPVP8L", "latin1"),
|
||||||
|
),
|
||||||
|
).toBe("webp");
|
||||||
|
expect(detectNitroTextureFormat(Buffer.from("GIF89a"))).toBeNull();
|
||||||
|
expect(detectNitroTextureFormat(Buffer.alloc(0))).toBeNull();
|
||||||
|
// A RIFF container that is not WebP (e.g. WAVE) must not pass.
|
||||||
|
expect(
|
||||||
|
detectNitroTextureFormat(
|
||||||
|
Buffer.from("RIFF\x00\x00\x00\x00WAVEfmt ", "latin1"),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WebP lossless encoding", () => {
|
||||||
|
it("round-trips every texel bit-for-bit, including colour hidden under alpha 0", async () => {
|
||||||
|
const sprite = swfStyleSprite(0);
|
||||||
|
const encoded = await encodeWebpLossless(
|
||||||
|
sprite.width,
|
||||||
|
sprite.height,
|
||||||
|
sprite.pixels,
|
||||||
|
);
|
||||||
|
expect(encoded.toString("ascii", 12, 16)).toBe("VP8L");
|
||||||
|
const decoded = await sharp(encoded).raw().ensureAlpha().toBuffer();
|
||||||
|
expect(decoded).toEqual(sprite.pixels);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps partially transparent texels exact as well", async () => {
|
||||||
|
const pixels = Buffer.from([
|
||||||
|
10, 20, 30, 255, 40, 50, 60, 128, 70, 80, 90, 1, 100, 110, 120, 254,
|
||||||
|
]);
|
||||||
|
const encoded = await encodeWebpLossless(4, 1, pixels);
|
||||||
|
expect(await sharp(encoded).raw().ensureAlpha().toBuffer()).toEqual(pixels);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is far smaller than the equivalent PNG", async () => {
|
||||||
|
const sheet = await buildSpritesheet(sprites(), { format: "png" });
|
||||||
|
const webpSheet = await buildSpritesheet(sprites(), { format: "webp" });
|
||||||
|
expect(webpSheet.texture.length).toBeLessThan(sheet.texture.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WebP losslessness detection", () => {
|
||||||
|
it("confirms a VP8L image is lossless", async () => {
|
||||||
|
const sprite = swfStyleSprite(0);
|
||||||
|
const encoded = await encodeWebpLossless(
|
||||||
|
sprite.width,
|
||||||
|
sprite.height,
|
||||||
|
sprite.pixels,
|
||||||
|
);
|
||||||
|
expect(isLosslessWebp(encoded)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a lossy VP8 image even though the container says WebP", async () => {
|
||||||
|
const sprite = swfStyleSprite(0);
|
||||||
|
const lossy = await sharp(sprite.pixels, {
|
||||||
|
raw: { width: sprite.width, height: sprite.height, channels: 4 },
|
||||||
|
})
|
||||||
|
.webp({ quality: 80 })
|
||||||
|
.toBuffer();
|
||||||
|
// The container is still RIFF/WEBP, so only the chunk tells the truth.
|
||||||
|
expect(detectNitroTextureFormat(lossy)).toBe("webp");
|
||||||
|
expect(isLosslessWebp(lossy)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not fooled by a PNG or by a truncated header", () => {
|
||||||
|
expect(isLosslessWebp(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(isLosslessWebp(Buffer.from("RIFF"))).toBe(false);
|
||||||
|
expect(isLosslessWebp(Buffer.alloc(0))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the bytes of an empty sheet as lossless", async () => {
|
||||||
|
const sheet = await buildSpritesheet([], { format: "webp" });
|
||||||
|
expect(isLosslessWebp(sheet.texture)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spritesheet encoding", () => {
|
||||||
|
it("defaults to WebP and reports the format it produced", async () => {
|
||||||
|
const sheet = await buildSpritesheet(sprites());
|
||||||
|
expect(sheet.format).toBe("webp");
|
||||||
|
expect(detectNitroTextureFormat(sheet.texture)).toBe("webp");
|
||||||
|
expect(sheet.frames.size).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still encodes PNG on request for legacy bundles", async () => {
|
||||||
|
const sheet = await buildSpritesheet(sprites(), { format: "png" });
|
||||||
|
expect(sheet.format).toBe("png");
|
||||||
|
expect(detectNitroTextureFormat(sheet.texture)).toBe("png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a decodable sheet whose frames hold the original pixels", async () => {
|
||||||
|
const source = sprites();
|
||||||
|
const sheet = await buildSpritesheet(source, { format: "webp" });
|
||||||
|
const decoded = await sharp(sheet.texture).raw().ensureAlpha().toBuffer();
|
||||||
|
for (const sprite of source) {
|
||||||
|
const rect = sheet.frames.get(sprite.name);
|
||||||
|
if (!rect) throw new Error(`frame ${sprite.name} is missing`);
|
||||||
|
for (let y = 0; y < sprite.height; y++) {
|
||||||
|
const from = y * sprite.width * 4;
|
||||||
|
const to = ((rect.y + y) * sheet.width + rect.x) * 4;
|
||||||
|
expect(decoded.subarray(to, to + sprite.width * 4)).toEqual(
|
||||||
|
sprite.pixels.subarray(from, from + sprite.width * 4),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces a valid empty sheet rather than failing", async () => {
|
||||||
|
const sheet = await buildSpritesheet([], { format: "webp" });
|
||||||
|
expect(sheet.width).toBe(1);
|
||||||
|
expect(sheet.frames.size).toBe(0);
|
||||||
|
expect(detectNitroTextureFormat(sheet.texture)).toBe("webp");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bundle round-trip", () => {
|
||||||
|
const metadata = (name: string) => ({
|
||||||
|
name,
|
||||||
|
spritesheet: {
|
||||||
|
meta: { image: `${name}.webp`, format: "RGBA8888", size: { w: 1, h: 1 } },
|
||||||
|
frames: { [`${name}_0`]: { frame: { x: 0, y: 0, w: 1, h: 1 } } },
|
||||||
|
},
|
||||||
|
assets: { [`${name}_0`]: { x: 0, y: 0 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the member after the real encoding and parses it back", async () => {
|
||||||
|
const sheet = await buildSpritesheet(sprites(), { format: "webp" });
|
||||||
|
const bundle = createNitroBundle(metadata("chair"), sheet.texture, "chair");
|
||||||
|
const parsed = parseNitroBundle(bundle);
|
||||||
|
expect(parsed.textureFileName).toBe("chair.webp");
|
||||||
|
expect(parsed.textureFormat).toBe("webp");
|
||||||
|
expect(parsed.texture.toString("ascii", 12, 16)).toBe("VP8L");
|
||||||
|
// The historical aliases must keep pointing at the same bytes.
|
||||||
|
expect(parsed.png).toEqual(parsed.texture);
|
||||||
|
expect(parsed.pngFileName).toBe("chair.webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps labelling a PNG texture as .png", () => {
|
||||||
|
const png = encodePng(1, 1, Buffer.from([1, 2, 3, 255]));
|
||||||
|
const parsed = parseNitroBundle(
|
||||||
|
createNitroBundle(metadata("chair"), png, "chair"),
|
||||||
|
);
|
||||||
|
expect(parsed.textureFileName).toBe("chair.png");
|
||||||
|
expect(parsed.textureFormat).toBe("png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the member sizes the compression report relies on", async () => {
|
||||||
|
const sheet = await buildSpritesheet(sprites(), { format: "webp" });
|
||||||
|
const bundle = createNitroBundle(metadata("chair"), sheet.texture, "chair");
|
||||||
|
const parsed = parseNitroBundle(bundle);
|
||||||
|
expect(parsed.compressedSizes.texture).toBeGreaterThan(0);
|
||||||
|
expect(parsed.compressedSizes.json).toBeGreaterThan(0);
|
||||||
|
expect(parsed.bundleBytes).toBe(bundle.length);
|
||||||
|
// Overhead is the container framing, so members must fit inside it.
|
||||||
|
expect(
|
||||||
|
parsed.compressedSizes.json + parsed.compressedSizes.texture,
|
||||||
|
).toBeLessThan(parsed.bundleBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs a stale .png pointer instead of writing an inconsistent bundle", async () => {
|
||||||
|
const sheet = await buildSpritesheet(sprites(), { format: "webp" });
|
||||||
|
// A studio metadata edit can leave the old pointer behind while the
|
||||||
|
// texture itself stays WebP; the client would then look for a member
|
||||||
|
// that is not in the bundle.
|
||||||
|
const stale = metadata("chair");
|
||||||
|
stale.spritesheet.meta.image = "chair.png";
|
||||||
|
const parsed = parseNitroBundle(
|
||||||
|
createNitroBundle(stale, sheet.texture, "chair"),
|
||||||
|
);
|
||||||
|
expect(parsed.textureFileName).toBe("chair.webp");
|
||||||
|
expect(
|
||||||
|
(parsed.json.spritesheet as { meta: { image: string } }).meta.image,
|
||||||
|
).toBe("chair.webp");
|
||||||
|
// The caller's object must not be mutated in place.
|
||||||
|
expect(stale.spritesheet.meta.image).toBe("chair.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a matching pointer and the rest of the metadata alone", () => {
|
||||||
|
const png = encodePng(1, 1, Buffer.from([1, 2, 3, 255]));
|
||||||
|
const parsed = parseNitroBundle(
|
||||||
|
createNitroBundle(metadata("chair"), png, "chair"),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(parsed.json.spritesheet as { meta: { image: string } }).meta.image,
|
||||||
|
).toBe("chair.png");
|
||||||
|
expect(
|
||||||
|
(parsed.json.spritesheet as { frames: Record<string, unknown> }).frames,
|
||||||
|
).toEqual({ chair_0: { frame: { x: 0, y: 0, w: 1, h: 1 } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates metadata that has no spritesheet at all", () => {
|
||||||
|
const png = encodePng(1, 1, Buffer.from([1, 2, 3, 255]));
|
||||||
|
const parsed = parseNitroBundle(
|
||||||
|
createNitroBundle({ name: "bare" }, png, "bare"),
|
||||||
|
);
|
||||||
|
expect(parsed.textureFileName).toBe("bare.png");
|
||||||
|
expect(parsed.json.name).toBe("bare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a bundle whose texture is neither PNG nor WebP", () => {
|
||||||
|
const good = encodePng(1, 1, Buffer.from([1, 2, 3, 255]));
|
||||||
|
expect(() =>
|
||||||
|
createNitroBundle(metadata("chair"), Buffer.from("GIF89a"), "chair"),
|
||||||
|
).toThrow(/PNG or WebP/);
|
||||||
|
// Sanity: the valid case still builds.
|
||||||
|
expect(
|
||||||
|
createNitroBundle(metadata("chair"), good, "chair").length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user