Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2978483ea9 | ||
|
|
43742e8d99 |
No files matched your search
File diff suppressed because it is too large.
Load diff
+46
-1
@@ -1,7 +1,47 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { PHASE_PRODUCTION_BUILD } from "next/constants";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This host runs with `vm.overcommit_memory=0` and no swap, so a process that
|
||||||
|
* asks for more memory than is free gets OOM-killed by the kernel immediately.
|
||||||
|
* The killer picks its victim across the WHOLE machine — an unbounded build can
|
||||||
|
* take down the database, nginx and the live release with it.
|
||||||
|
*
|
||||||
|
* `scripts/with-memory-cap.sh` runs a heavy command in its own cgroup with a
|
||||||
|
* hard `MemoryMax`, so only that build dies and the site keeps serving. Every
|
||||||
|
* script in package.json goes through it.
|
||||||
|
*
|
||||||
|
* The one hole that leaves is running the builder by hand: `npx next build`,
|
||||||
|
* `pnpm exec next build`, or an IDE/agent task invoking it directly skips the
|
||||||
|
* wrapper entirely and is unbounded. This guard closes that. `next build`
|
||||||
|
* loads the config, so refusing here stops the build before it allocates
|
||||||
|
* anything. See the header of scripts/with-memory-cap.sh.
|
||||||
|
*/
|
||||||
|
function assertMemoryCapped(phase: string): void {
|
||||||
|
if (phase !== PHASE_PRODUCTION_BUILD) return;
|
||||||
|
if (process.env.CMS_MEMORY_CAPPED === "1") return;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
"Refusing to run an uncapped production build.",
|
||||||
|
"",
|
||||||
|
"On this host an unbounded `next build` gets OOM-killed by the kernel,",
|
||||||
|
"and the killer may take the database, nginx or the live release with it.",
|
||||||
|
"",
|
||||||
|
"Use the capped build instead:",
|
||||||
|
" pnpm build",
|
||||||
|
"",
|
||||||
|
"It runs the builder through scripts/with-memory-cap.sh, which puts it in",
|
||||||
|
"its own cgroup with a MemoryMax, so a runaway build fails alone.",
|
||||||
|
"",
|
||||||
|
"Already inside an isolated environment (Docker, a CI runner) where the",
|
||||||
|
"container itself is the boundary? Set CMS_MEMORY_CAPPED=1 explicitly.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const getGitCommit = () => {
|
const getGitCommit = () => {
|
||||||
try {
|
try {
|
||||||
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
||||||
@@ -174,4 +214,9 @@ const nextConfig: NextConfig = {
|
|||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
// Exported as a function so the build phase is known before the config is
|
||||||
|
// used. next-intl only accepts a plain object, so it is applied here.
|
||||||
|
export default function config(phase: string) {
|
||||||
|
assertMemoryCapped(phase);
|
||||||
|
return withNextIntl(nextConfig);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -47,6 +47,14 @@
|
|||||||
# `next build --webpack` + `--max-old-space-size` en in hun eigen
|
# `next build --webpack` + `--max-old-space-size` en in hun eigen
|
||||||
# geïsoleerde container draaien, niet op de host.
|
# geïsoleerde container draaien, niet op de host.
|
||||||
#
|
#
|
||||||
|
# Markering:
|
||||||
|
# Elke backend zet `CMS_MEMORY_CAPPED=1` voordat het commando start.
|
||||||
|
# `next.config.ts` weigert een productie-build zonder die markering, zodat
|
||||||
|
# een handmatig `npx next build` (of een IDE/agent die de build zelf
|
||||||
|
# start) niet meer onbeperkt geheugen kan vragen en de hele host mee
|
||||||
|
# neemt. `CMS_MEMORY_CAP_BACKEND=none` telt mee: die omgevingen draaien
|
||||||
|
# al in een eigen, geïsoleerde container.
|
||||||
|
#
|
||||||
# Gebruik: bash scripts/with-memory-cap.sh 10g <command...>
|
# Gebruik: bash scripts/with-memory-cap.sh 10g <command...>
|
||||||
# Backend kiezen: CMS_MEMORY_CAP_BACKEND=systemd|ulimit|none|auto
|
# Backend kiezen: CMS_MEMORY_CAP_BACKEND=systemd|ulimit|none|auto
|
||||||
# ulimit-waarde kiezen: CMS_MEMORY_CAP_VIRTUAL=40g
|
# ulimit-waarde kiezen: CMS_MEMORY_CAP_VIRTUAL=40g
|
||||||
@@ -93,6 +101,10 @@ virtual_kb=$((virtual_bytes / 1024))
|
|||||||
backend="${CMS_MEMORY_CAP_BACKEND:-auto}"
|
backend="${CMS_MEMORY_CAP_BACKEND:-auto}"
|
||||||
systemd_cmd=()
|
systemd_cmd=()
|
||||||
|
|
||||||
|
# Vanaf hier is dit script de enige plek waar een zwaar commando nog mag
|
||||||
|
# starten. De markering maakt dat afdwingbaar in next.config.ts.
|
||||||
|
export CMS_MEMORY_CAPPED=1
|
||||||
|
|
||||||
# Echt proberen, niet alleen uitzoeken of het bestand bestaat: `systemd-run`
|
# Echt proberen, niet alleen uitzoeken of het bestand bestaat: `systemd-run`
|
||||||
# zonder rechten faalt met "Access denied", en dat moet dan een nette
|
# zonder rechten faalt met "Access denied", en dat moet dan een nette
|
||||||
# terugval naar ulimit worden in plaats van een kapotte build.
|
# terugval naar ulimit worden in plaats van een kapotte build.
|
||||||
|
|||||||
@@ -20,12 +20,18 @@ export const GET = withAdmin(
|
|||||||
|
|
||||||
const directories = catalogueAssetDirectories(await getGamedataRoot());
|
const directories = catalogueAssetDirectories(await getGamedataRoot());
|
||||||
const files = await readCatalogueAssetFiles(directories);
|
const files = await readCatalogueAssetFiles(directories);
|
||||||
let icons = files
|
// A handful of icons ship as both `.png` and `.gif`. The client always
|
||||||
|
// tries `.png` first and falls back to `.gif`, so an id that has both is
|
||||||
|
// one icon, not two — dedupe instead of offering it twice in the picker.
|
||||||
|
let icons = [
|
||||||
|
...new Set(
|
||||||
|
files
|
||||||
.map((f) => /^icon_(\d+)\.(?:png|gif)$/i.exec(f))
|
.map((f) => /^icon_(\d+)\.(?:png|gif)$/i.exec(f))
|
||||||
.filter((m): m is RegExpExecArray => Boolean(m))
|
.filter((m): m is RegExpExecArray => Boolean(m))
|
||||||
.map((m) => Number(m[1]))
|
.map((m) => Number(m[1]))
|
||||||
.filter((n) => Number.isFinite(n))
|
.filter((n) => Number.isFinite(n)),
|
||||||
.sort((a, b) => a - b);
|
),
|
||||||
|
].sort((a, b) => a - b);
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
icons = icons.filter((id) => String(id).includes(search));
|
icons = icons.filter((id) => String(id).includes(search));
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const GET = withAdmin(
|
|||||||
iconCache.set(cacheKey, null);
|
iconCache.set(cacheKey, null);
|
||||||
return apiError("Bundle not found", 404);
|
return apiError("Bundle not found", 404);
|
||||||
}
|
}
|
||||||
icon = extractFurniIconPng(Buffer.from(await res.arrayBuffer()));
|
icon = await extractFurniIconPng(Buffer.from(await res.arrayBuffer()));
|
||||||
iconCache.set(cacheKey, icon);
|
iconCache.set(cacheKey, icon);
|
||||||
pruneIconCache();
|
pruneIconCache();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const GET = withAdmin(
|
|||||||
{ permission: PERMS.ASSETS_IMPORT },
|
{ permission: PERMS.ASSETS_IMPORT },
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const lib = request.nextUrl.searchParams.get("lib") || "";
|
const lib = request.nextUrl.searchParams.get("lib") || "";
|
||||||
const png = getPetIconPng(lib);
|
const png = await getPetIconPng(lib);
|
||||||
if (!png) return new Response(null, { status: 404 });
|
if (!png) return new Response(null, { status: 404 });
|
||||||
return new Response(new Uint8Array(png), {
|
return new Response(new Uint8Array(png), {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -13,21 +13,22 @@ export function CatalogIcon({
|
|||||||
iconImage: number;
|
iconImage: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
}) {
|
}) {
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(0);
|
||||||
|
|
||||||
if (error || iconImage <= 0) {
|
// A few icons ship only as `.gif`; retry once before giving up.
|
||||||
|
if (error >= 2 || iconImage <= 0) {
|
||||||
return <Package className="h-4 w-4 text-muted-foreground shrink-0" />;
|
return <Package className="h-4 w-4 text-muted-foreground shrink-0" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={catalogueIconUrl(iconImage)}
|
src={catalogueIconUrl(iconImage, error === 0 ? "png" : "gif")}
|
||||||
alt=""
|
alt=""
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
className="shrink-0 object-contain"
|
className="shrink-0 object-contain"
|
||||||
style={{ imageRendering: "pixelated", minWidth: size, minHeight: size }}
|
style={{ imageRendering: "pixelated", minWidth: size, minHeight: size }}
|
||||||
onError={() => setError(true)}
|
onError={() => setError((e) => e + 1)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,13 +213,14 @@ function IconPreview({
|
|||||||
iconImage: number;
|
iconImage: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
}) {
|
}) {
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(0);
|
||||||
// Clear the previous load failure when the icon changes, otherwise a
|
// Clear the previous load failure when the icon changes, otherwise a
|
||||||
// placeholder sticks to the next image too.
|
// placeholder sticks to the next image too.
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: iconImage is a prop; the reset is meant to follow it
|
// biome-ignore lint/correctness/useExhaustiveDependencies: iconImage is a prop; the reset is meant to follow it
|
||||||
useEffect(() => setError(false), [iconImage]);
|
useEffect(() => setError(0), [iconImage]);
|
||||||
|
|
||||||
if (error || iconImage <= 0) {
|
// A few icons ship only as `.gif`; retry once before falling back.
|
||||||
|
if (error >= 2 || iconImage <= 0) {
|
||||||
return (
|
return (
|
||||||
<Package
|
<Package
|
||||||
className="shrink-0 text-muted-foreground"
|
className="shrink-0 text-muted-foreground"
|
||||||
@@ -230,13 +231,13 @@ function IconPreview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={catalogueIconUrl(iconImage)}
|
src={catalogueIconUrl(iconImage, error === 0 ? "png" : "gif")}
|
||||||
alt=""
|
alt=""
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
className="shrink-0 object-contain"
|
className="shrink-0 object-contain"
|
||||||
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
|
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
|
||||||
onError={() => setError(true)}
|
onError={() => setError((e) => e + 1)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,13 @@ describe("catalogue asset URLs", () => {
|
|||||||
expect(catalogueIconUrl(7)).toBe("/gamedata/c_images/catalogue/icon_7.png");
|
expect(catalogueIconUrl(7)).toBe("/gamedata/c_images/catalogue/icon_7.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A few catalogue icons exist only as `.gif`, so the picker retries with it.
|
||||||
|
it("can build the `.gif` variant of a category icon", () => {
|
||||||
|
expect(catalogueIconUrl(1542, "gif")).toBe(
|
||||||
|
"/gamedata/c_images/catalogue/icon_1542.gif",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("encodes catalogue image names and supports both image formats", () => {
|
it("encodes catalogue image names and supports both image formats", () => {
|
||||||
expect(catalogueAssetUrl("front page", "png")).toBe(
|
expect(catalogueAssetUrl("front page", "png")).toBe(
|
||||||
"/gamedata/c_images/catalogue/front%20page.png",
|
"/gamedata/c_images/catalogue/front%20page.png",
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
export const CATALOGUE_ASSET_BASE_PATH = "/gamedata/c_images/catalogue";
|
export const CATALOGUE_ASSET_BASE_PATH = "/gamedata/c_images/catalogue";
|
||||||
|
|
||||||
export function catalogueIconUrl(iconImage: number): string {
|
export type CatalogueImageExtension = "png" | "gif";
|
||||||
return `${CATALOGUE_ASSET_BASE_PATH}/icon_${iconImage}.png`;
|
|
||||||
|
/**
|
||||||
|
* A handful of catalogue icons only exist as `.gif`, so callers must be able to
|
||||||
|
* retry with `"gif"` after the `.png` attempt 404s. Defaults to `"png"`, which
|
||||||
|
* is what every icon except a few uses.
|
||||||
|
*/
|
||||||
|
export function catalogueIconUrl(
|
||||||
|
iconImage: number,
|
||||||
|
extension: CatalogueImageExtension = "png",
|
||||||
|
): string {
|
||||||
|
return `${CATALOGUE_ASSET_BASE_PATH}/icon_${iconImage}.${extension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function catalogueAssetUrl(
|
export function catalogueAssetUrl(
|
||||||
name: string,
|
name: string,
|
||||||
extension: "png" | "gif",
|
extension: CatalogueImageExtension,
|
||||||
): string {
|
): string {
|
||||||
return `${CATALOGUE_ASSET_BASE_PATH}/${encodeURIComponent(name)}.${extension}`;
|
return `${CATALOGUE_ASSET_BASE_PATH}/${encodeURIComponent(name)}.${extension}`;
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,48 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import nextConfig from "../../next.config";
|
import nextConfig from "../../next.config";
|
||||||
|
|
||||||
|
// The default export is a function so the build phase is known before the
|
||||||
|
// config is handed to next-intl. Any phase other than the production build
|
||||||
|
// resolves it; deploymentId itself is phase-independent.
|
||||||
|
const resolveConfig = () => nextConfig("phase-production-server");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.CMS_MEMORY_CAPPED;
|
||||||
|
});
|
||||||
|
|
||||||
describe("deployment version skew protection", () => {
|
describe("deployment version skew protection", () => {
|
||||||
it("uses an explicit deployment ID or the current Git commit", () => {
|
it("uses an explicit deployment ID or the current Git commit", () => {
|
||||||
const gitCommit = execFileSync("git", ["rev-parse", "HEAD"], {
|
const gitCommit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
expect(nextConfig.deploymentId).toBe(
|
expect(resolveConfig().deploymentId).toBe(
|
||||||
process.env.NEXT_DEPLOYMENT_ID?.trim() || gitCommit,
|
process.env.NEXT_DEPLOYMENT_ID?.trim() || gitCommit,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("production build memory cap", () => {
|
||||||
|
// An uncapped `next build` gets OOM-killed by the kernel on this host, and
|
||||||
|
// the killer may take the database and the live release with it. Refusing
|
||||||
|
// here stops the build before it allocates anything.
|
||||||
|
it("refuses a production build that is not running under the memory cap", () => {
|
||||||
|
expect(() => nextConfig("phase-production-build")).toThrow(
|
||||||
|
/Refusing to run an uncapped production build/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a production build when the wrapper marked it as capped", () => {
|
||||||
|
process.env.CMS_MEMORY_CAPPED = "1";
|
||||||
|
expect(() => nextConfig("phase-production-build")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only production builds are heavy enough to kill the machine; dev and
|
||||||
|
// start must keep working uncapped.
|
||||||
|
it("does not restrict other phases", () => {
|
||||||
|
expect(() => nextConfig("phase-development-server")).not.toThrow();
|
||||||
|
expect(() => nextConfig("phase-production-server")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
||||||
|
import { decodePng } from "@/lib/services/imager/png-decode";
|
||||||
|
import {
|
||||||
|
createNitroBundle,
|
||||||
|
encodePng,
|
||||||
|
isLosslessWebp,
|
||||||
|
parseNitroBundle,
|
||||||
|
toWebpLosslessBundle,
|
||||||
|
} from "@/lib/services/swf/nitro-builder";
|
||||||
|
|
||||||
|
const NAME = "chair_test";
|
||||||
|
const W = 16;
|
||||||
|
const H = 16;
|
||||||
|
|
||||||
|
function makeRgba(): Buffer {
|
||||||
|
const rgba = Buffer.alloc(W * H * 4);
|
||||||
|
for (let i = 0; i < W * H; i++) {
|
||||||
|
rgba[i * 4 + 0] = 200;
|
||||||
|
rgba[i * 4 + 1] = 40;
|
||||||
|
rgba[i * 4 + 2] = 90;
|
||||||
|
rgba[i * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
return rgba;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBundle(texture: Buffer): Buffer {
|
||||||
|
return createNitroBundle(
|
||||||
|
{
|
||||||
|
name: NAME,
|
||||||
|
assets: { [`${NAME}_icon_a`]: { x: 0, y: 0 } },
|
||||||
|
spritesheet: {
|
||||||
|
frames: {
|
||||||
|
[`${NAME}_${NAME}_icon_a`]: { frame: { x: 0, y: 0, w: W, h: H } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
texture,
|
||||||
|
NAME,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("extractFurniIconPng", () => {
|
||||||
|
// Every write path normalises bundles to WebP Lossless, so a PNG-only
|
||||||
|
// decoder made this silently return null for nearly every real bundle.
|
||||||
|
it("extracts the icon from a PNG-textured bundle", async () => {
|
||||||
|
const icon = await extractFurniIconPng(
|
||||||
|
buildBundle(encodePng(W, H, makeRgba())),
|
||||||
|
);
|
||||||
|
expect(icon).not.toBeNull();
|
||||||
|
expect(decodePng(icon as Buffer)).toMatchObject({ width: W, height: H });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts the icon from a WebP-lossless-textured bundle", async () => {
|
||||||
|
const webpBundle = await toWebpLosslessBundle(
|
||||||
|
buildBundle(encodePng(W, H, makeRgba())),
|
||||||
|
);
|
||||||
|
expect(isLosslessWebp(parseNitroBundle(webpBundle).texture)).toBe(true);
|
||||||
|
|
||||||
|
const icon = await extractFurniIconPng(webpBundle);
|
||||||
|
expect(icon).not.toBeNull();
|
||||||
|
expect(decodePng(icon as Buffer)).toMatchObject({ width: W, height: H });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a bundle with no icon asset", async () => {
|
||||||
|
const bundle = createNitroBundle(
|
||||||
|
{ name: NAME, assets: { other: { x: 0, y: 0 } } },
|
||||||
|
encodePng(W, H, makeRgba()),
|
||||||
|
NAME,
|
||||||
|
);
|
||||||
|
expect(await extractFurniIconPng(bundle)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for bytes that are not a bundle at all", async () => {
|
||||||
|
expect(await extractFurniIconPng(Buffer.from([1, 2, 3, 4]))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cropRgba, decodePng } from "@/lib/services/imager/png-decode";
|
import { decodeTextureRgba } from "@/lib/services/imager/decode-texture";
|
||||||
|
import { cropRgba } from "@/lib/services/imager/png-decode";
|
||||||
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||||
|
|
||||||
interface FurniAsset {
|
interface FurniAsset {
|
||||||
@@ -16,19 +17,48 @@ interface FurniNitroJson {
|
|||||||
spritesheet?: { frames?: Record<string, FurniFrame> };
|
spritesheet?: { frames?: Record<string, FurniFrame> };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate the spritesheet frame for an asset. Try the two exact spellings
|
||||||
|
* first (with and without a trailing `.png`), then one case-insensitive pass
|
||||||
|
* so a converter that lowercased the frame keys still resolves.
|
||||||
|
*/
|
||||||
|
function findFrame(
|
||||||
|
frames: Record<string, FurniFrame>,
|
||||||
|
name: string,
|
||||||
|
pixelAsset: string,
|
||||||
|
): FurniFrame | undefined {
|
||||||
|
const direct =
|
||||||
|
frames[`${name}_${pixelAsset}`] ?? frames[`${name}_${pixelAsset}.png`];
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
const wanted = new Set([
|
||||||
|
`${name}_${pixelAsset}`.toLowerCase(),
|
||||||
|
`${name}_${pixelAsset}.png`.toLowerCase(),
|
||||||
|
]);
|
||||||
|
for (const [key, frame] of Object.entries(frames)) {
|
||||||
|
if (wanted.has(key.toLowerCase())) return frame;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the furni catalog icon — the `{name}_icon_a` sprite — from a Nitro
|
* Extract the furni catalog icon — the `{name}_icon_a` sprite — from a Nitro
|
||||||
* bundle and return it as a standalone PNG, or null if the bundle has no icon
|
* bundle and return it as a standalone PNG, or null if the bundle has no icon
|
||||||
* asset / usable frame. Used for sources that embed furni icons inside the
|
* asset / usable frame. Used for sources that embed furni icons inside the
|
||||||
* `.nitro` instead of serving a separate `{classname}_icon.png`.
|
* `.nitro` instead of serving a separate `{classname}_icon.png`.
|
||||||
|
*
|
||||||
|
* Async because the texture is usually WebP Lossless, which needs a real codec
|
||||||
|
* to read back out.
|
||||||
*/
|
*/
|
||||||
export function extractFurniIconPng(nitro: Buffer): Buffer | null {
|
export async function extractFurniIconPng(
|
||||||
|
nitro: Buffer,
|
||||||
|
): Promise<Buffer | null> {
|
||||||
let json: FurniNitroJson;
|
let json: FurniNitroJson;
|
||||||
let png: Buffer;
|
let texture: Buffer;
|
||||||
try {
|
try {
|
||||||
const parsed = parseNitroBundle(nitro);
|
const parsed = parseNitroBundle(nitro);
|
||||||
json = parsed.json as FurniNitroJson;
|
json = parsed.json as FurniNitroJson;
|
||||||
png = parsed.png;
|
texture = parsed.texture;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -44,18 +74,20 @@ export function extractFurniIconPng(nitro: Buffer): Buffer | null {
|
|||||||
|
|
||||||
const asset = json.assets[iconKey];
|
const asset = json.assets[iconKey];
|
||||||
// An asset may alias another's pixels via `source`; the frame key is the
|
// An asset may alias another's pixels via `source`; the frame key is the
|
||||||
// bundle name prefixed onto the asset name. Different converters pack the
|
// bundle name prefixed onto the asset name. Converters disagree on the
|
||||||
// key with or without a trailing `.png`, so try both.
|
// packing: some keep the trailing `.png`, and some lowercase the whole key
|
||||||
|
// while leaving `name` mixed-case (`LTD_fashionistaf` -> key
|
||||||
|
// `ltd_fashionistaf_ltd_fashionistaf_icon_a`). Match exactly first, then
|
||||||
|
// fall back to a case-insensitive lookup so mixed-case classnames work.
|
||||||
const pixelAsset = asset.source ?? iconKey;
|
const pixelAsset = asset.source ?? iconKey;
|
||||||
const frames = json.spritesheet?.frames ?? {};
|
const frames = json.spritesheet?.frames ?? {};
|
||||||
const frame =
|
const frame = findFrame(frames, name, pixelAsset);
|
||||||
frames[`${name}_${pixelAsset}`] ?? frames[`${name}_${pixelAsset}.png`];
|
|
||||||
if (!frame || frame.rotated) return null;
|
if (!frame || frame.rotated) return null;
|
||||||
const { x, y, w, h } = frame.frame;
|
const { x, y, w, h } = frame.frame;
|
||||||
if (w <= 0 || h <= 0) return null;
|
if (w <= 0 || h <= 0) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sheet = decodePng(png);
|
const sheet = await decodeTextureRgba(texture);
|
||||||
const px = cropRgba(sheet.rgba, sheet.width, x, y, w, h);
|
const px = cropRgba(sheet.rgba, sheet.width, x, y, w, h);
|
||||||
return encodePng(w, h, px);
|
return encodePng(w, h, px);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ export async function cloneSingleFurni(params: {
|
|||||||
// Source serves no standalone icon (e.g. icons embedded in the .nitro) —
|
// Source serves no standalone icon (e.g. icons embedded in the .nitro) —
|
||||||
// extract the catalog icon from the bundle we just downloaded.
|
// extract the catalog icon from the bundle we just downloaded.
|
||||||
try {
|
try {
|
||||||
const icon = extractFurniIconPng(
|
const icon = await extractFurniIconPng(
|
||||||
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
|
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
|
||||||
);
|
);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
|
|||||||
@@ -926,7 +926,7 @@ export async function importSingleFurni(params: {
|
|||||||
|
|
||||||
if (!iconOk && nitroExists()) {
|
if (!iconOk && nitroExists()) {
|
||||||
try {
|
try {
|
||||||
const icon = extractFurniIconPng(
|
const icon = await extractFurniIconPng(
|
||||||
await fs.readFile(resolveNitro() ?? nitroPath),
|
await fs.readFile(resolveNitro() ?? nitroPath),
|
||||||
);
|
);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
import type { DecodedPng } from "@/lib/services/imager/png-decode";
|
||||||
|
import { decodePng } from "@/lib/services/imager/png-decode";
|
||||||
|
import { detectNitroTextureFormat } from "@/lib/services/swf/nitro-builder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a Nitro spritesheet texture to raw RGBA pixels.
|
||||||
|
*
|
||||||
|
* Every write path normalises bundles to WebP Lossless, so a PNG-only decoder
|
||||||
|
* silently returns null for almost every bundle on disk. PNG keeps the
|
||||||
|
* dependency-free decoder (it handles the sprite format natively and avoids a
|
||||||
|
* native round-trip); WebP — the common case — goes through sharp.
|
||||||
|
*
|
||||||
|
* Throws when the bytes are neither PNG nor WebP, or when sharp cannot decode.
|
||||||
|
*/
|
||||||
|
export async function decodeTextureRgba(bytes: Buffer): Promise<DecodedPng> {
|
||||||
|
const format = detectNitroTextureFormat(bytes);
|
||||||
|
if (format === "png") return decodePng(bytes);
|
||||||
|
if (format !== "webp") throw new Error("not a PNG or WebP texture");
|
||||||
|
|
||||||
|
const { data, info } = await sharp(bytes)
|
||||||
|
.ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
|
||||||
|
// sharp can report a colour space the spritesheet maths does not expect;
|
||||||
|
// normalise to the 4-bytes-per-pixel RGBA that cropRgba assumes.
|
||||||
|
if (info.channels !== 4) {
|
||||||
|
throw new Error(`unsupported WebP channel count ${info.channels}`);
|
||||||
|
}
|
||||||
|
if (info.width * info.height * 4 !== data.length) {
|
||||||
|
throw new Error("WebP texture has an inconsistent pixel buffer size");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width: info.width, height: info.height, rgba: data };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
|
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
|
||||||
import { cropRgba, decodePng } from "@/lib/services/imager/png-decode";
|
import { decodeTextureRgba } from "@/lib/services/imager/decode-texture";
|
||||||
|
import { cropRgba } from "@/lib/services/imager/png-decode";
|
||||||
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||||
|
|
||||||
@@ -50,8 +51,10 @@ interface LayerSpec {
|
|||||||
// Directions present on pets are a subset of 0..7; 2 reads best as a portrait.
|
// Directions present on pets are a subset of 0..7; 2 reads best as a portrait.
|
||||||
const DIR_PREFERENCE = [2, 0, 4, 3, 1, 7, 6, 5];
|
const DIR_PREFERENCE = [2, 0, 4, 3, 1, 7, 6, 5];
|
||||||
|
|
||||||
// Generated PNG icons are tiny; cache them in-process keyed by lib.
|
// Generated PNG icons are tiny; cache them in-process keyed by lib. The cache
|
||||||
const iconCache = new Map<string, Buffer | null>();
|
// holds the in-flight promise so concurrent requests for the same pet decode
|
||||||
|
// once, and it never holds a rejection.
|
||||||
|
const iconCache = new Map<string, Promise<Buffer | null>>();
|
||||||
|
|
||||||
function findNitro(lib: string): string | null {
|
function findNitro(lib: string): string | null {
|
||||||
for (const dir of PET_DIRS) {
|
for (const dir of PET_DIRS) {
|
||||||
@@ -357,27 +360,29 @@ function composite(
|
|||||||
/**
|
/**
|
||||||
* Build a composited PNG icon for a pet from its `.nitro` spritesheet, or null
|
* Build a composited PNG icon for a pet from its `.nitro` spritesheet, or null
|
||||||
* if the pet has no bundle / no usable frames. Cached in-process.
|
* if the pet has no bundle / no usable frames. Cached in-process.
|
||||||
|
*
|
||||||
|
* Async because the texture is usually WebP Lossless, which needs a real codec
|
||||||
|
* to read back out.
|
||||||
*/
|
*/
|
||||||
export function getPetIconPng(lib: string): Buffer | null {
|
export async function getPetIconPng(lib: string): Promise<Buffer | null> {
|
||||||
if (!/^[A-Za-z0-9_]+$/.test(lib)) return null;
|
if (!/^[A-Za-z0-9_]+$/.test(lib)) return null;
|
||||||
const cached = iconCache.get(lib);
|
const cached = iconCache.get(lib);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
const pending = (async (): Promise<Buffer | null> => {
|
||||||
const nitroPath = findNitro(lib);
|
const nitroPath = findNitro(lib);
|
||||||
if (!nitroPath) {
|
if (!nitroPath) return null;
|
||||||
iconCache.set(lib, null);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { json, png } = parseNitroBundle(
|
const { json, texture } = parseNitroBundle(
|
||||||
readFileSync(/*turbopackIgnore: true*/ nitroPath),
|
readFileSync(/*turbopackIgnore: true*/ nitroPath),
|
||||||
);
|
);
|
||||||
const sheet = decodePng(png);
|
const sheet = await decodeTextureRgba(texture);
|
||||||
const icon = composite(json as NitroJson, sheet);
|
return composite(json as NitroJson, sheet);
|
||||||
iconCache.set(lib, icon);
|
|
||||||
return icon;
|
|
||||||
} catch {
|
} catch {
|
||||||
iconCache.set(lib, null);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
iconCache.set(lib, pending);
|
||||||
|
return pending;
|
||||||
}
|
}
|
||||||
@@ -274,7 +274,7 @@ export async function repairMissingIcons(
|
|||||||
foundNitro = true;
|
foundNitro = true;
|
||||||
try {
|
try {
|
||||||
const nitroBuf = await fs.readFile(nitroPath);
|
const nitroBuf = await fs.readFile(nitroPath);
|
||||||
const icon = extractFurniIconPng(nitroBuf);
|
const icon = await extractFurniIconPng(nitroBuf);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
const writeErrors = await writeIconToDirs(
|
const writeErrors = await writeIconToDirs(
|
||||||
icon,
|
icon,
|
||||||
@@ -375,7 +375,9 @@ export async function repairMissingIcons(
|
|||||||
);
|
);
|
||||||
wroteTmp = dl.ok;
|
wroteTmp = dl.ok;
|
||||||
if (dl.ok) {
|
if (dl.ok) {
|
||||||
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
const icon = await extractFurniIconPng(
|
||||||
|
await fs.readFile(nitroTmp),
|
||||||
|
);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
const writeErrors = await writeIconToDirs(
|
const writeErrors = await writeIconToDirs(
|
||||||
icon,
|
icon,
|
||||||
|
|||||||
Reference in new issue
Block a user