Add GLB XKT pipeline and LOD controls
This commit is contained in:
+3
-2
@@ -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
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user