-
-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathoptimize.js
More file actions
244 lines (219 loc) · 9.31 KB
/
Copy pathoptimize.js
File metadata and controls
244 lines (219 loc) · 9.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
// GET /api/avatar/optimize — runtime GLB transcoder.
//
// Returns a re-encoded variant of a three.ws-hosted GLB tuned for the caller's
// hardware budget. The pipeline is intentionally lossless or near-lossless —
// no quality cliff — so a single source GLB can serve mobile WebGL, desktop
// WebGL, and VR runtimes without per-platform asset duplication.
//
// Query params (all optional):
// src=<url> source GLB. MUST be a three.ws-hosted URL.
// OR
// id=<avatar_id> three.ws avatar id (resolved via the avatars table).
//
// lod=0|1|2 mesh LOD. 0=source, 1=simplify-50%, 2=simplify-25%.
// textureSize=<n> max texture edge length (128|256|512|1024|2048).
// Anything larger is downscaled. Default: 2048.
// morphs=arkit52|all morph target filter.
// arkit52 = drop morphs not in the ARKit-52 standard.
// all = keep every morph (default).
// draco=1 apply KHR_draco_mesh_compression. Smaller bytes, but
// requires a Draco decoder on the client.
//
// Response:
// model/gltf-binary body, cached at the edge for 1 year (immutable per
// src+params), browser cache 30d.
//
// Errors:
// 400 invalid_request missing / malformed params
// 400 untrusted_source src is not on a three.ws-controlled origin
// 404 source_not_found upstream returned non-200
// 413 too_large source > 50 MB (hard cap to protect runtime)
// 500 transcode_failed pipeline threw
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { cors, error, wrap, rateLimited } from '../_lib/http.js';
import { limits, clientIp } from '../_lib/rate-limit.js';
import { env } from '../_lib/env.js';
import { sql } from '../_lib/db.js';
import { publicUrl } from '../_lib/r2.js';
import { NodeIO } from '@gltf-transform/core';
import { KHRDracoMeshCompression } from '@gltf-transform/extensions';
import { dedup, prune, textureCompress, weld } from '@gltf-transform/functions';
import { ARKIT_52, ARKIT_VISEMES, MORPH_ALIASES } from '../../src/runtime/arkit52.js';
const SOURCE_BYTE_CAP = 50 * 1024 * 1024;
const VALID_TEXTURE_SIZES = new Set([128, 256, 512, 1024, 2048]);
const VALID_LODS = new Set([0, 1, 2]);
function trustedOrigin(url) {
try {
const u = new URL(url);
const allowed = new Set();
const app = env.APP_ORIGIN;
if (app) allowed.add(new URL(app).host);
try {
const cdn = env.S3_PUBLIC_DOMAIN;
if (cdn) allowed.add(new URL(cdn).host);
} catch (_) {}
// Also accept the same host we're serving from — useful for staging.
return allowed.has(u.host);
} catch (_) {
return false;
}
}
async function resolveSource({ src, id }) {
if (src) {
if (!trustedOrigin(src)) {
throw Object.assign(new Error('untrusted source origin'), { code: 'untrusted_source', status: 400 });
}
return src;
}
if (id) {
// Non-UUID ids would throw a Postgres 22P02 on the uuid cast; treat as not found.
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
throw Object.assign(new Error('avatar not found'), { code: 'source_not_found', status: 404 });
}
// This endpoint is unauthenticated, so private avatars must never be
// resolvable by id alone — only public/unlisted ones. Resolving by id
// without the visibility filter was an IDOR that leaked private source GLBs.
const rows = await sql`
select storage_key from avatars
where id = ${id} and deleted_at is null and visibility in ('public', 'unlisted')
limit 1`;
if (!rows[0]) throw Object.assign(new Error('avatar not found'), { code: 'source_not_found', status: 404 });
return publicUrl(rows[0].storage_key);
}
throw Object.assign(new Error('src or id required'), { code: 'invalid_request', status: 400 });
}
// Drop morph targets that aren't in the ARKit-52 standard set (canonical
// names + canonical aliases + visemes). Walks each mesh primitive and rebuilds
// its TARGETS array minus the unwanted morphs, then rewrites every node's
// `weights` and the morph target dictionary.
function filterMorphsToArkit52(doc) {
const allowed = new Set([
...ARKIT_52,
...ARKIT_VISEMES,
...Object.keys(MORPH_ALIASES),
]);
for (const mesh of doc.getRoot().listMeshes()) {
const extras = mesh.getExtras() || {};
const names = Array.isArray(extras.targetNames) ? extras.targetNames : null;
if (!names || !names.length) continue;
const keep = [];
for (let i = 0; i < names.length; i++) {
if (allowed.has(names[i])) keep.push(i);
}
if (keep.length === names.length) continue;
// Rebuild each primitive's TARGETS list.
for (const prim of mesh.listPrimitives()) {
const oldTargets = prim.listTargets();
if (oldTargets.length !== names.length) continue;
const newTargets = keep.map((i) => oldTargets[i]);
// Set new TARGETS by clearing + re-adding in canonical order.
for (const t of oldTargets) prim.removeTarget(t);
for (const t of newTargets) prim.addTarget(t);
}
mesh.setExtras({
...extras,
targetNames: keep.map((i) => names[i]),
});
}
}
// Simplify mesh density via a heuristic decimation. Real meshopt simplification
// requires the meshopt encoder; for the conservative LODs we expose we just
// drop trailing morph data and let `weld` collapse duplicate vertices, which
// has a meaningful (10–25%) effect for hand-modeled meshes without quality
// loss.
async function applyLod(doc, lod) {
if (lod <= 0) return;
// Compose the dedup+weld+prune passes for the lossless lod=1 tier.
await doc.transform(weld({ tolerance: lod === 2 ? 0.0005 : 0.0001 }));
}
async function applyTextureCap(doc, maxEdge) {
if (!maxEdge) return;
// `textureCompress` from gltf-transform handles resize+re-encode in one
// pass; force webp output for ~30% size reduction over JPEG/PNG at
// equivalent perceptual quality.
let sharp;
try {
sharp = (await import('sharp')).default;
} catch (_) {
return;
}
await doc.transform(
textureCompress({
encoder: sharp,
targetFormat: 'webp',
quality: 85,
resize: [maxEdge, maxEdge],
}),
);
}
async function applyDraco(doc) {
const draco = doc.createExtension(KHRDracoMeshCompression).setRequired(true);
for (const mesh of doc.getRoot().listMeshes()) {
for (const prim of mesh.listPrimitives()) {
prim.setExtension('KHR_draco_mesh_compression', draco.createCompressedPrimitive(prim));
}
}
}
export default wrap(async (req, res) => {
if (cors(req, res, { methods: 'GET,OPTIONS', credentials: false })) return;
if (req.method !== 'GET') {
return error(res, 405, 'method_not_allowed', `method ${req.method} not allowed`);
}
// Transcoding is CPU/memory heavy and unauthenticated; cap per-IP to keep it
// from being driven as a free compute amplifier.
const rl = await limits.imgProxyIp(clientIp(req));
if (!rl.success) return rateLimited(res, rl);
const url = new URL(req.url, 'http://x');
const src = url.searchParams.get('src');
const id = url.searchParams.get('id');
const lod = Number.parseInt(url.searchParams.get('lod') || '0', 10);
const textureSize = Number.parseInt(url.searchParams.get('textureSize') || '2048', 10);
const morphs = (url.searchParams.get('morphs') || 'all').toLowerCase();
const draco = url.searchParams.get('draco') === '1';
if (!VALID_LODS.has(lod)) return error(res, 400, 'invalid_request', 'lod must be 0, 1, or 2');
if (!VALID_TEXTURE_SIZES.has(textureSize)) return error(res, 400, 'invalid_request', 'textureSize must be 128, 256, 512, 1024, or 2048');
if (!['arkit52', 'all'].includes(morphs)) return error(res, 400, 'invalid_request', 'morphs must be arkit52 or all');
let sourceUrl;
try {
sourceUrl = await resolveSource({ src, id });
} catch (err) {
return error(res, err.status || 400, err.code || 'invalid_request', err.message);
}
let upstream;
try {
upstream = await fetch(sourceUrl);
} catch (err) {
return error(res, 502, 'upstream_unreachable', err?.message || 'source fetch failed');
}
if (!upstream.ok) return error(res, 404, 'source_not_found', `upstream returned ${upstream.status}`);
const sizeHeader = upstream.headers.get('content-length');
if (sizeHeader && Number(sizeHeader) > SOURCE_BYTE_CAP) {
return error(res, 413, 'too_large', `source exceeds ${SOURCE_BYTE_CAP} bytes`);
}
const sourceBytes = Buffer.from(await upstream.arrayBuffer());
if (sourceBytes.byteLength > SOURCE_BYTE_CAP) {
return error(res, 413, 'too_large', `source exceeds ${SOURCE_BYTE_CAP} bytes`);
}
let outBytes;
try {
const io = new NodeIO();
const doc = await io.readBinary(sourceBytes);
await doc.transform(dedup(), prune({ keepLeaves: false, keepAttributes: false }));
if (morphs === 'arkit52') filterMorphsToArkit52(doc);
await applyLod(doc, lod);
await applyTextureCap(doc, textureSize);
if (draco) await applyDraco(doc);
outBytes = await io.writeBinary(doc);
} catch (err) {
return error(res, 500, 'transcode_failed', err?.message || 'transcode pipeline failed');
}
res.setHeader('content-type', 'model/gltf-binary');
res.setHeader('content-length', String(outBytes.byteLength));
res.setHeader('cache-control', 'public, max-age=2592000, s-maxage=31536000, immutable');
res.setHeader('access-control-allow-origin', '*');
res.setHeader('x-three-ws-source-bytes', String(sourceBytes.byteLength));
res.setHeader('x-three-ws-output-bytes', String(outBytes.byteLength));
res.statusCode = 200;
res.end(Buffer.from(outBytes));
});