Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 31s
CI / tests-unit (push) Successful in 1m59s
CI / tests-integration (push) Successful in 2m19s
CI / tests-ui (push) Successful in 2m56s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m18s
Newly converted .nitro bundles now store their spritesheet as WebP VP8L instead of PNG, so imports land much smaller without changing a single pixel. The texture member and spritesheet.meta.image are both labelled from the actual bytes, never from a caller's assumption. - encode through sharp with lossless and exact, so colour hidden under alpha 0 survives; this mirrors ImageSharp's TransparentColorMode.Preserve - detect PNG/WebP by magic bytes and reject anything the client cannot render, on create, download and upload paths - keep the source format when deriving size-32 sheets, scaling composites and editing metadata, so existing bundles are never silently rewritten - report fidelity in the studio: the compression panel re-encodes with the same options the importer uses, so it cannot drift and invent false warnings, and shows PNG/WebP size estimates convertSwfToNitro and buildSpritesheet are now async, so the worker, the main-thread fallback and every import call site await them. PNG stays supported for existing bundles and icon sidecars are untouched.
209 lines
5.6 KiB
TypeScript
209 lines
5.6 KiB
TypeScript
import { existsSync, promises as fs } from "node:fs";
|
|
import {
|
|
listOfficialEffects,
|
|
resolveEffectSwfUrl,
|
|
} from "@/lib/services/effect-source";
|
|
import {
|
|
listLocalEffects,
|
|
mergeEffect,
|
|
removeEffect,
|
|
} from "@/lib/services/effectmap";
|
|
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
|
import { downloadFile } from "@/lib/services/import/core/download";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
|
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
|
import type {
|
|
EffectListItem,
|
|
EffectMapEntry,
|
|
ImportEffectResult,
|
|
} from "@/types/effects";
|
|
|
|
const EFFECT_NITRO_DIR_FALLBACK = getRuntimePath(
|
|
/*turbopackIgnore: true*/ process.cwd(),
|
|
"public/nitro-assets/bundled/effect",
|
|
);
|
|
const EFFECT_SWF_DIR_FALLBACK = getRuntimePath(
|
|
/*turbopackIgnore: true*/ process.cwd(),
|
|
"public/swf/gordon",
|
|
);
|
|
|
|
async function getEffectNitroDir(): Promise<string> {
|
|
const configured = (
|
|
(await siteSettings.get("effect_nitro_dir", "")) ?? ""
|
|
).trim();
|
|
if (configured) return configured;
|
|
const gamedataRoot = await getGamedataRoot();
|
|
if (gamedataRoot)
|
|
return getRuntimePath(
|
|
/*turbopackIgnore: true*/ gamedataRoot,
|
|
"bundled/effect",
|
|
);
|
|
return EFFECT_NITRO_DIR_FALLBACK;
|
|
}
|
|
|
|
async function getEffectSwfDir(): Promise<string> {
|
|
const configured = (
|
|
(await siteSettings.get("effect_swf_dir", "")) ?? ""
|
|
).trim();
|
|
return configured || EFFECT_SWF_DIR_FALLBACK;
|
|
}
|
|
|
|
export async function ensureEffectDirs(): Promise<void> {
|
|
const [nitroDir, swfDir] = await Promise.all([
|
|
getEffectNitroDir(),
|
|
getEffectSwfDir(),
|
|
]);
|
|
await Promise.all(
|
|
[nitroDir, swfDir]
|
|
.filter((dir) => !existsSync(/*turbopackIgnore: true*/ dir))
|
|
.map((dir) =>
|
|
fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true }),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function nitroPathFor(lib: string): Promise<string> {
|
|
return getRuntimePath(
|
|
/*turbopackIgnore: true*/ await getEffectNitroDir(),
|
|
`${lib}.nitro`,
|
|
);
|
|
}
|
|
|
|
export async function importSingleEffect(params: {
|
|
id: string;
|
|
lib: string;
|
|
type: string;
|
|
revision: number;
|
|
onProgress?: (status: string) => void;
|
|
}): Promise<ImportEffectResult> {
|
|
const { id, lib, type, revision, onProgress } = params;
|
|
const warnings: string[] = [];
|
|
await ensureEffectDirs();
|
|
|
|
const nitroPath = await nitroPathFor(lib);
|
|
|
|
// Download SWF
|
|
onProgress?.("downloading");
|
|
const swfPath = getRuntimePath(
|
|
/*turbopackIgnore: true*/ await getEffectSwfDir(),
|
|
`${lib}.swf`,
|
|
);
|
|
let swfUrl: string;
|
|
try {
|
|
swfUrl = await resolveEffectSwfUrl(lib);
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
lib,
|
|
warnings,
|
|
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
|
};
|
|
}
|
|
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
|
if (!dl.ok)
|
|
return {
|
|
ok: false,
|
|
lib,
|
|
warnings,
|
|
error: `SWF download failed: ${swfUrl}`,
|
|
};
|
|
|
|
// Convert SWF → Nitro
|
|
onProgress?.("converting");
|
|
try {
|
|
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
|
const result = await convertSwfToNitro(swfBuffer, lib);
|
|
await fs.writeFile(/*turbopackIgnore: true*/ nitroPath, result.bundle);
|
|
if (result.warnings?.length) warnings.push(...result.warnings);
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
lib,
|
|
warnings,
|
|
error: `SWF→Nitro conversion failed: ${(err as Error).message}`,
|
|
};
|
|
}
|
|
|
|
// Merge EffectMap.json (last step — no orphan entry if anything above failed)
|
|
onProgress?.("writing_map");
|
|
try {
|
|
const entry: EffectMapEntry = { id, lib, type, revision };
|
|
await mergeEffect(entry);
|
|
} catch (err) {
|
|
warnings.push(`EffectMap.json merge failed: ${(err as Error).message}`);
|
|
return { ok: false, lib, warnings, error: "EffectMap.json merge failed" };
|
|
}
|
|
|
|
return { ok: true, lib, warnings };
|
|
}
|
|
|
|
export async function deleteImportedEffect(
|
|
idOrLib: string,
|
|
): Promise<{ removed: boolean; deletedFile: boolean }> {
|
|
const local = await listLocalEffects();
|
|
const entry = local.find((e) => e.id === idOrLib || e.lib === idOrLib);
|
|
const removed = await removeEffect(idOrLib);
|
|
let deletedFile = false;
|
|
if (entry) {
|
|
const p = await nitroPathFor(entry.lib);
|
|
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
|
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
|
|
deletedFile = true;
|
|
}
|
|
}
|
|
return { removed, deletedFile };
|
|
}
|
|
|
|
export async function getEffectList(search: string): Promise<EffectListItem[]> {
|
|
const [official, local, nitroDir] = await Promise.all([
|
|
listOfficialEffects(),
|
|
listLocalEffects(),
|
|
getEffectNitroDir(),
|
|
]);
|
|
// The official Habbo effectmap endpoint is frequently unreachable/dead.
|
|
// Fall back to the locally cached map so the list stays usable.
|
|
const base = official.length > 0 ? official : local;
|
|
const localByLib = new Map(local.map((e) => [e.lib, e]));
|
|
const term = search.trim().toLowerCase();
|
|
const merged = base.map((e) => ({
|
|
...e,
|
|
present: localByLib.has(e.lib),
|
|
nitroExists: existsSync(
|
|
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${e.lib}.nitro`),
|
|
),
|
|
}));
|
|
return term
|
|
? merged.filter(
|
|
(e) =>
|
|
e.lib.toLowerCase().includes(term) ||
|
|
e.id.toLowerCase().includes(term) ||
|
|
e.type.includes(term),
|
|
)
|
|
: merged;
|
|
}
|
|
|
|
export async function getEffectStats(): Promise<{
|
|
official: number;
|
|
imported: number;
|
|
missing: number;
|
|
}> {
|
|
const [official, local] = await Promise.all([
|
|
listOfficialEffects(),
|
|
listLocalEffects(),
|
|
]);
|
|
const localLibs = new Set(local.map((e) => e.lib));
|
|
const imported =
|
|
official.length > 0
|
|
? official.filter((e) => localLibs.has(e.lib)).length
|
|
: localLibs.size;
|
|
return {
|
|
official: official.length > 0 ? official.length : local.length,
|
|
imported,
|
|
missing:
|
|
official.length > 0
|
|
? official.length - imported
|
|
: local.length - imported,
|
|
};
|
|
}
|