feat(nitro): normalise hotel and uploaded bundles to WebP Lossless
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 31s
CI / tests-integration (push) Successful in 2m17s
CI / tests-unit (push) Failing after 2m29s
CI / tests-ui (push) Successful in 3m17s
CI / preflight (push) Skipped
CI / deploy (push) Skipped

Importing from a hotel wrote the downloaded .nitro to disk untouched, so
official PNG textures stayed PNG and only SWF imports ended up as WebP.
Every Studio import should produce the same format regardless of where the
bytes came from, so both clone and upload paths now run the bundle through
toWebpLosslessBundle.

The helper decodes the texture and re-encodes it with the same VP8L options
the SWF importer uses, so the artwork round-trips bit-for-bit, and lets
createNitroBundle relabel the member and repair the meta.image pointer. A
bundle that is already lossless WebP is returned untouched, making the
operation idempotent and safe to run on re-import. A colour variant that
shares a library keeps the member base name it arrived with.

A texture that cannot be decoded keeps its original format with a warning
instead of failing the import: the bundle is valid, and losing a furniture
item over a codec edge case is worse than a slightly larger texture.
This commit is contained in:
openhands committed 2026-09-27 15:55:17 +02:00
1 parent 306e209e29
commit b26e2e0de4
5 files changed
+174 -36

No files matched your search

