diff --git a/src/app/admin/import/furni/import-furni-client.tsx b/src/app/admin/import/furni/import-furni-client.tsx index c18618ec53..4ed5349c32 100644 --- a/src/app/admin/import/furni/import-furni-client.tsx +++ b/src/app/admin/import/furni/import-furni-client.tsx @@ -12,6 +12,7 @@ import { FileWarning, FolderSync, Globe2, + ImageDown, LayoutGrid, List, Loader2, @@ -147,6 +148,7 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { const [recentResyncResult, setRecentResyncResult] = useState(null); const [verifyingInteractions, setVerifyingInteractions] = useState(false); + const [addingSize32, setAddingSize32] = useState(false); const [verifyResult, setVerifyResult] = useState(null); const searchRef = useRef(null); @@ -775,6 +777,38 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { } } + async function addSize32All() { + if ( + !confirm( + "Add a size-32 visualization to every existing .nitro that lacks one? This rewrites the live furniture bundles (incl. /var/www/Gamedata) and can take a while.", + ) + ) { + return; + } + setAddingSize32(true); + try { + const res = await adminFetch("/api/admin/import/furni/add-size32", { + method: "POST", + }); + const d = await res.json(); + if (res.ok) { + toast.success( + d.alreadyRunning + ? "Size-32 conversion is already running in the background" + : "Started size-32 conversion in the background — watch the server log for progress", + ); + } else { + toast.error(d.error || "Failed to start size-32 conversion"); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Size-32 conversion failed", + ); + } finally { + setAddingSize32(false); + } + } + async function applyReorganize() { setReorgPreview(null); setReorganizing(true); @@ -1551,6 +1585,10 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { {reorganizing ? "Reorganizing..." : "Reorganize Catalog"} + + + {addingSize32 ? "Adding size 32..." : "Add size 32 to all .nitro"} + { + if (evt.type === "progress") { + if (evt.status === "done") status.succeeded++; + else if (evt.status === "convert-failed" || evt.status === "write-failed") + status.failed++; + else status.skipped++; + if (evt.message) + status.lastMessage = `${evt.classname}: ${evt.status} ${evt.message}`; + } else if (evt.type === "batch_complete") { + status.succeeded = evt.succeeded ?? 0; + status.failed = evt.failed ?? 0; + status.skipped = evt.skipped ?? 0; + status.existing = evt.existing ?? 0; + status.lastMessage = "done"; + } else if (evt.type === "error") { + status.lastMessage = evt.message ?? null; + } + }) + .then((result) => { + status.succeeded = result.succeeded; + status.failed = result.failed; + status.skipped = result.skipped; + status.existing = result.existing; + status.lastMessage = "done"; + status.running = false; + status.finishedAt = Date.now(); + }) + .catch((err) => { + status.lastMessage = (err as Error).message; + status.running = false; + status.finishedAt = Date.now(); + }); + + logAudit({ + userId, + action: "furni_add_size32", + target: "FurniNitro", + targetId: 0, + }); +} + +export const POST = withAdmin( + { permission: PERMS.ASSETS_IMPORT }, + async (_request, ctx) => { + if (status.running) { + return apiOk({ ...status, alreadyRunning: true }); + } + try { + runJob(ctx.session.user.id); + return apiOk({ ...status, started: true }); + } catch (err) { + return apiError((err as Error).message, 500); + } + }, +); + +export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { + return apiOk({ ...status }); +}); diff --git a/src/lib/services/repair-nitros.ts b/src/lib/services/repair-nitros.ts index da1d25a455..27137f98c2 100644 --- a/src/lib/services/repair-nitros.ts +++ b/src/lib/services/repair-nitros.ts @@ -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(); + 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(); + 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 }; +} diff --git a/src/lib/services/swf/add-size32.ts b/src/lib/services/swf/add-size32.ts new file mode 100644 index 0000000000..9b51f11dc9 --- /dev/null +++ b/src/lib/services/swf/add-size32.ts @@ -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): 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 { + 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) || {}; + const spritesheet = json.spritesheet as + | { frames?: Record } + | 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 = {}; + 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>) || []; + const sourceVis = vis.find((v) => v.size === sourceSize); + if (sourceVis) { + vis.push({ ...sourceVis, size: 32 }); + } + + return createNitroBundle(json, newPng, name); +}