Add GLB XKT pipeline and LOD controls

This commit is contained in:
CODEX
2026-06-25 16:24:48 +03:00
parent 761cb56fac
commit abf899fefd
7 changed files with 1144 additions and 37 deletions
+3 -2
View File
@@ -3,9 +3,10 @@
Лёгкий файловый backend без внешних зависимостей.
Быстрый старт
- Сервер: `npm --prefix server start` (PORT=8080 по умолчанию). Если порт занят, сервер попробует 8081/8082/8083 автоматически.
- Полный локальный viewer с backend/API: `npm run serve` или `npm --prefix server start` (PORT=8080 по умолчанию). Если порт занят, сервер попробует 8081/8082/8083 автоматически.
- Ручной выбор порта: `PORT=8088 npm --prefix server start`
- Клиент отдельно (только статика, без API): `npx http-server frontend -p 9001` и открыть http://localhost:9001
- Клиент отдельно (только статика, без API): `npm run serve:frontend` или `npx http-server frontend -p 9001` и открыть http://localhost:9001
- Старый статический корень без backend оставлен как `npm run serve:static`; для проверки upload/comments/share/LOD его не использовать.
Статика: раздаётся из `../frontend/dist`, при отсутствии сборки — из `../frontend`.
+211 -5
View File
@@ -96,6 +96,8 @@ const CONVERTIBLE_MODEL_FORMATS = new Map([
[".stp", "step"]
]);
const GLB_DRACO_OPTIMIZABLE_FORMATS = new Set(["glb"]);
const GLB_XKT_CONVERTIBLE_FORMATS = new Set(["glb"]);
const GLB_LOD_SOURCE_FORMATS = new Set(["glb"]);
const nowIso = () => new Date().toISOString();
@@ -1281,6 +1283,37 @@ const upsertAssetVersion = async (assetDir, versionRecord) => {
return nextManifest;
};
const updateAssetVersionRecord = async (assetDir, versionRecord) => {
const existing = await readAssetManifest(assetDir);
if (!existing || !Array.isArray(existing.versions)) {
return null;
}
const now = nowIso();
let replaced = false;
const nextVersions = existing.versions.map((version) => {
const sameId = versionRecord.versionId && version?.versionId === versionRecord.versionId;
const sameNumber = versionRecord.version && Number(version?.version) === Number(versionRecord.version);
if (!sameId && !sameNumber) {
return version;
}
replaced = true;
return {...version, ...versionRecord};
});
if (!replaced) {
nextVersions.push(versionRecord);
}
nextVersions.sort((first, second) => Number(first.version || 0) - Number(second.version || 0));
const nextManifest = {
...existing,
assetId: existing.assetId || versionRecord.assetId,
projectId: existing.projectId || versionRecord.projectId,
updatedAt: now,
versions: nextVersions
};
await writeAssetManifest(assetDir, nextManifest);
return nextManifest;
};
const STATIC_ROOTS = [
{prefix: "/uploads/", dir: UPLOADS_DIR},
{prefix: "/data/", dir: DATA_ROOT},
@@ -1425,6 +1458,36 @@ const guessViewerTypeFromSrc = (src, fallback = null) => {
const publicUrlForUploadSrc = (req, src) => new URL(`/${src.replace(/^\/+/, "")}`, `${getRequestBaseUrl(req)}/`).toString();
const publicLodRecords = (req, lods) => {
if (!Array.isArray(lods)) {
return [];
}
return lods
.map((lod) => {
if (!lod || typeof lod !== "object") {
return null;
}
const artifactSrc = normalizeUploadSrcValue(lod.artifactSrc || lod.src || lod.url);
if (!artifactSrc) {
return null;
}
return {
id: stringOrNull(lod.id) || "lod",
label: stringOrNull(lod.label) || stringOrNull(lod.id) || "LOD",
artifactSrc,
src: publicUrlForUploadSrc(req, artifactSrc),
url: publicUrlForUploadSrc(req, artifactSrc),
type: stringOrNull(lod.type) || guessViewerTypeFromSrc(artifactSrc, "gltf"),
ratio: Number.isFinite(Number(lod.ratio)) ? Number(lod.ratio) : null,
error: Number.isFinite(Number(lod.error)) ? Number(lod.error) : null,
size: Number(lod.size) || 0,
sha256: stringOrNull(lod.sha256),
generatedAt: stringOrNull(lod.generatedAt)
};
})
.filter(Boolean);
};
const safeShareToken = (token) => {
if (typeof token !== "string") {
return null;
@@ -1685,8 +1748,13 @@ const publicModelVersionRecord = (req, versionRecord) => {
projectId: stringOrNull(versionRecord?.projectId),
conversion,
downloadUrl: downloadSrc ? publicUrlForUploadSrc(req, downloadSrc) : "",
lodGeneration: versionRecord?.lodGeneration && typeof versionRecord.lodGeneration === "object"
? versionRecord.lodGeneration
: null,
lods: publicLodRecords(req, versionRecord?.lods),
originalFilename: stringOrNull(versionRecord?.originalFilename) || (downloadSrc ? path.basename(downloadSrc) : "model"),
previewAvailable,
selectedLod: stringOrNull(versionRecord?.viewerSettings?.viewerState?.lod?.selectedId || versionRecord?.selectedLod),
sha256: stringOrNull(versionRecord?.sha256),
size: Number(versionRecord?.size) || 0,
sourceSrc: sourceSrc || null,
@@ -1697,6 +1765,9 @@ const publicModelVersionRecord = (req, versionRecord) => {
uploadedAt: stringOrNull(versionRecord?.uploadedAt || versionRecord?.updatedAt),
version: Number(versionRecord?.version) || null,
versionId: stringOrNull(versionRecord?.versionId),
xktConversion: versionRecord?.xktConversion && typeof versionRecord.xktConversion === "object"
? versionRecord.xktConversion
: null,
viewerUrl
};
};
@@ -1912,6 +1983,7 @@ const handleCreateShare = async (req, res) => {
assetId: identity.assetId,
projectId: identity.projectId,
versionId: typeof payload?.versionId === "string" ? payload.versionId : null,
selectedLod: stringOrNull(payload?.selectedLod),
createdAt: now,
createdBy: session?.user ? {
id: session.user.id || null,
@@ -2000,6 +2072,7 @@ const handleGetShare = async (req, res, token) => {
assetId: share.assetId || null,
projectId: share.projectId || null,
versionId: share.versionId || versionHistory?.currentVersionId || null,
selectedLod: share.selectedLod || null,
versions: versionHistory?.versions || []
},
share: {
@@ -2508,7 +2581,11 @@ const findManifestForModelSrc = async (src) => {
manifest.sourceSrc,
manifest.artifactSrc,
manifest.fallbackArtifactSrc,
manifest.metadataSrc
manifest.glbArtifactSrc,
manifest.dracoArtifactSrc,
manifest.xktArtifactSrc,
manifest.metadataSrc,
...(Array.isArray(manifest.lods) ? manifest.lods.flatMap((lod) => [lod?.artifactSrc, lod?.src, lod?.url]) : [])
].map(normalizeUploadSrcValue).filter(Boolean);
if (relatedSrcs.includes(sourceSrc)) {
return {
@@ -2597,6 +2674,13 @@ const handleRawUpload = async (req, res, url) => {
const rawSourceFormat = path.extname(safeName).replace(/^\./, "").toLowerCase();
const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(safeName).toLowerCase());
const modelSourceFormat = sourceFormat || rawSourceFormat || "file";
const xktConversion = !sourceFormat && GLB_XKT_CONVERTIBLE_FORMATS.has(modelSourceFormat)
? {
status: "queued",
targetFormat: "xkt",
updatedAt: uploadedAt
}
: null;
const dracoOptimization = !sourceFormat && GLB_DRACO_OPTIMIZABLE_FORMATS.has(modelSourceFormat)
? {
status: "queued",
@@ -2630,9 +2714,12 @@ const handleRawUpload = async (req, res, url) => {
downloadSrc: src,
sourceFormat: modelSourceFormat,
status: sourceFormat ? "conversion_required" : "ready",
targetFormat: sourceFormat ? "xkt" : (dracoOptimization ? "gltf" : null),
targetFormat: sourceFormat ? "xkt" : (xktConversion ? "xkt" : (dracoOptimization ? "gltf" : null)),
uploadedAt,
message: dracoOptimization ? "Model is ready. Draco optimization queued." : "Model is ready.",
message: xktConversion
? "Model is ready. XKT conversion queued."
: (dracoOptimization ? "Model is ready. Draco optimization queued." : "Model is ready."),
...(xktConversion ? {xktConversion} : {}),
...(dracoOptimization ? {dracoOptimization} : {}),
...(sourceDedup || {})
};
@@ -3075,7 +3162,19 @@ const handleConversionStatus = async (res, searchParams) => {
sendJSON(res, 200, manifest);
};
const handleGetModelSettings = async (res, searchParams) => {
const buildModelLodPayload = (req, manifestRef, manifest = {}) => ({
sourceSrc: manifest?.sourceSrc || manifestRef.sourceSrc,
artifactSrc: manifest?.artifactSrc || null,
artifactType: stringOrNull(manifest?.artifactType || manifest?.targetFormat) || null,
lodGeneration: manifest?.lodGeneration && typeof manifest.lodGeneration === "object"
? manifest.lodGeneration
: null,
lods: publicLodRecords(req, manifest?.lods),
selectedLod: stringOrNull(manifest?.viewerSettings?.viewerState?.lod?.selectedId || manifest?.selectedLod),
sourceFormat: stringOrNull(manifest?.sourceFormat) || guessViewerTypeFromSrc(manifest?.sourceSrc || manifestRef.sourceSrc || "", "file")
});
const handleGetModelSettings = async (req, res, searchParams) => {
const manifestRef = await findManifestForModelSrc(searchParams.get("src"));
if (!manifestRef) {
sendText(res, 400, "Invalid model path");
@@ -3087,10 +3186,18 @@ const handleGetModelSettings = async (res, searchParams) => {
}
const manifest = await readJSONFile(manifestRef.manifestPath);
const lodPayload = buildModelLodPayload(req, manifestRef, manifest || {});
sendJSON(res, 200, {
sourceSrc: manifest?.sourceSrc || manifestRef.sourceSrc,
artifactSrc: manifest?.artifactSrc || null,
artifactType: lodPayload.artifactType,
fallbackArtifactSrc: manifest?.fallbackArtifactSrc || null,
xktConversion: manifest?.xktConversion && typeof manifest.xktConversion === "object"
? manifest.xktConversion
: null,
lodGeneration: lodPayload.lodGeneration,
lods: lodPayload.lods,
selectedLod: lodPayload.selectedLod,
viewerSettings: manifest?.viewerSettings || null
});
};
@@ -3140,6 +3247,9 @@ const handlePutModelSettings = async (req, res, searchParams) => {
sourceSrc: nextManifest.sourceSrc,
artifactSrc: nextManifest.artifactSrc || null,
fallbackArtifactSrc: nextManifest.fallbackArtifactSrc || null,
lodGeneration: nextManifest.lodGeneration || null,
lods: publicLodRecords(req, nextManifest.lods),
selectedLod: stringOrNull(nextManifest.viewerSettings?.viewerState?.lod?.selectedId || nextManifest.selectedLod),
viewerSettings: nextManifest.viewerSettings
});
} catch (err) {
@@ -3148,6 +3258,94 @@ const handlePutModelSettings = async (req, res, searchParams) => {
}
};
const handleGetModelLods = async (req, res, searchParams) => {
const manifestRef = await findManifestForModelSrc(searchParams.get("src"));
if (!manifestRef) {
sendText(res, 400, "Invalid model path");
return;
}
if (!existsSync(manifestRef.modelPath)) {
sendText(res, 404, "Model file not found");
return;
}
const manifest = await readJSONFile(manifestRef.manifestPath) || {};
sendJSON(res, 200, {
ok: true,
...buildModelLodPayload(req, manifestRef, manifest)
});
};
const handleGenerateModelLods = async (req, res) => {
let payload = {};
try {
payload = await parseJSONBody(req);
} catch (err) {
sendText(res, 400, err.message || "Invalid JSON");
return;
}
const src = payload?.settingsSrc || payload?.sourceSrc || payload?.src || payload?.url || payload?.artifactSrc;
const manifestRef = await findManifestForModelSrc(src);
if (!manifestRef) {
sendText(res, 400, "Invalid model path");
return;
}
if (!existsSync(manifestRef.modelPath)) {
sendText(res, 404, "Model file not found");
return;
}
const existing = await readJSONFile(manifestRef.manifestPath) || {};
const sourceSrc = normalizeUploadSrcValue(existing.sourceSrc || manifestRef.sourceSrc);
const sourcePath = resolveUploadSrc(sourceSrc);
const sourceFormat = String(existing.sourceFormat || path.extname(sourcePath || "").replace(/^\./, "")).toLowerCase();
if (!sourcePath || !GLB_LOD_SOURCE_FORMATS.has(sourceFormat)) {
sendJSON(res, 400, {
ok: false,
error: "lod_source_not_supported",
message: "LOD generation is available only for GLB models."
});
return;
}
const now = nowIso();
const requestedBy = getRequestRegistryUser(req);
const currentGeneration = existing.lodGeneration && typeof existing.lodGeneration === "object"
? existing.lodGeneration
: {};
const nextManifest = {
...existing,
sourceSrc,
downloadSrc: existing.downloadSrc || sourceSrc,
sourceFormat: "glb",
status: existing.status || "ready",
lodGeneration: {
...currentGeneration,
status: "queued",
requestedAt: currentGeneration.requestedAt || now,
updatedAt: now,
requestedBy: requestedBy ? summarizeRegistryUser(requestedBy) : null
},
updatedAt: now
};
try {
await writeJSONFile(manifestRef.manifestPath, nextManifest);
const identity = resolveAssetIdentityFromPayload({...nextManifest, src: sourceSrc});
if (identity && !identity.legacy) {
await updateAssetVersionRecord(path.join(UPLOADS_DIR, identity.projectId, identity.assetId), nextManifest);
}
sendJSON(res, 202, {
ok: true,
...buildModelLodPayload(req, manifestRef, nextManifest)
});
} catch (err) {
console.error("[server] failed to queue model LOD generation", err);
sendText(res, 500, "Failed to queue model LOD generation");
}
};
const buildProjectPayload = (body) => {
const now = new Date().toISOString();
const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`;
@@ -3591,12 +3789,20 @@ const requestHandler = async (req, res) => {
return handleGetUploadVersions(req, res, url.searchParams);
}
if (req.method === "GET" && url.pathname === "/api/uploads/lods") {
return handleGetModelLods(req, res, url.searchParams);
}
if (req.method === "POST" && url.pathname === "/api/uploads/lods") {
return handleGenerateModelLods(req, res);
}
if (req.method === "GET" && url.pathname === "/api/conversions/status") {
return handleConversionStatus(res, url.searchParams);
}
if (req.method === "GET" && url.pathname === "/api/model-settings") {
return handleGetModelSettings(res, url.searchParams);
return handleGetModelSettings(req, res, url.searchParams);
}
if (req.method === "PUT" && url.pathname === "/api/model-settings") {