feat: synthesize size 32 (catalog icon) frame for all furniture .nitro bundles
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:
1 parent
2996ae3ab0
commit
ac7c427c3d
4 files changed
+668
-1
No files matched your search
@@ -12,6 +12,7 @@ import {
|
|||||||
FileWarning,
|
FileWarning,
|
||||||
FolderSync,
|
FolderSync,
|
||||||
Globe2,
|
Globe2,
|
||||||
|
ImageDown,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -147,6 +148,7 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
|||||||
const [recentResyncResult, setRecentResyncResult] =
|
const [recentResyncResult, setRecentResyncResult] =
|
||||||
useState<RecentFurniResyncResult | null>(null);
|
useState<RecentFurniResyncResult | null>(null);
|
||||||
const [verifyingInteractions, setVerifyingInteractions] = useState(false);
|
const [verifyingInteractions, setVerifyingInteractions] = useState(false);
|
||||||
|
const [addingSize32, setAddingSize32] = useState(false);
|
||||||
const [verifyResult, setVerifyResult] =
|
const [verifyResult, setVerifyResult] =
|
||||||
useState<VerifyInteractionsResult | null>(null);
|
useState<VerifyInteractionsResult | null>(null);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(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() {
|
async function applyReorganize() {
|
||||||
setReorgPreview(null);
|
setReorgPreview(null);
|
||||||
setReorganizing(true);
|
setReorganizing(true);
|
||||||
@@ -1551,6 +1585,10 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
|||||||
<FolderSync className="w-4 h-4 mr-2" />
|
<FolderSync className="w-4 h-4 mr-2" />
|
||||||
{reorganizing ? "Reorganizing..." : "Reorganize Catalog"}
|
{reorganizing ? "Reorganizing..." : "Reorganize Catalog"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={addSize32All} disabled={addingSize32}>
|
||||||
|
<ImageDown className="w-4 h-4 mr-2" />
|
||||||
|
{addingSize32 ? "Adding size 32..." : "Add size 32 to all .nitro"}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={verifyAllInteractions}
|
onClick={verifyAllInteractions}
|
||||||
disabled={verifyingInteractions}
|
disabled={verifyingInteractions}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
|
import { apiError, apiOk } from "@/lib/api-response";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
import { addSize32AllNitros } from "@/lib/services/repair-nitros";
|
||||||
|
|
||||||
|
interface Size32Status {
|
||||||
|
running: boolean;
|
||||||
|
startedAt: number | null;
|
||||||
|
finishedAt: number | null;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
skipped: number;
|
||||||
|
existing: number;
|
||||||
|
lastMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status: Size32Status = {
|
||||||
|
running: false,
|
||||||
|
startedAt: null,
|
||||||
|
finishedAt: null,
|
||||||
|
succeeded: 0,
|
||||||
|
failed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
existing: 0,
|
||||||
|
lastMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function runJob(userId: number) {
|
||||||
|
status.running = true;
|
||||||
|
status.startedAt = Date.now();
|
||||||
|
status.finishedAt = null;
|
||||||
|
status.succeeded = 0;
|
||||||
|
status.failed = 0;
|
||||||
|
status.skipped = 0;
|
||||||
|
status.existing = 0;
|
||||||
|
status.lastMessage = "started";
|
||||||
|
|
||||||
|
addSize32AllNitros((evt) => {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
@@ -17,6 +17,8 @@ import { downloadFile } from "@/lib/services/import/core/download";
|
|||||||
import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind";
|
import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind";
|
||||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||||
import { ensureDirectories } from "./furni-import";
|
import { ensureDirectories } from "./furni-import";
|
||||||
|
import { convertSwfToNitro } from "./swf";
|
||||||
|
import { addSize32ToBundle } from "./swf/add-size32";
|
||||||
|
|
||||||
const REPAIR_CONCURRENCY = 6;
|
const REPAIR_CONCURRENCY = 6;
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ export interface RepairNitroEvent {
|
|||||||
succeeded?: number;
|
succeeded?: number;
|
||||||
failed?: number;
|
failed?: number;
|
||||||
skipped?: number;
|
skipped?: number;
|
||||||
|
existing?: number;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +358,360 @@ export async function repairMissingNitros(
|
|||||||
failed,
|
failed,
|
||||||
skipped,
|
skipped,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { succeeded, 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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user