diff --git a/.gitignore b/.gitignore index b8a5b61..8abe0ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ # .gitignore node_modules .DS_Store +__pycache__/ +*.pyc +server/data/uploads/ diff --git a/converter/Dockerfile b/converter/Dockerfile new file mode 100644 index 0000000..dbc4c4a --- /dev/null +++ b/converter/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + NODEDC_BIM_CONVERTER_UPLOADS_DIR=/beam/uploads \ + NODEDC_BIM_CONVERTER_INTERVAL_SECONDS=10 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libgl1 \ + libglib2.0-0 \ + libglu1-mesa \ + libsm6 \ + libxext6 \ + libxrender1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . ./ + +CMD ["python", "worker.py"] diff --git a/converter/requirements.txt b/converter/requirements.txt new file mode 100644 index 0000000..324558a --- /dev/null +++ b/converter/requirements.txt @@ -0,0 +1 @@ +cadquery==2.7.0 diff --git a/converter/worker.py b/converter/worker.py new file mode 100644 index 0000000..b7c24ff --- /dev/null +++ b/converter/worker.py @@ -0,0 +1,260 @@ +import hashlib +import json +import os +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import cadquery as cq + + +CONVERTER_NAME = "NodeDcBimConverter" +CONVERTER_VERSION = "0.2.0" +UPLOADS_DIR = Path(os.environ.get("NODEDC_BIM_CONVERTER_UPLOADS_DIR", "/beam/uploads")).resolve() +POLL_INTERVAL_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_INTERVAL_SECONDS", "10")) +PROCESS_ONCE = os.environ.get("NODEDC_BIM_CONVERTER_ONCE", "").lower() in {"1", "true", "yes"} +STEP_EXTENSIONS = {".step", ".stp"} + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def src_from_path(path: Path) -> str: + return f"uploads/{path.resolve().relative_to(UPLOADS_DIR).as_posix()}" + + +def manifest_path(source_path: Path) -> Path: + return source_path.with_name(f"{source_path.name}.beam.json") + + +def read_json(path: Path) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def write_json_atomic(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_name(f"{path.name}.tmp") + tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + tmp_path.replace(path) + + +def hash_id(value: str) -> str: + return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16] + + +def json_safe(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(k): json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [json_safe(v) for v in value] + return str(value) + + +def node_to_metadata(node: cq.Assembly, parent_path: str = "") -> dict[str, Any]: + name = node.name or "root" + node_path = f"{parent_path}/{name}" if parent_path else name + children = [node_to_metadata(child, node_path) for child in node.children] + return { + "id": hash_id(node_path), + "name": name, + "path": node_path, + "hasShape": bool(node.obj), + "metadata": json_safe(node.metadata or {}), + "children": children, + } + + +def count_nodes(node: dict[str, Any]) -> int: + return 1 + sum(count_nodes(child) for child in node.get("children", [])) + + +def get_shape_type(value: Any) -> str | None: + try: + shape_type = value.ShapeType() + return str(shape_type) if shape_type else None + except Exception: + return None + + +def collect_solids(workplane: cq.Workplane) -> list[Any]: + solids: list[Any] = [] + seen: set[int] = set() + + for value in workplane.vals(): + candidates = [] + try: + candidates = list(value.Solids()) + except Exception: + candidates = [] + if not candidates and get_shape_type(value) == "Solid": + candidates = [value] + for solid in candidates: + key = hash(solid) + if key in seen: + continue + seen.add(key) + solids.append(solid) + + return solids + + +def import_step_assembly(source_path: Path) -> tuple[cq.Assembly, str]: + try: + return cq.Assembly.importStep(str(source_path)), "assembly" + except ValueError as exc: + if "does not contain an assembly" not in str(exc): + raise + + step_model = cq.importers.importStep(str(source_path)) + solids = collect_solids(step_model) + assy = cq.Assembly(name=f"{source_path.stem}_root") + + if len(solids) > 1: + for index, solid in enumerate(solids, start=1): + assy.add(solid, name=f"{source_path.stem}_solid_{index:03d}") + return assy, "split-solids" + + assy.add(step_model, name=source_path.stem) + return assy, "single-shape" + + +def convert_step_to_glb(source_path: Path, glb_path: Path, metadata_path: Path) -> dict[str, Any]: + assy, import_strategy = import_step_assembly(source_path) + tree = node_to_metadata(assy) + glb_path.parent.mkdir(parents=True, exist_ok=True) + assy.export(str(glb_path), tolerance=0.1, angularTolerance=0.1) + + metadata = { + "source": src_from_path(source_path), + "artifact": src_from_path(glb_path), + "format": "step", + "targetFormat": "glb", + "importStrategy": import_strategy, + "componentTree": tree, + "componentCount": count_nodes(tree), + "generatedAt": now_iso(), + "converter": { + "name": CONVERTER_NAME, + "version": CONVERTER_VERSION, + "engine": "cadquery", + "cadqueryVersion": getattr(cq, "__version__", "unknown"), + }, + } + write_json_atomic(metadata_path, metadata) + return metadata + + +def should_process(source_path: Path, manifest: dict[str, Any], glb_path: Path) -> bool: + status = manifest.get("status") + if status == "ready" and glb_path.exists(): + return manifest.get("converterVersion") != CONVERTER_VERSION + if status == "processing": + updated_at = manifest.get("updatedAt") + if updated_at: + try: + updated = datetime.fromisoformat(str(updated_at).replace("Z", "+00:00")) + if (datetime.now(timezone.utc) - updated).total_seconds() < 300: + return False + except Exception: + pass + return source_path.exists() + + +def process_one(source_path: Path) -> bool: + manifest_file = manifest_path(source_path) + stem = source_path.stem + glb_path = source_path.with_name(f"{stem}.glb") + metadata_path = source_path.with_name(f"{stem}.metadata.json") + manifest = read_json(manifest_file) + + if not should_process(source_path, manifest, glb_path): + return False + + base_manifest = { + "createdAt": manifest.get("createdAt") or now_iso(), + "componentTreeRequired": True, + "converterName": CONVERTER_NAME, + "converterVersion": CONVERTER_VERSION, + "sourceFormat": "step", + "sourceSrc": src_from_path(source_path), + "targetFormat": "glb", + } + + write_json_atomic( + manifest_file, + { + **base_manifest, + "message": "Preparing GLB and component tree.", + "status": "processing", + "updatedAt": now_iso(), + }, + ) + print(f"[{CONVERTER_NAME}] converting {source_path}", flush=True) + + try: + metadata = convert_step_to_glb(source_path, glb_path, metadata_path) + write_json_atomic( + manifest_file, + { + **base_manifest, + "artifactSrc": src_from_path(glb_path), + "artifactType": "gltf", + "componentCount": metadata.get("componentCount"), + "metadataSrc": src_from_path(metadata_path), + "message": "GLB and component tree are ready.", + "status": "ready", + "updatedAt": now_iso(), + }, + ) + print(f"[{CONVERTER_NAME}] ready {glb_path}", flush=True) + return True + except Exception as exc: + write_json_atomic( + manifest_file, + { + **base_manifest, + "error": str(exc), + "message": "Failed to prepare GLB and component tree.", + "status": "failed", + "updatedAt": now_iso(), + }, + ) + print(f"[{CONVERTER_NAME}] failed {source_path}: {exc}", file=sys.stderr, flush=True) + return False + + +def scan_once() -> int: + if not UPLOADS_DIR.exists(): + print(f"[{CONVERTER_NAME}] uploads dir does not exist: {UPLOADS_DIR}", flush=True) + return 0 + + processed = 0 + for source_path in sorted(UPLOADS_DIR.rglob("*")): + if not source_path.is_file(): + continue + if source_path.suffix.lower() not in STEP_EXTENSIONS: + continue + if process_one(source_path): + processed += 1 + return processed + + +def main() -> None: + print(f"[{CONVERTER_NAME}] watching {UPLOADS_DIR}", flush=True) + while True: + scan_once() + if PROCESS_ONCE: + return + time.sleep(POLL_INTERVAL_SECONDS) + + +if __name__ == "__main__": + main() diff --git a/docker-compose.beam.yml b/docker-compose.beam.yml new file mode 100644 index 0000000..455485f --- /dev/null +++ b/docker-compose.beam.yml @@ -0,0 +1,23 @@ +services: + ndc-beam-viewer: + image: node:22-alpine + working_dir: /beam/server + command: node index.js + environment: + PORT: "8080" + ports: + - "8080:8080" + volumes: + - ./:/beam + + nodedc-bim-converter: + build: + context: ./converter + image: nodedc/bim-converter:local + container_name: NodeDcBimConverter + platform: linux/amd64 + environment: + NODEDC_BIM_CONVERTER_UPLOADS_DIR: /beam/uploads + NODEDC_BIM_CONVERTER_INTERVAL_SECONDS: "10" + volumes: + - ./server/data/uploads:/beam/uploads diff --git a/frontend/dcViewer.css b/frontend/dcViewer.css index 57aaa66..9e7b1ab 100644 --- a/frontend/dcViewer.css +++ b/frontend/dcViewer.css @@ -73,6 +73,21 @@ body { overflow: hidden; } +html, +body, +.viewer-wrapper, +#viewerCanvas { + outline: none; +} + +#viewerCanvas:focus, +#viewerCanvas:focus-visible, +.viewer-wrapper:focus, +.viewer-wrapper:focus-visible { + outline: none; + box-shadow: none; +} + header { position: fixed; top: 10px; @@ -576,6 +591,52 @@ header { margin-right: 6px; } +.display-mode-panel { + margin-top: 10px; +} + +.display-mode-title { + margin-bottom: 7px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.display-mode-buttons { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.display-mode-buttons button { + min-width: 0; + border: 1px solid var(--modal-border); + border-radius: 9px; + background: var(--modal-focus); + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: 11px; + font-weight: 700; + line-height: 1; + outline: none; + padding: 8px 6px; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.display-mode-buttons button:hover, +.display-mode-buttons button.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, var(--modal-focus)); + color: var(--text); +} + +.display-mode-buttons button:focus, +.display-mode-buttons button:focus-visible { + outline: none; + box-shadow: none; +} + .examples-bar { position: fixed; left: 0; diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js index 47c2b0f..e2a5b6e 100644 --- a/frontend/dcViewer.js +++ b/frontend/dcViewer.js @@ -35,6 +35,8 @@ const settingsButton = document.querySelector('[data-action="settings"]'); const templatesButton = document.querySelector('[data-action="templates"]'); const projectionToggle = document.querySelector('[data-action="projection"]'); const treeContainer = document.getElementById("treeContainer"); +const displayModeControl = document.getElementById("displayModeControl"); +const displayModeButtons = document.querySelectorAll("[data-display-mode]"); const logList = document.getElementById("logList"); const loaderEl = document.getElementById("loader"); const navCubeCanvas = document.getElementById("navCube"); @@ -162,6 +164,7 @@ let gizmo = null; const transformStore = {}; // modelId -> objectId -> {position, rotation, scale} const modelTypes = {}; // modelId -> normalized type const sceneModels = {}; // modelId -> SceneModel +let activeDisplayMode = "source"; let currentSelection = null; // { modelId, objectId } const customTrees = new Map(); // modelId -> element let navCubePlugin = null; @@ -229,6 +232,52 @@ const updateProjectionButton = () => { } }; +const setDisplayModeButtonState = () => { + displayModeButtons.forEach((button) => { + button.classList.toggle("active", button.dataset.displayMode === activeDisplayMode); + }); +}; + +const getDisplayModeColorize = (mode) => { + switch (mode) { + case "white": + return [1, 1, 1]; + case "contrast": + return [0.78, 0.82, 1]; + case "source": + default: + return null; + } +}; + +const applyDisplayModeToModel = (modelId) => { + const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId]; + if (!model) return; + const colorize = getDisplayModeColorize(activeDisplayMode); + const objects = Object.values(model.objects || {}); + const targets = objects.length ? objects : [model]; + + targets.forEach((entity) => { + if (!entity) return; + try { + entity.colorize = colorize; + entity.opacity = 1; + if (activeDisplayMode !== "source") { + entity.edges = false; + } + } catch (err) { + console.warn("display mode update failed", err); + } + }); + + viewer?.scene?.glRedraw?.(); +}; + +const applyDisplayMode = () => { + setDisplayModeButtonState(); + Object.keys(sceneModels).forEach((modelId) => applyDisplayModeToModel(modelId)); +}; + const toggleProjection = () => { if (!viewer?.camera) return; const cam = viewer.camera; @@ -658,12 +707,23 @@ const applyGlobalTarget = () => { }); }); -window.addEventListener("focus", () => { +const scheduleCameraRearm = () => { rearmCameraControl(); -}); + window.setTimeout(rearmCameraControl, 60); + window.setTimeout(rearmCameraControl, 220); +}; -window.addEventListener("pointerup", () => { - rearmCameraControl(); +window.addEventListener("blur", scheduleCameraRearm); +window.addEventListener("focus", scheduleCameraRearm); +window.addEventListener("pageshow", scheduleCameraRearm); +window.addEventListener("pointerup", scheduleCameraRearm, true); +window.addEventListener("mouseup", scheduleCameraRearm, true); +window.addEventListener("touchend", scheduleCameraRearm, true); +window.addEventListener("touchcancel", scheduleCameraRearm, true); +document.addEventListener("visibilitychange", () => { + if (!document.hidden) { + scheduleCameraRearm(); + } }); const makeTranslationMat = (x, y, z) => { @@ -1829,6 +1889,7 @@ const buildProjectSnapshot = async (name) => { transparent: !!toggleTransparent?.checked, edges: !!toggleEdges?.checked, addMode: !!addMode?.checked, + displayMode: activeDisplayMode, selection: { ids: lastSelection.ids || [], modelId: lastSelection.modelId || null, @@ -1889,6 +1950,10 @@ const applyViewerState = (state) => { if (typeof state.addMode === "boolean" && addMode) { addMode.checked = state.addMode; } + if (typeof state.displayMode === "string") { + activeDisplayMode = state.displayMode; + applyDisplayMode(); + } if (state.transforms && typeof state.transforms === "object") { Object.keys(transformStore).forEach((k) => delete transformStore[k]); Object.assign(transformStore, safeClone(state.transforms)); @@ -2067,8 +2132,8 @@ const initViewer = async () => { if (mainCanvas) { mainCanvas.addEventListener("pointerdown", () => { focusCanvasAndControls(); - resetCameraControl(); - }); + }, true); + mainCanvas.addEventListener("pointerenter", scheduleCameraRearm); } rebuildNavCube(sdk); @@ -2296,7 +2361,7 @@ const initViewer = async () => { const loadModel = (options) => { try { - const { type, url, name = "", replace = true, id: forcedId, meta } = options; + const { type, url, name = "", replace = true, id: forcedId, meta, edges } = options; const normalizedType = normalizeType(type, typeof url === "string" ? url : ""); const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : "")); if (!inferredType || !acceptByType[inferredType]) { @@ -2337,7 +2402,11 @@ const initViewer = async () => { blobUrls.push(blobUrl); srcToLoad = blobUrl; } - const loadOpts = { id, src: srcToLoad, edges: toggleEdges.checked }; + const loadOpts = { + id, + src: srcToLoad, + edges: edges ?? (inferredType === "gltf" ? false : toggleEdges.checked), + }; if (inferredType === "gltf") loadOpts.autoMetaModel = true; if (inferredType === "bim" || inferredType === "las") loadOpts.rotation = [-90, 0, 0]; if (inferredType === "stl") loadOpts.smoothNormals = true; @@ -2355,6 +2424,7 @@ const initViewer = async () => { setStatus(`Загружено: ${name || inferredType}`); dropZone.classList.add("hidden"); setLoading(false); + applyDisplayModeToModel(id); updateNavCubeVisibility(); updatePanelsVisibility(); rearmCameraControl(); @@ -2414,6 +2484,35 @@ const initViewer = async () => { model.on("error", (err) => reject(new Error(err?.message || err))); }); + const loadStartupModelFromQuery = () => { + const params = new URLSearchParams(window.location.search); + const src = params.get("url") || params.get("src"); + if (!src) return; + + const name = params.get("name") || src.split("/").pop() || "model"; + const type = normalizeType(params.get("type"), src) || normalizeType(guessTypeFromName(name)); + if (!type) { + showError("Не удалось определить формат модели из URL"); + return; + } + if (params.has("displayMode")) { + activeDisplayMode = params.get("displayMode") || "source"; + setDisplayModeButtonState(); + } + + loadModel({ + type, + url: src, + name, + replace: params.get("replace") !== "false", + edges: params.has("edges") ? params.get("edges") !== "false" : undefined, + meta: { + label: name, + source: "query", + }, + }); + }; + const loadProjectSnapshot = async (project) => { if (!project) { throw new Error("Нет данных проекта"); @@ -2698,6 +2797,16 @@ const loadProjectSnapshot = async (project) => { rebuildNavCube(sdk); applyHighlightTheme(); applyMeasureTheme(); + if (displayModeControl) { + displayModeButtons.forEach((button) => { + button.addEventListener("click", () => { + activeDisplayMode = button.dataset.displayMode || "source"; + applyDisplayMode(); + focusCanvasAndControls(); + }); + }); + setDisplayModeButtonState(); + } loadProjectsList().catch(() => { templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" }); }); @@ -3076,6 +3185,8 @@ const loadProjectSnapshot = async (project) => { onClose: () => {} }); } + + loadStartupModelFromQuery(); }; document.addEventListener("DOMContentLoaded", () => { diff --git a/frontend/index.html b/frontend/index.html index 85fa271..e8632bf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -87,7 +87,7 @@ - +
@@ -128,6 +128,14 @@