+20 -1
View File
@@ -24,7 +24,10 @@ import {
} from "@/lib/services/import/core/curl-fetch";
import { downloadFile } from "@/lib/services/import/core/download";
import { siteSettings } from "@/lib/services/site-settings";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
import {
parseNitroBundle,
toWebpLosslessBundle,
} from "@/lib/services/swf/nitro-builder";
import { getRuntimePath } from "@/lib/utils/runtime-path";
export interface SourceFurni {
@@ -331,6 +334,22 @@ export async function cloneSingleFurni(params: {
logger.warn("[clone-import] invalid .nitro bundle for", { classname });
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
}
// Hotels ship PNG textures, so normalise every clone to WebP Lossless and
// make it match the SWF importer. A texture that will not re-encode is
// kept as it arrived rather than failing the whole import — the bundle is
// valid, and losing the furniture over a codec edge case is worse.
try {
const converted = await toWebpLosslessBundle(
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
);
await fs.writeFile(/*turbopackIgnore: true*/ nitroPath, converted);
} catch (err) {
warnings.push("texture kept in its original format");
logger.warn("[clone-import] WebP normalisation failed for", {
classname,
err,
});
}
let iconDl: { ok: boolean; size: number };
if (source.iconBaseUrl) {
iconDl = await downloadFile(
+41
View File
@@ -503,3 +503,44 @@ function inflateJsonOrGzip(
return gunzipSync(input, options);
}
}
// ── WebP Lossless Normalisation ────────────────────────────────────
/**
* Return the bundle with its texture stored as WebP Lossless (VP8L).
*
* Studio imports should all look the same regardless of where the bytes
* came from: a hotel serves PNG, a hand-made bundle may carry anything, and
* an SWF import already produces WebP. Re-encoding here means one code path
* decides the format instead of each importer deciding on its own.
*
* The texture is decoded and re-encoded with `encodeWebpLossless`, so it
* round-trips bit-for-bit, and the member keeps the base name it arrived
* with — a `chair*2` colour variant sharing the `chair` library must not be
* renamed. `meta.image` is corrected by `createNitroBundle`.
*
* A bundle that is already lossless WebP is returned untouched, which keeps
* the operation idempotent and avoids a pointless re-encode.
*
* Throws if the texture claims to be PNG or WebP but cannot actually be
* decoded; callers that would rather keep the original should catch.
*/
export async function toWebpLosslessBundle(bundle: Buffer): Promise<Buffer> {
const parsed = parseNitroBundle(bundle);
if (isLosslessWebp(parsed.texture)) return bundle;
const image = sharp(parsed.texture);
const { width = 0, height = 0 } = await image.metadata();
if (!width || !height)
throw new Error(
`Nitro texture "${parsed.textureFileName}" has no readable dimensions`,
);
const webp = await encodeWebpLossless(
width,
height,
await image.raw().ensureAlpha().toBuffer(),
);
const memberBase = parsed.textureFileName.replace(/\.(?:png|webp)$/i, "");
return createNitroBundle(parsed.json, webp, memberBase);
}
+95 -9
View File
@@ -9,6 +9,7 @@ import {
encodeWebpLossless,
isLosslessWebp,
parseNitroBundle,
toWebpLosslessBundle,
} from "./nitro-builder";
/**
@@ -44,6 +45,8 @@ function swfStyleSprite(index: number): SpriteImage {
const sprites = () => [0, 1, 2, 3, 4].map(swfStyleSprite);
const pngHeaderOnly = () => Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
describe("nitro texture format detection", () => {
it("identifies PNG and WebP by magic bytes, not by filename", () => {
expect(
@@ -130,6 +133,98 @@ describe("WebP losslessness detection", () => {
});
});
/** Minimal Nitro JSON: one frame, enough for the container assertions. */
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 } },
});
describe("normalising a bundle to WebP Lossless", () => {
const bundleWith = async (format: "png" | "webp", image?: string) => {
const sheet = await buildSpritesheet(sprites(), { format });
const meta = metadata("chair");
meta.spritesheet.meta.image = image ?? `chair.${format}`;
return createNitroBundle(meta, sheet.texture, "chair");
};
it("converts a hotel-style PNG bundle to lossless WebP", async () => {
const before = parseNitroBundle(await bundleWith("png"));
expect(before.textureFormat).toBe("png");
const pngTexture = before.texture;
const after = parseNitroBundle(
await toWebpLosslessBundle(await bundleWith("png")),
);
expect(after.textureFormat).toBe("webp");
expect(after.textureFileName).toBe("chair.webp");
expect(
(after.json.spritesheet as { meta: { image: string } }).meta.image,
).toBe("chair.webp");
expect(isLosslessWebp(after.texture)).toBe(true);
// The artwork must be untouched by the format change.
const decoded = await sharp(after.texture).raw().ensureAlpha().toBuffer();
const original = await sharp(pngTexture).raw().ensureAlpha().toBuffer();
expect(decoded).toEqual(original);
});
it("repairs a stale pointer while converting", async () => {
const after = parseNitroBundle(
await toWebpLosslessBundle(await bundleWith("png", "chair.png")),
);
expect(after.textureFileName).toBe("chair.webp");
expect(
(after.json.spritesheet as { meta: { image: string } }).meta.image,
).toBe("chair.webp");
});
it("leaves an already-lossless bundle completely untouched", async () => {
const webpBundle = await bundleWith("webp");
await expect(toWebpLosslessBundle(webpBundle)).resolves.toBe(webpBundle);
});
it("is idempotent, so re-running it changes nothing", async () => {
const once = await toWebpLosslessBundle(await bundleWith("png"));
const twice = await toWebpLosslessBundle(once);
expect(twice).toBe(once);
});
it("upgrades a lossy WebP to lossless without claiming to restore quality", async () => {
const sheet = await buildSpritesheet(sprites(), { format: "webp" });
const lossy = await sharp(sheet.texture).webp({ quality: 60 }).toBuffer();
const source = createNitroBundle(metadata("chair"), lossy, "chair");
expect(isLosslessWebp(parseNitroBundle(source).texture)).toBe(false);
const after = parseNitroBundle(await toWebpLosslessBundle(source));
expect(after.textureFormat).toBe("webp");
expect(isLosslessWebp(after.texture)).toBe(true);
});
it("keeps the member base name of a colour variant", async () => {
const sheet = await buildSpritesheet(sprites(), { format: "png" });
const source = createNitroBundle(metadata("chair"), sheet.texture, "chair");
const after = parseNitroBundle(await toWebpLosslessBundle(source));
// A `chair*2` upload shares the `chair` library, so the member name
// must stay based on `chair`.
expect(after.textureFileName).toBe("chair.webp");
expect(
(after.json.spritesheet as { meta: { image: string } }).meta.image,
).toBe("chair.webp");
});
it("rejects a bundle whose texture cannot be decoded", async () => {
const broken = createNitroBundle(
metadata("chair"),
pngHeaderOnly(),
"chair",
);
await expect(toWebpLosslessBundle(broken)).rejects.toThrow();
});
});
describe("spritesheet encoding", () => {
it("defaults to WebP and reports the format it produced", async () => {
const sheet = await buildSpritesheet(sprites());
@@ -170,15 +265,6 @@ describe("spritesheet encoding", () => {
});
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");
+7 -16
View File
@@ -84,19 +84,16 @@ vi.mock("@/lib/services/furni-import", () => ({
IMPORTED_PAGE_CAPTION_SAVE: "imported_furni",
}));
const nitroBuilderMock = vi.hoisted(() => ({
createNitroBundle: vi.fn((json: object) =>
Buffer.from(`rebuilt:${JSON.stringify(json)}`, "utf-8"),
),
}));
vi.mock("@/lib/services/swf/nitro-builder", () => ({
parseNitroBundle: vi.fn(() => ({
json: { xdim: 1, ydim: 1 },
texture: Buffer.from("texture-bytes"),
textureFileName: "chair.png",
})),
createNitroBundle: nitroBuilderMock.createNitroBundle,
isLosslessWebp: vi.fn(() => false),
toWebpLosslessBundle: vi.fn(() =>
Promise.resolve(Buffer.from("webp-normalised", "utf-8")),
),
}));
import {
@@ -146,19 +143,13 @@ describe("uploadSingleFurni live asset mirrors", () => {
});
expect(result.ok).toBe(true);
// The bundle is re-written through the builder so `meta.image` can never
// disagree with the texture member, while the member keeps the name it
// arrived with (colour variants share a library name).
expect(nitroBuilderMock.createNitroBundle).toHaveBeenCalledWith(
{ xdim: 1, ydim: 1 },
Buffer.from("texture-bytes"),
"chair",
);
// Every upload is normalised to WebP Lossless, whatever the source
// bundle carried, so what lands on disk is not the raw upload.
await expect(
fs.readFile(
getRuntimePath(assetTargets.mirrorDirs[0].nitroDir, "chair.nitro"),
),
).resolves.toEqual(Buffer.from('rebuilt:{"xdim":1,"ydim":1}', "utf-8"));
).resolves.toEqual(Buffer.from("webp-normalised", "utf-8"));
await expect(
fs.readFile(
getRuntimePath(assetTargets.mirrorDirs[0].iconDir, "chair_icon.png"),
+11 -10
View File
@@ -18,8 +18,9 @@ import {
} from "@/lib/services/furni-import";
import { nitroAnimationStatesCount } from "@/lib/services/furni-real-interaction";
import {
createNitroBundle,
isLosslessWebp,
parseNitroBundle,
toWebpLosslessBundle,
} from "@/lib/services/swf/nitro-builder";
import { getRuntimePath, getRuntimeResolve } from "@/lib/utils/runtime-path";
@@ -278,19 +279,19 @@ export async function uploadSingleFurni(params: {
}
let nitroMeta: Record<string, unknown> | null = null;
// The client resolves the spritesheet through `spritesheet.meta.image`, so
// that pointer must name the member we actually ship. A bundle from a
// third-party tool can arrive with a stale pointer (say `.png` next to a
// WebP member), which stores fine and then renders nothing. Re-writing
// through the builder repairs it without touching a single texture byte.
// The member keeps its own base name, so `chair*2` colour variants that
// share the `chair` library are not renamed.
// Studio imports always land as WebP Lossless, whatever the uploaded
// bundle carried, so a hotel-supplied PNG and an SWF import end up
// identical on disk. Re-encoding also repairs a `meta.image` pointer that
// disagrees with the texture member, which would otherwise store fine and
// then render nothing. The member keeps its own base name, so `chair*2`
// colour variants that share the `chair` library are not renamed.
let storedNitro = nitroBuffer;
try {
const parsed = parseNitroBundle(nitroBuffer);
nitroMeta = parsed.json;
const memberBase = parsed.textureFileName.replace(/\.(?:png|webp)$/i, "");
storedNitro = createNitroBundle(parsed.json, parsed.texture, memberBase);
storedNitro = await toWebpLosslessBundle(nitroBuffer);
if (!isLosslessWebp(parsed.texture))
warnings.push("texture converted to WebP Lossless");
} catch {
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
}