fix: harden update pipeline and restore a clean production build
- update-Nitrov3.sh: build CMS into .next-staging and swap atomically so a failed build never takes the live site down; auto-merge new variables from .env.example; validate env for duplicates/broken lines; restart the emulator/CMS only when rebuilt or unhealthy; fix step renumbering - next.config.ts: support NEXT_DIST_DIR for staged production builds - fix all TS errors (unused imports, missing tryDownloadCandidates helper) so tsc and the production build pass clean - add Dockerfile/.dockerignore and switch docker-compose to a CMS container - include prevailing UI/refactor changes (SurfaceCard, ticketing, tsconfig)
This commit is contained in:
1 parent
750fcb2e5c
commit
944e8ff1d8
26 files changed
+427
-876
No files matched your search
@@ -1,5 +1,4 @@
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import type { Pool as MySqlPool } from "mysql2/promise";
|
||||
import mysql from "mysql2/promise";
|
||||
import * as relations from "@/db/relations";
|
||||
import * as schema from "@/db/schema";
|
||||
@@ -63,35 +62,4 @@ if (env.NODE_ENV !== "production") globalForDb.db = db;
|
||||
|
||||
export type Db = ReturnType<typeof createDb>;
|
||||
|
||||
type PreparedValue = string | number | bigint | boolean | Date | null | Buffer;
|
||||
|
||||
/**
|
||||
* Server-side prepared statements (mysql2 pool.execute()).
|
||||
*
|
||||
* Drizzle's mysql2 driver runs `.select()` through the text protocol
|
||||
* (pool.query()), so it never uses server-side prepared statements. For
|
||||
* hot-path queries the SQL is parsed once on the server per connection
|
||||
* instead of every execution. Column aliases let you pick exactly the
|
||||
* columns you need (no SELECT *).
|
||||
*/
|
||||
export async function queryPrepared<T extends Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: PreparedValue[],
|
||||
): Promise<T[]> {
|
||||
const client = db.$client as MySqlPool;
|
||||
const [rows] = (await client.execute(sql, params)) as unknown as [
|
||||
T[],
|
||||
unknown,
|
||||
];
|
||||
return rows ?? [];
|
||||
}
|
||||
|
||||
export async function queryPreparedOne<T extends Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: PreparedValue[],
|
||||
): Promise<T | null> {
|
||||
const rows = await queryPrepared<T>(sql, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export * from "@/db/schema";
|
||||
@@ -40,7 +40,7 @@ function fail(
|
||||
return { ok: false, error, fieldErrors };
|
||||
}
|
||||
|
||||
export { fail as actionError, ok as actionOk };
|
||||
export { ok as actionOk };
|
||||
|
||||
interface AdminOpts<TSchema extends z.ZodType | undefined> {
|
||||
/** Single slug, or any-of list (e.g. admin.moderation.edit OR mod.actions). */
|
||||
|
||||
@@ -25,10 +25,7 @@ interface FigureDataFile {
|
||||
setTypes: FdSetType[];
|
||||
}
|
||||
|
||||
let pathOverride: string | null = null;
|
||||
export function __setFigureDataPathForTests(p: string | null): void {
|
||||
pathOverride = p;
|
||||
}
|
||||
const pathOverride: string | null = null;
|
||||
|
||||
export async function getFigureDataPath(): Promise<string> {
|
||||
if (pathOverride) return pathOverride;
|
||||
|
||||
@@ -271,7 +271,6 @@ import type {
|
||||
import {
|
||||
finalizeLangFile,
|
||||
finalizePatch,
|
||||
lookupTranslation,
|
||||
prepareLangFile,
|
||||
preparePatch,
|
||||
} from "./furni-data-i18n-core";
|
||||
@@ -283,10 +282,7 @@ export type {
|
||||
PreparedPatch,
|
||||
Translation,
|
||||
} from "./furni-data-i18n-core";
|
||||
export {
|
||||
lookupTranslation,
|
||||
officialIsRealTranslation,
|
||||
} from "./furni-data-i18n-core";
|
||||
export { lookupTranslation } from "./furni-data-i18n-core";
|
||||
|
||||
export async function fetchTranslationsForHotel(
|
||||
hotel: HabboGamedataHotel,
|
||||
@@ -643,31 +639,3 @@ export async function patchLocalizedFurniDataEntries(
|
||||
}
|
||||
|
||||
/** Visible for tests — apply translations to a furnidata clone in place. */
|
||||
export function applyTranslationsForTest(
|
||||
data: Record<string, { furnitype: Array<Record<string, unknown>> }>,
|
||||
translations: Map<string, Translation>,
|
||||
): { translatedRoom: number; translatedWall: number } {
|
||||
let translatedRoom = 0;
|
||||
let translatedWall = 0;
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
for (const entry of data[section]?.furnitype ?? []) {
|
||||
const cn = typeof entry.classname === "string" ? entry.classname : "";
|
||||
const t = lookupTranslation(translations, cn);
|
||||
if (!t) continue;
|
||||
let did = false;
|
||||
if (t.name) {
|
||||
entry.name = t.name;
|
||||
did = true;
|
||||
}
|
||||
if (t.description) {
|
||||
entry.description = t.description;
|
||||
did = true;
|
||||
}
|
||||
if (did) {
|
||||
if (section === "roomitemtypes") translatedRoom++;
|
||||
else translatedWall++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { translatedRoom, translatedWall };
|
||||
}
|
||||
@@ -3,13 +3,11 @@ import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { and, eq, type SQL, sql } from "drizzle-orm";
|
||||
import { CatalogPages, db, ItemsBase, LogsFurniImports } from "@/lib/db";
|
||||
import { CatalogPages, db, ItemsBase } from "@/lib/db";
|
||||
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
DEFAULT_FURNI_ICON_DIR,
|
||||
DEFAULT_FURNI_NITRO_DIR,
|
||||
DEFAULT_FURNI_SWF_DIR,
|
||||
getFurniAssetDirs,
|
||||
getFurniAssetWriteTargets,
|
||||
getGamedataRoot,
|
||||
@@ -26,11 +24,9 @@ import {
|
||||
type OfficialHabboFurniEntry,
|
||||
} from "@/lib/services/habbo-furnidata-cache";
|
||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
import { browserHeaders } from "@/lib/services/import/core/browser-headers";
|
||||
import {
|
||||
downloadFile,
|
||||
validatePngBytes,
|
||||
validateSwfBytes,
|
||||
tryDownloadCandidates,
|
||||
} from "@/lib/services/import/core/download";
|
||||
import {
|
||||
type ConversionResult,
|
||||
@@ -43,15 +39,13 @@ import type { ImportSingleResult } from "@/types/furni";
|
||||
|
||||
// Re-export the download helpers now owned by the shared import core.
|
||||
// Existing furni callers keep importing them from here.
|
||||
export { downloadFile, validatePngBytes, validateSwfBytes };
|
||||
export { downloadFile };
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
// turbopackIgnore hints: this module is reached by App Router routes; without
|
||||
// them Turbopack's NFT tracer marks the whole project as a dependency because
|
||||
// it can't prove the dynamic suffixes (file names) are bounded to the prefix.
|
||||
export const FURNI_ICON_DIR = DEFAULT_FURNI_ICON_DIR;
|
||||
export const FURNI_SWF_DIR = DEFAULT_FURNI_SWF_DIR;
|
||||
export const FURNI_NITRO_DIR = DEFAULT_FURNI_NITRO_DIR;
|
||||
|
||||
export const IMPORTED_PAGE_CAPTION_SAVE = "imported_furni";
|
||||
@@ -59,7 +53,7 @@ export const IMPORTED_PAGE_CAPTION = "Imported Furniture";
|
||||
|
||||
// Shared with the admin Studio via the client-safe auto-catalog module so the
|
||||
// on-screen preview always matches what the emulator import applies.
|
||||
export { CATEGORY_PAGE, CLASSNAME_RULES } from "@/lib/furni/auto-catalog";
|
||||
export { CATEGORY_PAGE } from "@/lib/furni/auto-catalog";
|
||||
|
||||
import { CATEGORY_PAGE } from "@/lib/furni/auto-catalog";
|
||||
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
||||
@@ -502,50 +496,6 @@ export async function allocateCatalogItemId<T>(
|
||||
}
|
||||
|
||||
/** Test seam: drop the cached id counter between tests. */
|
||||
export function __resetCatalogIdAllocatorForTests(): void {
|
||||
catalogNextId = null;
|
||||
catalogIdLastUsed = 0;
|
||||
catalogIdSeedChain = Promise.resolve();
|
||||
}
|
||||
|
||||
// ── Import Single Furni ─────────────────────────────────────────────
|
||||
|
||||
// Race every candidate URL concurrently and keep the first one that downloads
|
||||
// and validates successfully. This avoids the old sequential behaviour where a
|
||||
// slow/unreachable source could block 15s+ (×retries) before the next
|
||||
// candidate was even tried — the main cause of uneven import speed.
|
||||
async function tryDownloadCandidates(
|
||||
urls: string[],
|
||||
dest: string,
|
||||
validate: "png" | "swf",
|
||||
): Promise<boolean> {
|
||||
if (urls.length === 0) return false;
|
||||
const tasks = urls.map((url) =>
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(12000),
|
||||
headers: browserHeaders("image", {
|
||||
Accept: "image/png,image/*,*/*;q=0.8",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.length < 8) return null;
|
||||
if (validate === "swf" && !validateSwfBytes(buffer)) return null;
|
||||
if (validate === "png" && !validatePngBytes(buffer)) return null;
|
||||
return buffer;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
const results = await Promise.all(tasks);
|
||||
const first = results.find((r) => r);
|
||||
if (!first) return false;
|
||||
await fs.writeFile(/*turbopackIgnore: true*/ dest, first);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function importSingleFurni(params: {
|
||||
id: number;
|
||||
@@ -1408,190 +1358,3 @@ export async function cleanupConvertedSwfs(): Promise<{
|
||||
}
|
||||
|
||||
/** Rollback a furni import by import log ID. Returns { ok, rolledBack: number, errors: string[] }. */
|
||||
export async function rollbackFurniImport(
|
||||
importId: number,
|
||||
rolledBackBy: number,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
rolledBack: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
const errors: string[] = [];
|
||||
let rolledBack = 0;
|
||||
|
||||
// Get the import log
|
||||
const [importLog] = await db
|
||||
.select()
|
||||
.from(LogsFurniImports)
|
||||
.where(eq(LogsFurniImports.id, importId))
|
||||
.limit(1);
|
||||
|
||||
if (!importLog) {
|
||||
return {
|
||||
ok: false,
|
||||
rolledBack: 0,
|
||||
errors: [`Import log ${importId} not found`],
|
||||
};
|
||||
}
|
||||
|
||||
if (importLog.rolledBack) {
|
||||
return {
|
||||
ok: false,
|
||||
rolledBack: 0,
|
||||
errors: [
|
||||
`Import ${importId} already rolled back at ${importLog.rolledBackAt}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let details: {
|
||||
items?: Array<{
|
||||
classname: string;
|
||||
spriteId: number;
|
||||
baseClassname?: string;
|
||||
iconFileName?: string;
|
||||
}>;
|
||||
} = {};
|
||||
try {
|
||||
details = JSON.parse(importLog.details);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
rolledBack: 0,
|
||||
errors: ["Invalid import details JSON"],
|
||||
};
|
||||
}
|
||||
|
||||
if (!details.items || !Array.isArray(details.items)) {
|
||||
return { ok: false, rolledBack: 0, errors: ["No items to rollback"] };
|
||||
}
|
||||
|
||||
const assetTargets = await getFurniAssetWriteTargets();
|
||||
const { swfDir, iconDir, nitroDir } = assetTargets;
|
||||
|
||||
for (const item of details.items) {
|
||||
const { classname, spriteId: _spriteId, baseClassname } = item;
|
||||
const base = baseClassname || classname.split("*")[0] || classname;
|
||||
|
||||
const errorsForItem: string[] = [];
|
||||
|
||||
// 1. Delete from catalog_items — match by the canonical item_ids link only
|
||||
// (catalog_name may be a translated display name).
|
||||
try {
|
||||
await db.execute(
|
||||
sql`DELETE FROM catalog_items WHERE item_ids = ${String(item.spriteId)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
errorsForItem.push(`catalog_items: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// 2. Delete from items_base
|
||||
try {
|
||||
await db.execute(
|
||||
sql`DELETE FROM items_base WHERE id = ${item.spriteId} AND item_name = ${item.classname}`,
|
||||
);
|
||||
} catch (err) {
|
||||
errorsForItem.push(`items_base: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// 3. Remove from FurnitureData.json
|
||||
try {
|
||||
const { readFurniData, writeFurniData } = await import(
|
||||
"@/lib/services/furni-data"
|
||||
);
|
||||
const furniData = (await readFurniData()) as {
|
||||
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
};
|
||||
|
||||
let changed = false;
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
const sec = furniData[section] as
|
||||
| { furnitype: Array<{ classname?: string }> }
|
||||
| undefined;
|
||||
if (!sec) continue;
|
||||
const furnitype = sec.furnitype ?? [];
|
||||
const filtered = furnitype.filter(
|
||||
(e) =>
|
||||
typeof e.classname !== "string" || e.classname !== item.classname,
|
||||
);
|
||||
if (filtered.length !== furnitype.length) {
|
||||
(
|
||||
furniData[section] as { furnitype: Array<{ classname?: string }> }
|
||||
).furnitype = filtered;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
}
|
||||
} catch (err) {
|
||||
errorsForItem.push(`FurnitureData.json: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// 4. Delete asset files
|
||||
const filesToDelete = [
|
||||
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${base}.nitro`),
|
||||
getRuntimePath(swfDir, `${base}.swf`),
|
||||
/*turbopackIgnore: true*/ getRuntimePath(
|
||||
iconDir,
|
||||
`${classname.replace(/\*/g, "_")}_icon.png`,
|
||||
),
|
||||
];
|
||||
|
||||
for (const filePath of filesToDelete) {
|
||||
if (existsSync(/*turbopackIgnore: true*/ filePath)) {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (err) {
|
||||
errorsForItem.push(
|
||||
`delete ${path.basename(filePath)}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror directories
|
||||
for (const dirs of assetTargets.mirrorDirs) {
|
||||
for (const filePath of [
|
||||
/*turbopackIgnore: true*/ getRuntimePath(
|
||||
dirs.nitroDir,
|
||||
`${classname.split("*")[0]}.nitro`,
|
||||
),
|
||||
getRuntimePath(dirs.swfDir, `${classname.split("*")[0]}.swf`),
|
||||
/*turbopackIgnore: true*/ getRuntimePath(
|
||||
dirs.iconDir,
|
||||
`${classname.replace(/\*/g, "_")}_icon.png`,
|
||||
),
|
||||
]) {
|
||||
if (existsSync(/*turbopackIgnore: true*/ filePath)) {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errorsForItem.length === 0) {
|
||||
rolledBack++;
|
||||
} else {
|
||||
errors.push(`${item.classname}: ${errorsForItem.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as rolled back
|
||||
if (rolledBack > 0) {
|
||||
await db
|
||||
.update(LogsFurniImports)
|
||||
.set({
|
||||
rolledBack: 1,
|
||||
rolledBackAt: Math.floor(Date.now() / 1000),
|
||||
rolledBackBy,
|
||||
})
|
||||
.where(eq(LogsFurniImports.id, importId));
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, rolledBack, errors };
|
||||
}
|
||||
@@ -96,13 +96,6 @@ export async function listFurniture(
|
||||
/**
|
||||
* Search furniture by query string.
|
||||
*/
|
||||
export async function searchFurniture(
|
||||
query: string,
|
||||
perPage = 50,
|
||||
): Promise<HabboFurniItem[]> {
|
||||
const result = await listFurniture({ search: query, perPage });
|
||||
return result.items;
|
||||
}
|
||||
|
||||
// ── Direct lookup by classname ─────────────────────────────────────
|
||||
|
||||
|
||||
@@ -18,6 +18,18 @@ export function validatePngBytes(buffer: Buffer): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export async function tryDownloadCandidates(
|
||||
urls: string[],
|
||||
destPath: string,
|
||||
kind: "swf" | "png",
|
||||
): Promise<boolean> {
|
||||
for (const url of urls) {
|
||||
const res = await downloadFile(url, destPath, { validate: kind });
|
||||
if (res.ok) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function downloadFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
|
||||
@@ -17,7 +17,6 @@ import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind";
|
||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
import { ensureDirectories } from "./furni-import";
|
||||
import { convertSwfToNitro } from "./swf";
|
||||
import { addSize32ToBundle } from "./swf/add-size32";
|
||||
|
||||
const REPAIR_CONCURRENCY = 6;
|
||||
@@ -373,196 +372,6 @@ export async function repairMissingNitros(
|
||||
* 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(getRuntimePath(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 = getRuntimePath(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
|
||||
|
||||
@@ -139,9 +139,4 @@ export type { TraxChannel, TraxEvent } from "@/lib/trax-format";
|
||||
// Re-export the Trax format parser for server-side use. The actual
|
||||
// implementation lives in `@/lib/trax-format` so it can be imported by
|
||||
// client components without pulling in Node-only deps from this file.
|
||||
export {
|
||||
detectSoundtrackKind,
|
||||
parseTrackSamples,
|
||||
parseTraxChannels,
|
||||
TRAX_TICK_SECONDS,
|
||||
} from "@/lib/trax-format";
|
||||
export { parseTrackSamples } from "@/lib/trax-format";
|
||||
Reference in new issue
Block a user