Инспектор

+
+
Режим отображения
+
+ + + +
+

Название проекта

@@ -223,6 +231,6 @@ - + diff --git a/server/README.md b/server/README.md index 2ba2f0d..9defa3a 100644 --- a/server/README.md +++ b/server/README.md @@ -15,5 +15,13 @@ API: - `GET /api/projects[?type=project|template]` — укороченный список из `index.json`. - `GET /api/projects/:id` — полный JSON проекта. - `POST /api/projects` — создать проект, генерируется `id`, `createdAt`, `updatedAt`, сохраняется файл и обновляется индекс. +- `POST /api/uploads?filename=&projectId=` — сохранить оригинальный файл модели в `server/data/uploads`. +- `GET /api/conversions/status?src=` — получить статус подготовки viewer-артефакта. + Для `.step/.stp` ответ помечается `conversion.status=conversion_required`: оригинал уже доступен для скачивания, + а preview должен появиться после работы `NodeDcBimConverter`, который готовит GLB и metadata/tree. + +Локальный Beam compose: +- `docker compose -f docker-compose.beam.yml up ndc-beam-viewer` +- `docker compose -f docker-compose.beam.yml up nodedc-bim-converter` Поле `ownerId` пока всегда `null`, но оставлено для будущих пользователей/шаринга. diff --git a/server/index.js b/server/index.js index 5f3e719..77f2daa 100644 --- a/server/index.js +++ b/server/index.js @@ -34,10 +34,19 @@ const MIME_TYPES = { ".las": "application/octet-stream", ".laz": "application/octet-stream", ".bim": "application/octet-stream", + ".step": "application/octet-stream", + ".stp": "application/octet-stream", ".wasm": "application/wasm", ".txt": "text/plain" }; +const CONVERTIBLE_MODEL_FORMATS = new Map([ + [".step", "step"], + [".stp", "step"] +]); + +const nowIso = () => new Date().toISOString(); + const ensureStorage = async () => { await fs.mkdir(DATA_DIR, {recursive: true}); await fs.mkdir(UPLOADS_DIR, {recursive: true}); @@ -186,6 +195,54 @@ const sanitizeFilename = (name, fallback) => { return safe || fallback; }; +const manifestPathForSource = (sourcePath) => path.join(path.dirname(sourcePath), `${path.basename(sourcePath)}.beam.json`); + +const srcFromUploadPath = (uploadPath) => { + const relative = path.relative(UPLOADS_DIR, uploadPath).replace(/\\/g, "/"); + return path.join("uploads", relative).replace(/\\/g, "/"); +}; + +const resolveUploadSrc = (src) => { + if (!src || typeof src !== "string") { + return null; + } + let value = src.trim(); + if (!value) { + return null; + } + try { + if (/^https?:\/\//i.test(value)) { + const parsed = new URL(value); + value = parsed.pathname; + } + } catch (_) { + return null; + } + value = value.replace(/^\/+/, ""); + if (!value.startsWith("uploads/")) { + return null; + } + const relative = value.slice("uploads/".length); + const resolved = path.resolve(UPLOADS_DIR, relative); + if (!resolved.startsWith(UPLOADS_DIR)) { + return null; + } + return resolved; +}; + +const readJSONFile = async (filePath) => { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw); + } catch (_) { + return null; + } +}; + +const writeJSONFile = async (filePath, payload) => { + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); +}; + const processUploads = async (project) => { if (!Array.isArray(project.models)) { return; @@ -232,9 +289,64 @@ const handleRawUpload = async (req, res, url) => { return; } const src = path.join("uploads", uploadId, safeName).replace(/\\/g, "/"); + const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(safeName).toLowerCase()); + if (sourceFormat) { + const conversion = { + componentTreeRequired: true, + message: "Original STEP uploaded. GLB/component tree is waiting for NodeDcBimConverter.", + sourceFormat, + sourceSrc: src, + status: "conversion_required", + targetFormat: "glb", + updatedAt: nowIso() + }; + await writeJSONFile(manifestPathForSource(targetPath), conversion); + sendJSON(res, 201, { + src, + conversion + }); + return; + } sendJSON(res, 201, {src}); }; +const handleConversionStatus = async (res, searchParams) => { + const sourcePath = resolveUploadSrc(searchParams.get("src")); + if (!sourcePath) { + sendText(res, 400, "Invalid source path"); + return; + } + if (!existsSync(sourcePath)) { + sendText(res, 404, "Source file not found"); + return; + } + + const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(sourcePath).toLowerCase()); + if (!sourceFormat) { + sendJSON(res, 200, { + sourceSrc: srcFromUploadPath(sourcePath), + status: "ready" + }); + return; + } + + const manifest = await readJSONFile(manifestPathForSource(sourcePath)); + if (!manifest) { + sendJSON(res, 200, { + componentTreeRequired: true, + message: "GLB/component tree is waiting for NodeDcBimConverter.", + sourceFormat, + sourceSrc: srcFromUploadPath(sourcePath), + status: "conversion_required", + targetFormat: "glb", + updatedAt: nowIso() + }); + return; + } + + sendJSON(res, 200, manifest); +}; + const buildProjectPayload = (body) => { const now = new Date().toISOString(); const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`; @@ -518,6 +630,10 @@ const requestHandler = async (req, res) => { return handleRawUpload(req, res, url); } + if (req.method === "GET" && url.pathname === "/api/conversions/status") { + return handleConversionStatus(res, url.searchParams); + } + await serveStatic(req, res, url); };