diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts index b064d7e9..69b3d6e9 100644 --- a/src/lib/services/clone-import.ts +++ b/src/lib/services/clone-import.ts @@ -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( diff --git a/src/lib/services/swf/nitro-builder.ts b/src/lib/services/swf/nitro-builder.ts index e9a7792c..84ffd4db 100644 --- a/src/lib/services/swf/nitro-builder.ts +++ b/src/lib/services/swf/nitro-builder.ts @@ -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 { + 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); +} diff --git a/src/lib/services/swf/nitro-webp.test.ts b/src/lib/services/swf/nitro-webp.test.ts index dbe0cd36..36cd40ca 100644 --- a/src/lib/services/swf/nitro-webp.test.ts +++ b/src/lib/services/swf/nitro-webp.test.ts @@ -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"); diff --git a/src/lib/services/upload-import.test.ts b/src/lib/services/upload-import.test.ts index bff8bbe9..1c418d96 100644 --- a/src/lib/services/upload-import.test.ts +++ b/src/lib/services/upload-import.test.ts @@ -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"), diff --git a/src/lib/services/upload-import.ts b/src/lib/services/upload-import.ts index 047f2af7..09b5de85 100644 --- a/src/lib/services/upload-import.ts +++ b/src/lib/services/upload-import.ts @@ -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 | 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" }; }