samedche commited on
Commit
1f96d90
·
verified ·
1 Parent(s): 4e9fe8b

Add GLB optimization worker

Browse files
Files changed (1) hide show
  1. server.mjs +162 -0
server.mjs ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import http from "node:http";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import { spawn } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const PORT = Number(process.env.PORT || 7860);
9
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
10
+ const WORK = path.join(ROOT, "work");
11
+ const jobs = new Map();
12
+ const MAX_BYTES = 250 * 1024 * 1024;
13
+
14
+ await fs.mkdir(WORK, { recursive: true });
15
+
16
+ function json(res, status, body) {
17
+ const data = Buffer.from(JSON.stringify(body));
18
+ res.writeHead(status, {
19
+ "content-type": "application/json; charset=utf-8",
20
+ "content-length": data.length
21
+ });
22
+ res.end(data);
23
+ }
24
+
25
+ function readBody(req) {
26
+ return new Promise((resolve, reject) => {
27
+ const chunks = [];
28
+ let size = 0;
29
+ req.on("data", chunk => {
30
+ size += chunk.length;
31
+ if (size > 1024 * 1024) {
32
+ reject(new Error("request too large"));
33
+ req.destroy();
34
+ return;
35
+ }
36
+ chunks.push(chunk);
37
+ });
38
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
39
+ req.on("error", reject);
40
+ });
41
+ }
42
+
43
+ function safeUrl(value) {
44
+ try {
45
+ const parsed = new URL(value);
46
+ return parsed.protocol === "https:" || parsed.protocol === "http:";
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ async function download(url, target) {
53
+ const response = await fetch(url, {
54
+ signal: AbortSignal.timeout(180000),
55
+ headers: { "user-agent": "Machesta-GLB-Optimizer/1.0" }
56
+ });
57
+ if (!response.ok) throw new Error("download failed: HTTP " + response.status);
58
+ const declared = Number(response.headers.get("content-length") || 0);
59
+ if (declared > MAX_BYTES) throw new Error("input GLB exceeds size limit");
60
+ const buffer = Buffer.from(await response.arrayBuffer());
61
+ if (buffer.length > MAX_BYTES) throw new Error("input GLB exceeds size limit");
62
+ await fs.writeFile(target, buffer);
63
+ return buffer.length;
64
+ }
65
+
66
+ function runOptimize(input, output) {
67
+ return new Promise((resolve, reject) => {
68
+ const args = [
69
+ "gltf-transform", "optimize", input, output,
70
+ "--compress", "draco",
71
+ "--texture-compress", "webp",
72
+ "--texture-size", "512",
73
+ "--simplify-ratio", "0.3",
74
+ "--simplify-error", "0.001"
75
+ ];
76
+ const child = spawn("npx", ["--yes", ...args], {
77
+ cwd: ROOT,
78
+ stdio: ["ignore", "pipe", "pipe"]
79
+ });
80
+ let logs = "";
81
+ child.stdout.on("data", data => { logs += data.toString(); });
82
+ child.stderr.on("data", data => { logs += data.toString(); });
83
+ child.on("error", reject);
84
+ child.on("close", code => {
85
+ if (code === 0) resolve(logs.slice(-12000));
86
+ else reject(new Error("optimizer exited with code " + code + ": " + logs.slice(-4000)));
87
+ });
88
+ });
89
+ }
90
+
91
+ async function processJob(job) {
92
+ job.status = "processing";
93
+ const input = path.join(WORK, job.id + "-input.glb");
94
+ const output = path.join(WORK, job.id + "-mobile.glb");
95
+ try {
96
+ job.inputBytes = await download(job.url, input);
97
+ await runOptimize(input, output);
98
+ const stat = await fs.stat(output);
99
+ if (stat.size === 0) throw new Error("optimizer produced an empty file");
100
+ job.outputBytes = stat.size;
101
+ job.status = "ready";
102
+ job.downloadPath = "/download/" + job.id;
103
+ } catch (error) {
104
+ job.status = "failed";
105
+ job.error = error instanceof Error ? error.message : String(error);
106
+ } finally {
107
+ await fs.rm(input, { force: true }).catch(() => {});
108
+ if (job.status !== "ready") await fs.rm(output, { force: true }).catch(() => {});
109
+ }
110
+ }
111
+
112
+ const server = http.createServer(async (req, res) => {
113
+ const url = new URL(req.url || "/", "http://" + (req.headers.host || "localhost"));
114
+
115
+ if (req.method === "GET" && url.pathname === "/health") {
116
+ return json(res, 200, { ok: true, service: "machesta-glb-optimizer", optimizer: "gltf-transform-draco" });
117
+ }
118
+
119
+ if (req.method === "POST" && url.pathname === "/optimize") {
120
+ try {
121
+ const body = JSON.parse(await readBody(req));
122
+ if (!safeUrl(body.url)) return json(res, 400, { error: "url must be http or https" });
123
+ const id = crypto.randomUUID();
124
+ const job = { id: id, url: body.url, status: "queued", createdAt: new Date().toISOString() };
125
+ jobs.set(id, job);
126
+ processJob(job);
127
+ return json(res, 202, { jobId: id, status: job.status, statusUrl: "/jobs/" + id });
128
+ } catch (error) {
129
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
130
+ }
131
+ }
132
+
133
+ const jobMatch = url.pathname.match(/^\\/jobs\\/([a-f0-9-]+)$/);
134
+ if (req.method === "GET" && jobMatch) {
135
+ const job = jobs.get(jobMatch[1]);
136
+ return job ? json(res, 200, job) : json(res, 404, { error: "job not found" });
137
+ }
138
+
139
+ const downloadMatch = url.pathname.match(/^\\/download\\/([a-f0-9-]+)$/);
140
+ if (req.method === "GET" && downloadMatch) {
141
+ const job = jobs.get(downloadMatch[1]);
142
+ if (!job || job.status !== "ready") return json(res, 404, { error: "optimized file not ready" });
143
+ const file = path.join(WORK, job.id + "-mobile.glb");
144
+ try {
145
+ const data = await fs.readFile(file);
146
+ res.writeHead(200, {
147
+ "content-type": "model/gltf-binary",
148
+ "content-disposition": "attachment; filename=\"" + job.id + "-mobile.glb\"",
149
+ "content-length": data.length
150
+ });
151
+ return res.end(data);
152
+ } catch {
153
+ return json(res, 404, { error: "optimized file expired or unavailable" });
154
+ }
155
+ }
156
+
157
+ return json(res, 404, { error: "not found" });
158
+ });
159
+
160
+ server.listen(PORT, "0.0.0.0", () => {
161
+ console.log("Machesta GLB optimizer listening on " + PORT);
162
+ });