Spaces:
Running
Running
| import http from "node:http"; | |
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import crypto from "node:crypto"; | |
| import { spawn } from "node:child_process"; | |
| import { fileURLToPath } from "node:url"; | |
| const PORT = Number(process.env.PORT || 7860); | |
| const ROOT = path.dirname(fileURLToPath(import.meta.url)); | |
| const WORK = path.join(ROOT, "work"); | |
| const jobs = new Map(); | |
| const MAX_BYTES = 250 * 1024 * 1024; | |
| const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID || ""; | |
| const R2_BUCKET = process.env.R2_BUCKET || ""; | |
| const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID || ""; | |
| const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY || ""; | |
| const R2_PUBLIC_BASE_URL = process.env.R2_PUBLIC_BASE_URL || ""; | |
| const R2_REGION = "auto"; | |
| const R2_SERVICE = "s3"; | |
| function r2Configured() { | |
| return Boolean(R2_ACCOUNT_ID && R2_BUCKET && R2_ACCESS_KEY_ID && R2_SECRET_ACCESS_KEY && R2_PUBLIC_BASE_URL); | |
| } | |
| function encodeRfc3986(value) { | |
| return encodeURIComponent(value).replace(/[!'()*]/g, char => "%" + char.charCodeAt(0).toString(16).toUpperCase()); | |
| } | |
| function encodePath(value) { | |
| return value.split("/").map(encodeRfc3986).join("/"); | |
| } | |
| function canonicalQueryString(params) { | |
| return Object.keys(params).sort().map(key => encodeRfc3986(key) + "=" + encodeRfc3986(params[key])).join("&"); | |
| } | |
| function sha256Hex(value) { | |
| return crypto.createHash("sha256").update(value, "utf8").digest("hex"); | |
| } | |
| function hmac(key, value) { | |
| return crypto.createHmac("sha256", key).update(value, "utf8").digest(); | |
| } | |
| function hmacHex(key, value) { | |
| return crypto.createHmac("sha256", key).update(value, "utf8").digest("hex"); | |
| } | |
| function getSignatureKey(secret, dateStamp) { | |
| const kDate = hmac("AWS4" + secret, dateStamp); | |
| const kRegion = hmac(kDate, R2_REGION); | |
| const kService = hmac(kRegion, R2_SERVICE); | |
| return hmac(kService, "aws4_request"); | |
| } | |
| function toAmzDate(date) { | |
| return date.toISOString().replace(/[:-]|\.\d{3}/g, "").slice(0, 15) + "Z"; | |
| } | |
| function buildR2PresignedPutUrl(key, contentType) { | |
| if (!r2Configured()) throw new Error("R2 secrets are not configured"); | |
| const now = new Date(); | |
| const host = R2_ACCOUNT_ID + ".r2.cloudflarestorage.com"; | |
| const amzDate = toAmzDate(now); | |
| const dateStamp = amzDate.slice(0, 8); | |
| const credentialScope = dateStamp + "/" + R2_REGION + "/" + R2_SERVICE + "/aws4_request"; | |
| const canonicalUri = "/" + encodePath(R2_BUCKET) + "/" + encodePath(key); | |
| const signedHeaders = "content-type;host"; | |
| const query = { | |
| "X-Amz-Algorithm": "AWS4-HMAC-SHA256", | |
| "X-Amz-Credential": R2_ACCESS_KEY_ID + "/" + credentialScope, | |
| "X-Amz-Date": amzDate, | |
| "X-Amz-Expires": "600", | |
| "X-Amz-SignedHeaders": signedHeaders | |
| }; | |
| const canonicalQuery = canonicalQueryString(query); | |
| const canonicalHeaders = "content-type:" + contentType.trim().replace(/\s+/g, " ") + "\n" + "host:" + host + "\n"; | |
| const canonicalRequest = ["PUT", canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, "UNSIGNED-PAYLOAD"].join("\n"); | |
| const stringToSign = ["AWS4-HMAC-SHA256", amzDate, credentialScope, sha256Hex(canonicalRequest)].join("\n"); | |
| const signature = hmacHex(getSignatureKey(R2_SECRET_ACCESS_KEY, dateStamp), stringToSign); | |
| return "https://" + host + canonicalUri + "?" + canonicalQuery + "&X-Amz-Signature=" + signature; | |
| } | |
| function r2PublicUrl(key) { | |
| return R2_PUBLIC_BASE_URL.replace(/\/+$/, "") + "/" + encodePath(key); | |
| } | |
| async function uploadToR2(file, key) { | |
| const contentType = "model/gltf-binary"; | |
| const data = await fs.readFile(file); | |
| const uploadUrl = buildR2PresignedPutUrl(key, contentType); | |
| const response = await fetch(uploadUrl, { | |
| method: "PUT", | |
| headers: { "content-type": contentType }, | |
| body: data, | |
| signal: AbortSignal.timeout(180000) | |
| }); | |
| if (!response.ok) throw new Error("R2 upload failed: HTTP " + response.status + " " + (await response.text()).slice(0, 1000)); | |
| return {key, url: r2PublicUrl(key), bytes: data.length}; | |
| } | |
| await fs.mkdir(WORK, { recursive: true }); | |
| function json(res, status, body) { | |
| const data = Buffer.from(JSON.stringify(body)); | |
| res.writeHead(status, { | |
| "content-type": "application/json; charset=utf-8", | |
| "content-length": data.length | |
| }); | |
| res.end(data); | |
| } | |
| function readBody(req) { | |
| return new Promise((resolve, reject) => { | |
| const chunks = []; | |
| let size = 0; | |
| req.on("data", chunk => { | |
| size += chunk.length; | |
| if (size > 1024 * 1024) { | |
| reject(new Error("request too large")); | |
| req.destroy(); | |
| return; | |
| } | |
| chunks.push(chunk); | |
| }); | |
| req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); | |
| req.on("error", reject); | |
| }); | |
| } | |
| function safeUrl(value) { | |
| try { | |
| const parsed = new URL(value); | |
| return parsed.protocol === "https:" || parsed.protocol === "http:"; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async function download(url, target) { | |
| const response = await fetch(url, { | |
| signal: AbortSignal.timeout(180000), | |
| headers: { "user-agent": "Machesta-GLB-Optimizer/1.0" } | |
| }); | |
| if (!response.ok) throw new Error("download failed: HTTP " + response.status); | |
| const declared = Number(response.headers.get("content-length") || 0); | |
| if (declared > MAX_BYTES) throw new Error("input GLB exceeds size limit"); | |
| const buffer = Buffer.from(await response.arrayBuffer()); | |
| if (buffer.length > MAX_BYTES) throw new Error("input GLB exceeds size limit"); | |
| await fs.writeFile(target, buffer); | |
| return buffer.length; | |
| } | |
| function runOptimize(input, output) { | |
| return new Promise((resolve, reject) => { | |
| const args = [ | |
| "gltf-transform", "optimize", input, output, | |
| "--compress", "draco", | |
| "--texture-compress", "webp", | |
| "--texture-size", "512", | |
| "--simplify-ratio", "0.3", | |
| "--simplify-error", "0.001" | |
| ]; | |
| const child = spawn("npx", ["--yes", ...args], { | |
| cwd: ROOT, | |
| stdio: ["ignore", "pipe", "pipe"] | |
| }); | |
| let logs = ""; | |
| child.stdout.on("data", data => { logs += data.toString(); }); | |
| child.stderr.on("data", data => { logs += data.toString(); }); | |
| child.on("error", reject); | |
| child.on("close", code => { | |
| if (code === 0) resolve(logs.slice(-12000)); | |
| else reject(new Error("optimizer exited with code " + code + ": " + logs.slice(-4000))); | |
| }); | |
| }); | |
| } | |
| async function processJob(job) { | |
| job.status = "processing"; | |
| const input = path.join(WORK, job.id + "-input.glb"); | |
| const output = path.join(WORK, job.id + "-mobile.glb"); | |
| try { | |
| job.inputBytes = await download(job.url, input); | |
| await runOptimize(input, output); | |
| const stat = await fs.stat(output); | |
| if (stat.size === 0) throw new Error("optimizer produced an empty file"); | |
| job.outputBytes = stat.size; | |
| const r2Key = "optimized-glb/" + job.id + ".glb"; | |
| const uploaded = await uploadToR2(output, r2Key); | |
| job.r2Key = uploaded.key; | |
| job.r2Url = uploaded.url; | |
| job.status = "ready"; | |
| job.downloadPath = uploaded.url; | |
| } catch (error) { | |
| job.status = "failed"; | |
| job.error = error instanceof Error ? error.message : String(error); | |
| } finally { | |
| await fs.rm(input, { force: true }).catch(() => {}); | |
| if (job.status !== "ready") await fs.rm(output, { force: true }).catch(() => {}); | |
| } | |
| } | |
| const server = http.createServer(async (req, res) => { | |
| const url = new URL(req.url || "/", "http://" + (req.headers.host || "localhost")); | |
| if (req.method === "GET" && url.pathname === "/health") { | |
| return json(res, 200, { ok: true, service: "machesta-glb-optimizer", optimizer: "gltf-transform-draco", r2Configured: r2Configured() }); | |
| } | |
| if (req.method === "POST" && url.pathname === "/optimize") { | |
| try { | |
| const body = JSON.parse(await readBody(req)); | |
| if (!safeUrl(body.url)) return json(res, 400, { error: "url must be http or https" }); | |
| const id = crypto.randomUUID(); | |
| const job = { id: id, url: body.url, status: "queued", createdAt: new Date().toISOString() }; | |
| jobs.set(id, job); | |
| processJob(job); | |
| return json(res, 202, { jobId: id, status: job.status, statusUrl: "/jobs/" + id }); | |
| } catch (error) { | |
| return json(res, 400, { error: error instanceof Error ? error.message : String(error) }); | |
| } | |
| } | |
| const jobMatch = url.pathname.match(/^\/jobs\/([a-f0-9-]+)$/); | |
| if (req.method === "GET" && jobMatch) { | |
| const job = jobs.get(jobMatch[1]); | |
| return job ? json(res, 200, job) : json(res, 404, { error: "job not found" }); | |
| } | |
| const downloadMatch = url.pathname.match(/^\/download\/([a-f0-9-]+)$/); | |
| if (req.method === "GET" && downloadMatch) { | |
| const job = jobs.get(downloadMatch[1]); | |
| if (!job || job.status !== "ready") return json(res, 404, { error: "optimized file not ready" }); | |
| const file = path.join(WORK, job.id + "-mobile.glb"); | |
| try { | |
| const data = await fs.readFile(file); | |
| res.writeHead(200, { | |
| "content-type": "model/gltf-binary", | |
| "content-disposition": "attachment; filename=\"" + job.id + "-mobile.glb\"", | |
| "content-length": data.length | |
| }); | |
| return res.end(data); | |
| } catch { | |
| return json(res, 404, { error: "optimized file expired or unavailable" }); | |
| } | |
| } | |
| return json(res, 404, { error: "not found" }); | |
| }); | |
| server.listen(PORT, "0.0.0.0", () => { | |
| console.log("Machesta GLB optimizer listening on " + PORT); | |
| }); | |