feat: synthesize size 32 (catalog icon) frame for all furniture .nitro bundles
CI / check (push) Successful in 35s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m18s

AddSize32ToBundle downscales the largest existing sprite (usually size 64)
to generate a 32 visualization, so every bundle — including the
source-provided gamedata furniture — carries a 32 frame without needing the
original SWF. AddSize32AllNitros rewrites every existing .nitro across all
nitro directories (incl. /var/www/Gamedata/bundled/furniture) and is
idempotent (bundles that already have 32 are skipped). Expose it via a Studio
button and the background admin endpoint POST /api/admin/import/furni/add-size32.
This commit is contained in:
openhands committed 2026-08-22 13:31:50 +02:00
1 parent 2996ae3ab0
commit ac7c427c3d
4 files changed
+668 -1

No files matched your search

+358 -1
View File
@@ -17,6 +17,8 @@ import { downloadFile } from "@/lib/services/import/core/download";
import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
import { ensureDirectories } from "./furni-import";
import { convertSwfToNitro } from "./swf";
import { addSize32ToBundle } from "./swf/add-size32";
const REPAIR_CONCURRENCY = 6;
@@ -99,6 +101,7 @@ export interface RepairNitroEvent {
succeeded?: number;
failed?: number;
skipped?: number;
existing?: number;
duration?: number;
}
@@ -355,6 +358,360 @@ export async function repairMissingNitros(
failed,
skipped,
});
return { succeeded, failed, skipped };
}
/**
* Re-convert every furniture `.nitro` we produced from a local SWF so the
* bundles include size 32 (and every other visualization size the converter
* now emits). Used to backfill size 32 support on already-imported furniture
* after the converter was changed.
*
* Only items that have a local SWF are re-converted: those are the bundles our
* own converter built (and that may be missing size 32). Source-provided
* `.nitro` bundles already carry every size, so they are left untouched. The
* regenerated bundle is validated before it overwrites anything in the nitro
* directories.
*/
export async function reconvertAllNitros(
onEvent?: (evt: RepairNitroEvent) => void,
): Promise<{
succeeded: number;
failed: number;
skipped: number;
existing?: number;
}> {
let succeeded = 0;
let failed = 0;
let skipped = 0;
try {
await ensureDirectories();
} catch (err) {
onEvent?.({
type: "error",
message: `ensureDirectories failed: ${(err as Error).message}`,
});
return { succeeded, failed, skipped };
}
let targets: FurniAssetWriteTargets;
try {
targets = await getFurniAssetWriteTargets();
} catch (err) {
onEvent?.({
type: "error",
message: `getFurniAssetWriteTargets failed: ${(err as Error).message}`,
});
return { succeeded, failed, skipped };
}
const nitroDirs = uniqueDirs([
targets.nitroDir,
...targets.mirrorDirs.map((d) => d.nitroDir),
]);
// Build a kind lookup so we can skip furniture that has no asset (badges,
// song disks, etc).
const kindMap = new Map<
string,
{ item_name: string; type: string; interaction_type: string }
>();
try {
const [rows] = (await db.execute(sql`
SELECT item_name, type, interaction_type FROM items_base
`)) as unknown as [
Array<{ item_name: string; type: string; interaction_type: string }>,
unknown,
];
for (const r of rows) kindMap.set(r.item_name, r);
} catch (err) {
onEvent?.({
type: "error",
message: `Database query failed: ${(err as Error).message}`,
});
return { succeeded, failed, skipped };
}
const kindForBase = (base: string) => {
const exact = kindMap.get(base);
if (exact) return exact;
for (const [name, it] of kindMap) {
if (name.startsWith(`${base}*`)) return it;
}
return undefined;
};
// Visit every .nitro we already have on disk (primary dir + mirrored
// gamedata dirs) and re-convert the ones we have a local SWF for. Those are
// the bundles our own converter produced that may be missing size 32; we
// regenerate them and overwrite every nitro directory (including the live
// /var/www/Gamedata bundled/furniture). Bundles whose SWF is not available
// locally are left untouched — they are source-provided and already carry
// the sizes their assets define (a 32 frame can only exist if the furniture
// itself defines one in its assets).
const existingBases = new Set<string>();
for (const dir of nitroDirs) {
try {
const names = await fs.readdir(dir);
for (const n of names) {
if (!n.toLowerCase().endsWith(".nitro")) continue;
const base = n.replace(/\.nitro$/i, "");
existingBases.add(base);
// Classnames with dots are stored with underscores on disk.
existingBases.add(base.replace(/_/g, "."));
}
} catch {
/* ignore unreadable dir */
}
}
const existing = existingBases.size;
const basesToProcess: string[] = [];
for (const base of existingBases) {
if (existsSync(path.join(targets.swfDir, `${base}.swf`))) {
basesToProcess.push(base);
}
}
const total = basesToProcess.length;
onEvent?.({ type: "started", total });
await runPool(basesToProcess, REPAIR_CONCURRENCY, async (base, i) => {
const fileName = `${base}.nitro`;
const item = kindForBase(base);
if (item && NO_ASSET_KINDS.has(classifyCatalogItem(item))) {
skipped++;
onEvent?.({
type: "progress",
classname: base,
status: "skipped",
index: i,
total,
});
return;
}
const swfPath = path.join(targets.swfDir, `${base}.swf`);
let swf: Buffer;
try {
swf = await fs.readFile(swfPath);
} catch (err) {
skipped++;
onEvent?.({
type: "progress",
classname: base,
status: "no-swf",
index: i,
total,
message: (err as Error).message,
});
return;
}
try {
const result = convertSwfToNitro(swf, base);
if (!result.bundle) {
failed++;
onEvent?.({
type: "progress",
classname: base,
status: "convert-failed",
index: i,
total,
});
return;
}
// Validate the regenerated bundle before overwriting anything.
parseNitroBundle(result.bundle);
const errors = await writeNitroToDirs(result.bundle, nitroDirs, fileName);
if (errors.length > 0) {
failed++;
onEvent?.({
type: "progress",
classname: base,
status: "write-failed",
index: i,
total,
warnings: errors,
});
return;
}
succeeded++;
onEvent?.({
type: "progress",
classname: base,
status: "done",
index: i,
total,
warnings: result.warnings,
});
} catch (err) {
failed++;
onEvent?.({
type: "progress",
classname: base,
status: "convert-failed",
index: i,
total,
message: (err as Error).message,
});
}
});
onEvent?.({ type: "batch_complete", succeeded, failed, skipped, existing });
return { succeeded, failed, skipped, existing };
}
/**
* Synthesize a size-32 visualization on every existing furniture `.nitro` that
* does not already have one. The 32 frame is generated by downscaling the
* largest existing sprite (usually size 64) — no SWF is required, so this works
* for source-provided bundles too. Each rewritten bundle is validated and
* written back to every nitro directory (including the live
* `/var/www/Gamedata/bundled/furniture`). Bundles that already carry a 32 frame
* (or cannot derive one) are left untouched.
*/
export async function addSize32AllNitros(
onEvent?: (evt: RepairNitroEvent) => void,
opts?: { concurrency?: number },
): Promise<{
succeeded: number;
failed: number;
skipped: number;
existing: number;
}> {
let succeeded = 0;
let failed = 0;
let skipped = 0;
try {
await ensureDirectories();
} catch (err) {
onEvent?.({
type: "error",
message: `ensureDirectories failed: ${(err as Error).message}`,
});
return { succeeded, failed, skipped, existing: 0 };
}
let targets: FurniAssetWriteTargets;
try {
targets = await getFurniAssetWriteTargets();
} catch (err) {
onEvent?.({
type: "error",
message: `getFurniAssetWriteTargets failed: ${(err as Error).message}`,
});
return { succeeded, failed, skipped, existing: 0 };
}
const nitroDirs = uniqueDirs([
targets.nitroDir,
...targets.mirrorDirs.map((d) => d.nitroDir),
]);
// Collect every .nitro we have (across primary + mirrored gamedata dirs),
// keyed by file name so we process each bundle exactly once.
const fileNames = new Set<string>();
for (const dir of nitroDirs) {
try {
const names = await fs.readdir(dir);
for (const n of names) {
if (n.toLowerCase().endsWith(".nitro")) fileNames.add(n);
}
} catch {
/* ignore unreadable dir */
}
}
const existing = fileNames.size;
const total = existing;
onEvent?.({ type: "started", total });
await runPool(
[...fileNames],
opts?.concurrency ?? 12,
async (fileName, i) => {
let buf: Buffer | null = null;
for (const dir of nitroDirs) {
try {
buf = await fs.readFile(path.join(dir, fileName));
break;
} catch {
/* try next dir */
}
}
if (!buf) {
skipped++;
onEvent?.({
type: "progress",
classname: fileName,
status: "missing",
index: i,
total,
});
return;
}
let out: Buffer | null;
try {
out = await addSize32ToBundle(buf);
} catch (err) {
failed++;
onEvent?.({
type: "progress",
classname: fileName,
status: "convert-failed",
index: i,
total,
message: (err as Error).message,
});
return;
}
if (!out) {
skipped++;
return;
}
try {
const errors = await writeNitroToDirs(out, nitroDirs, fileName);
if (errors.length > 0) {
failed++;
onEvent?.({
type: "progress",
classname: fileName,
status: "write-failed",
index: i,
total,
warnings: errors,
});
return;
}
succeeded++;
onEvent?.({
type: "progress",
classname: fileName,
status: "done",
index: i,
total,
});
} catch (err) {
failed++;
onEvent?.({
type: "progress",
classname: fileName,
status: "write-failed",
index: i,
total,
message: (err as Error).message,
});
}
},
);
onEvent?.({ type: "batch_complete", succeeded, failed, skipped, existing });
return { succeeded, failed, skipped, existing };
}
+175
View File
@@ -0,0 +1,175 @@
import sharp from "sharp";
import type { SpriteImage } from "./image-decoder";
import {
buildSpritesheet,
createNitroBundle,
parseNitroBundle,
} from "./nitro-builder";
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
interface NitroFrame {
frame: { x: number; y: number; w: number; h: number };
rotated?: boolean;
trimmed?: boolean;
spriteSourceSize?: { x: number; y: number; w: number; h: number };
sourceSize?: { w: number; h: number };
pivot?: { x: number; y: number };
}
export function nitroHasSize32(json: Record<string, unknown>): boolean {
const vis = json.visualizations as Array<{ size?: number }> | undefined;
return Array.isArray(vis) && vis.some((v) => v?.size === 32);
}
/**
* Synthesize a size-32 visualization for a furniture `.nitro` bundle by
* downscaling the largest existing sprite (usually size 64). No SWF is needed
* — the 32 frame is generated from pixels already in the bundle. Returns the
* new bundle buffer, or `null` when it already has a 32 frame or none can be
* derived.
*/
export async function addSize32ToBundle(
buffer: Buffer,
): Promise<Buffer | null> {
const { json, png, jsonFileName } = parseNitroBundle(buffer);
if (nitroHasSize32(json)) return null;
const name = (json.name as string) || jsonFileName.replace(/\.json$/, "");
const assets =
(json.assets as Record<string, { x: number; y: number }>) || {};
const spritesheet = json.spritesheet as
| { frames?: Record<string, NitroFrame> }
| undefined;
const frames = spritesheet?.frames;
if (!frames) return null;
const sizeRe = new RegExp(`^${escapeRe(name)}_(\\d+)_a_(\\d+)_(\\d+)$`);
let sourceSize = -1;
for (const key of Object.keys(assets)) {
const m = key.match(sizeRe);
if (m) sourceSize = Math.max(sourceSize, Number(m[1]));
}
// Need a reasonably sized source to derive a quality 32 frame.
if (sourceSize < 8 || sourceSize === 32) return null;
const scale = 32 / sourceSize;
const sourceAssetKeys = Object.keys(assets).filter((key) => {
const m = key.match(sizeRe);
return m && Number(m[1]) === sourceSize;
});
const images: SpriteImage[] = [];
let id = 0;
// Re-pack every existing frame (so the sheet is rebuilt consistently).
for (const [frameName, frame] of Object.entries(frames)) {
const f = frame.frame;
if (!f || f.w <= 0 || f.h <= 0) continue;
const { data, info } = await sharp(png)
.extract({ left: f.x, top: f.y, width: f.w, height: f.h })
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
images.push({
id: id++,
name: frameName,
width: info.width,
height: info.height,
pixels: Buffer.from(data),
});
}
// For each source-size asset, create a downscaled 32 frame.
const newAssets: Record<string, { x: number; y: number }> = {};
for (const assetKey of sourceAssetKeys) {
const m = assetKey.match(sizeRe);
if (!m) continue;
const dir = m[2];
const layer = m[3];
const frameName = `${name}_${assetKey}`;
const frame = frames[frameName];
if (!frame) continue;
const f = frame.frame;
if (f.w <= 0 || f.h <= 0) continue;
const src = await sharp(png)
.extract({ left: f.x, top: f.y, width: f.w, height: f.h })
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
const targetW = Math.max(1, Math.round(f.w * scale));
const targetH = Math.max(1, Math.round(f.h * scale));
const resized = await sharp(src.data, {
raw: { width: src.info.width, height: src.info.height, channels: 4 },
})
.resize(targetW, targetH, { fit: "fill" })
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
const newAssetKey = `${name}_32_a_${dir}_${layer}`;
const newFrameName = `${name}_${newAssetKey}`;
images.push({
id: id++,
name: newFrameName,
width: resized.info.width,
height: resized.info.height,
pixels: Buffer.from(resized.data),
});
const orig = assets[assetKey];
newAssets[newAssetKey] = {
x: Math.round((orig?.x ?? 0) * scale),
y: Math.round((orig?.y ?? 0) * scale),
};
}
if (Object.keys(newAssets).length === 0) return null;
const { png: newPng, frames: newFrames } = buildSpritesheet(images);
// Update existing frame coordinates (sheet was rebuilt).
for (const [frameName, frame] of Object.entries(frames)) {
const rect = newFrames.get(frameName);
if (rect) {
frame.frame.x = rect.x;
frame.frame.y = rect.y;
frame.frame.w = rect.w;
frame.frame.h = rect.h;
}
}
// Add the new 32 frames.
for (const assetKey of Object.keys(newAssets)) {
const frameName = `${name}_${assetKey}`;
const rect = newFrames.get(frameName);
if (!rect) continue;
frames[frameName] = {
frame: { x: rect.x, y: rect.y, w: rect.w, h: rect.h },
rotated: false,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: rect.w, h: rect.h },
sourceSize: { w: rect.w, h: rect.h },
pivot: { x: 0.5, y: 0.5 },
};
}
// Merge new assets into the asset map.
for (const [assetKey, offset] of Object.entries(newAssets)) {
assets[assetKey] = offset as { x: number; y: number };
}
// Add a size-32 visualization mirroring the source-size one.
const vis = (json.visualizations as Array<Record<string, unknown>>) || [];
const sourceVis = vis.find((v) => v.size === sourceSize);
if (sourceVis) {
vis.push({ ...sourceVis, size: 32 });
}
return createNitroBundle(json, newPng, name);
}