beam: add local converter and STEP viewer flow

This commit is contained in:
CODEX 2026-06-04 19:39:22 +03:00
parent 9aaf82862f
commit 254bec97ab
10 changed files with 625 additions and 10 deletions

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
# .gitignore
node_modules
.DS_Store
__pycache__/
*.pyc
server/data/uploads/

24
converter/Dockerfile Normal file
View File

@ -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"]

View File

@ -0,0 +1 @@
cadquery==2.7.0

260
converter/worker.py Normal file
View File

@ -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()

23
docker-compose.beam.yml Normal file
View File

@ -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

View File

@ -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;

View File

@ -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", () => {

View File

@ -87,7 +87,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./dcViewer.css?v=3">
<link rel="stylesheet" href="./dcViewer.css?v=4">
</head>
<body>
<header>
@ -128,6 +128,14 @@
<div class="panel-section">
<h3>Инспектор</h3>
<div id="treeContainer" class="tree"></div>
<div class="display-mode-panel" id="displayModeControl">
<div class="display-mode-title">Режим отображения</div>
<div class="display-mode-buttons">
<button type="button" data-display-mode="source" class="active">Исходный</button>
<button type="button" data-display-mode="white">Белый</button>
<button type="button" data-display-mode="contrast">Контраст</button>
</div>
</div>
</div>
<div class="panel-section" id="projectNameSection">
<h3>Название проекта</h3>
@ -223,6 +231,6 @@
<input id="fileInput" class="hidden" type="file"
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
<script type="module" src="./dcViewer.js"></script>
<script type="module" src="./dcViewer.js?v=4"></script>
</body>
</html>

View File

@ -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=<name>&projectId=<id>` — сохранить оригинальный файл модели в `server/data/uploads`.
- `GET /api/conversions/status?src=<uploads/...>` — получить статус подготовки 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`, но оставлено для будущих пользователей/шаринга.

View File

@ -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);
};