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 type { NextConfig } from "next";
|
||||
import { PHASE_PRODUCTION_BUILD } from "next/constants";
|
||||
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 = () => {
|
||||
try {
|
||||
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
||||
@@ -174,4 +214,9 @@ const nextConfig: NextConfig = {
|
||||
|
||||
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
|
||||
# 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...>
|
||||
# Backend kiezen: CMS_MEMORY_CAP_BACKEND=systemd|ulimit|none|auto
|
||||
# ulimit-waarde kiezen: CMS_MEMORY_CAP_VIRTUAL=40g
|
||||
@@ -93,6 +101,10 @@ virtual_kb=$((virtual_bytes / 1024))
|
||||
backend="${CMS_MEMORY_CAP_BACKEND:-auto}"
|
||||
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`
|
||||
# zonder rechten faalt met "Access denied", en dat moet dan een nette
|
||||
# terugval naar ulimit worden in plaats van een kapotte build.
|
||||
|
||||
@@ -20,12 +20,18 @@ export const GET = withAdmin(
|
||||
|
||||
const directories = catalogueAssetDirectories(await getGamedataRoot());
|
||||
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))
|
||||
.filter((m): m is RegExpExecArray => Boolean(m))
|
||||
.map((m) => Number(m[1]))
|
||||
.filter((n) => Number.isFinite(n))
|
||||
.sort((a, b) => a - b);
|
||||
.filter((n) => Number.isFinite(n)),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
if (search) {
|
||||
icons = icons.filter((id) => String(id).includes(search));
|
||||
|
||||
@@ -46,7 +46,7 @@ export const GET = withAdmin(
|
||||
iconCache.set(cacheKey, null);
|
||||
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);
|
||||
pruneIconCache();
|
||||
} catch {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const lib = request.nextUrl.searchParams.get("lib") || "";
|
||||
const png = getPetIconPng(lib);
|
||||
const png = await getPetIconPng(lib);
|
||||
if (!png) return new Response(null, { status: 404 });
|
||||
return new Response(new Uint8Array(png), {
|
||||
headers: {
|
||||
|
||||
@@ -13,21 +13,22 @@ export function CatalogIcon({
|
||||
iconImage: 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 (
|
||||
<img
|
||||
src={catalogueIconUrl(iconImage)}
|
||||
src={catalogueIconUrl(iconImage, error === 0 ? "png" : "gif")}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className="shrink-0 object-contain"
|
||||
style={{ imageRendering: "pixelated", minWidth: size, minHeight: size }}
|
||||
onError={() => setError(true)}
|
||||
onError={() => setError((e) => e + 1)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,13 +213,14 @@ function IconPreview({
|
||||
iconImage: number;
|
||||
size?: number;
|
||||
}) {
|
||||
const [error, setError] = useState(false);
|
||||
const [error, setError] = useState(0);
|
||||
// Clear the previous load failure when the icon changes, otherwise a
|
||||
// placeholder sticks to the next image too.
|
||||
// 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 (
|
||||
<Package
|
||||
className="shrink-0 text-muted-foreground"
|
||||
@@ -230,13 +231,13 @@ function IconPreview({
|
||||
|
||||
return (
|
||||
<img
|
||||
src={catalogueIconUrl(iconImage)}
|
||||
src={catalogueIconUrl(iconImage, error === 0 ? "png" : "gif")}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className="shrink-0 object-contain"
|
||||
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");
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
expect(catalogueAssetUrl("front page", "png")).toBe(
|
||||
"/gamedata/c_images/catalogue/front%20page.png",
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
export const CATALOGUE_ASSET_BASE_PATH = "/gamedata/c_images/catalogue";
|
||||
|
||||
export function catalogueIconUrl(iconImage: number): string {
|
||||
return `${CATALOGUE_ASSET_BASE_PATH}/icon_${iconImage}.png`;
|
||||
export type CatalogueImageExtension = "png" | "gif";
|
||||
|
||||
/**
|
||||
* 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(
|
||||
name: string,
|
||||
extension: "png" | "gif",
|
||||
extension: CatalogueImageExtension,
|
||||
): string {
|
||||
return `${CATALOGUE_ASSET_BASE_PATH}/${encodeURIComponent(name)}.${extension}`;
|
||||
}
|
||||
@@ -1,16 +1,48 @@
|
||||
// @ts-nocheck
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
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", () => {
|
||||
it("uses an explicit deployment ID or the current Git commit", () => {
|
||||
const gitCommit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
expect(nextConfig.deploymentId).toBe(
|
||||
expect(resolveConfig().deploymentId).toBe(
|
||||
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";
|
||||
|
||||
interface FurniAsset {
|
||||
@@ -16,19 +17,48 @@ interface FurniNitroJson {
|
||||
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
|
||||
* 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
|
||||
* `.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 png: Buffer;
|
||||
let texture: Buffer;
|
||||
try {
|
||||
const parsed = parseNitroBundle(nitro);
|
||||
json = parsed.json as FurniNitroJson;
|
||||
png = parsed.png;
|
||||
texture = parsed.texture;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -44,18 +74,20 @@ export function extractFurniIconPng(nitro: Buffer): Buffer | null {
|
||||
|
||||
const asset = json.assets[iconKey];
|
||||
// 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
|
||||
// key with or without a trailing `.png`, so try both.
|
||||
// bundle name prefixed onto the asset name. Converters disagree on the
|
||||
// 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 frames = json.spritesheet?.frames ?? {};
|
||||
const frame =
|
||||
frames[`${name}_${pixelAsset}`] ?? frames[`${name}_${pixelAsset}.png`];
|
||||
const frame = findFrame(frames, name, pixelAsset);
|
||||
if (!frame || frame.rotated) return null;
|
||||
const { x, y, w, h } = frame.frame;
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
|
||||
try {
|
||||
const sheet = decodePng(png);
|
||||
const sheet = await decodeTextureRgba(texture);
|
||||
const px = cropRgba(sheet.rgba, sheet.width, x, y, w, h);
|
||||
return encodePng(w, h, px);
|
||||
} catch {
|
||||
|
||||
@@ -378,7 +378,7 @@ export async function cloneSingleFurni(params: {
|
||||
// Source serves no standalone icon (e.g. icons embedded in the .nitro) —
|
||||
// extract the catalog icon from the bundle we just downloaded.
|
||||
try {
|
||||
const icon = extractFurniIconPng(
|
||||
const icon = await extractFurniIconPng(
|
||||
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
|
||||
);
|
||||
if (icon) {
|
||||
|
||||
@@ -926,7 +926,7 @@ export async function importSingleFurni(params: {
|
||||
|
||||
if (!iconOk && nitroExists()) {
|
||||
try {
|
||||
const icon = extractFurniIconPng(
|
||||
const icon = await extractFurniIconPng(
|
||||
await fs.readFile(resolveNitro() ?? nitroPath),
|
||||
);
|
||||
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 { 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 { 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.
|
||||
const DIR_PREFERENCE = [2, 0, 4, 3, 1, 7, 6, 5];
|
||||
|
||||
// Generated PNG icons are tiny; cache them in-process keyed by lib.
|
||||
const iconCache = new Map<string, Buffer | null>();
|
||||
// Generated PNG icons are tiny; cache them in-process keyed by lib. The cache
|
||||
// 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 {
|
||||
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
|
||||
* 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;
|
||||
const cached = iconCache.get(lib);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const pending = (async (): Promise<Buffer | null> => {
|
||||
const nitroPath = findNitro(lib);
|
||||
if (!nitroPath) {
|
||||
iconCache.set(lib, null);
|
||||
return null;
|
||||
}
|
||||
if (!nitroPath) return null;
|
||||
try {
|
||||
const { json, png } = parseNitroBundle(
|
||||
const { json, texture } = parseNitroBundle(
|
||||
readFileSync(/*turbopackIgnore: true*/ nitroPath),
|
||||
);
|
||||
const sheet = decodePng(png);
|
||||
const icon = composite(json as NitroJson, sheet);
|
||||
iconCache.set(lib, icon);
|
||||
return icon;
|
||||
const sheet = await decodeTextureRgba(texture);
|
||||
return composite(json as NitroJson, sheet);
|
||||
} catch {
|
||||
iconCache.set(lib, null);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
iconCache.set(lib, pending);
|
||||
return pending;
|
||||
}
|
||||
@@ -274,7 +274,7 @@ export async function repairMissingIcons(
|
||||
foundNitro = true;
|
||||
try {
|
||||
const nitroBuf = await fs.readFile(nitroPath);
|
||||
const icon = extractFurniIconPng(nitroBuf);
|
||||
const icon = await extractFurniIconPng(nitroBuf);
|
||||
if (icon) {
|
||||
const writeErrors = await writeIconToDirs(
|
||||
icon,
|
||||
@@ -375,7 +375,9 @@ export async function repairMissingIcons(
|
||||
);
|
||||
wroteTmp = dl.ok;
|
||||
if (dl.ok) {
|
||||
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
||||
const icon = await extractFurniIconPng(
|
||||
await fs.readFile(nitroTmp),
|
||||
);
|
||||
if (icon) {
|
||||
const writeErrors = await writeIconToDirs(
|
||||
icon,
|
||||
|
||||
Reference in new issue
Block a user