Created
May 5, 2026 11:34
-
-
Save kirakira-dev/9342dafd801b0709c6c33e83979cd897 to your computer and use it in GitHub Desktop.
A tampermonkey plugin to download models from Meshy for free
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name meshy model dwonloader | |
| // @namespace https://kirakira.cloud | |
| // @version 1.0.0 | |
| // @description meshy model downloader, bypass paywall, made by the poor for the poor | |
| // @author kirakira | |
| // @match https://www.meshy.ai/* | |
| // @run-at document-start | |
| // @grant none | |
| // ==/UserScript== | |
| // this plugin was last tested on 2026 May 5, worked completly fine | |
| // note that some formats are buggy | |
| // i recommend obj | |
| (function () { | |
| 'use strict'; | |
| if (window.__meshyEasyDownloadTMInstalled) return; | |
| window.__meshyEasyDownloadTMInstalled = true; | |
| pageScript(); | |
| function pageScript() { | |
| 'use strict'; | |
| const DEBUG_MODE = true; | |
| const MODEL_CACHE_KEY = 'meshy_easy_download_models_v3'; | |
| const MAX_MODEL_CACHE = 100; | |
| const capturedGlbByTask = new Map(); | |
| const capturedGlbByUrl = new Map(); | |
| const modelUrlByTask = new Map(); | |
| const captureSignatures = new Set(); | |
| let lastCapturedGlb = null; | |
| let lastCapturedTaskId = null; | |
| let lastSeenTaskId = null; | |
| const CHUNK_TYPE_JSON = 0x4e4f534a; | |
| const CHUNK_TYPE_BIN = 0x004e4942; | |
| const GLB_MAGIC = 0x46546c67; | |
| const COMPONENT_TYPE_BYTES = { | |
| 5120: 1, | |
| 5121: 1, | |
| 5122: 2, | |
| 5123: 2, | |
| 5125: 4, | |
| 5126: 4 | |
| }; | |
| const TYPE_COMPONENTS = { | |
| SCALAR: 1, | |
| VEC2: 2, | |
| VEC3: 3, | |
| VEC4: 4, | |
| MAT2: 4, | |
| MAT3: 9, | |
| MAT4: 16 | |
| }; | |
| const ARRAY_CTORS = { | |
| 5120: Int8Array, | |
| 5121: Uint8Array, | |
| 5122: Int16Array, | |
| 5123: Uint16Array, | |
| 5125: Uint32Array, | |
| 5126: Float32Array | |
| }; | |
| function log(...args) { | |
| if (DEBUG_MODE) { | |
| console.log('[meshy-tm]', ...args); | |
| } | |
| } | |
| function publishDebugState() { | |
| try { | |
| window.__meshyEasyDownloadState = { | |
| lastSeenTaskId, | |
| lastCapturedTaskId, | |
| capturedTaskCount: capturedGlbByTask.size, | |
| capturedModelUrlCount: capturedGlbByUrl.size, | |
| lastCapturedSize: lastCapturedGlb ? lastCapturedGlb.byteLength : 0 | |
| }; | |
| } catch {} | |
| } | |
| function slugify(text) { | |
| return (text || 'model') | |
| .replace(/[^a-zA-Z0-9\s]/g, '') | |
| .replace(/\s+/g, '-') | |
| .replace(/^-+|-+$/g, '') | |
| .substring(0, 60) || 'model'; | |
| } | |
| function formatSize(bytes) { | |
| if (!bytes || bytes <= 0) return ''; | |
| if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; | |
| if (bytes >= 1024) return `${Math.round(bytes / 1024)}KB`; | |
| return `${bytes}B`; | |
| } | |
| function extractTaskIdFromUrl(path = window.location.pathname) { | |
| const match = path.match(/\/workspace\/([a-f0-9-]+)/i); | |
| return match ? match[1] : null; | |
| } | |
| function currentTaskId() { | |
| return extractTaskIdFromUrl(); | |
| } | |
| function normalizeTaskId(taskId) { | |
| return taskId || currentTaskId() || lastSeenTaskId || null; | |
| } | |
| function canonicalModelUrl(url) { | |
| if (!url) return ''; | |
| try { | |
| const parsed = new URL(url, window.location.origin); | |
| return `${parsed.origin}${parsed.pathname}`; | |
| } catch { | |
| return url; | |
| } | |
| } | |
| function loadModels() { | |
| try { | |
| const raw = localStorage.getItem(MODEL_CACHE_KEY); | |
| if (!raw) return {}; | |
| const parsed = JSON.parse(raw); | |
| return parsed && typeof parsed === 'object' ? parsed : {}; | |
| } catch (error) { | |
| log('model cache parse failed:', error.message); | |
| return {}; | |
| } | |
| } | |
| function saveModels(models) { | |
| try { | |
| localStorage.setItem(MODEL_CACHE_KEY, JSON.stringify(models)); | |
| } catch (error) { | |
| log('model cache save failed:', error.message); | |
| } | |
| } | |
| function getModel(taskId) { | |
| if (!taskId) return null; | |
| const models = loadModels(); | |
| return models[taskId] || null; | |
| } | |
| function saveModel(taskId, model) { | |
| if (!taskId || !model) return; | |
| const models = loadModels(); | |
| models[taskId] = { ...model, ts: Date.now() }; | |
| const keys = Object.keys(models); | |
| if (keys.length > MAX_MODEL_CACHE) { | |
| keys | |
| .sort((a, b) => (models[a]?.ts || 0) - (models[b]?.ts || 0)) | |
| .slice(0, keys.length - MAX_MODEL_CACHE) | |
| .forEach((key) => delete models[key]); | |
| } | |
| saveModels(models); | |
| } | |
| async function fetchSizeText(url) { | |
| if (!url) return ''; | |
| try { | |
| const res = await fetch(url, { method: 'HEAD' }); | |
| const size = Number.parseInt(res.headers.get('content-length') || '0', 10); | |
| return formatSize(size); | |
| } catch { | |
| return ''; | |
| } | |
| } | |
| function isGLBBuffer(buffer) { | |
| if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 12) return false; | |
| try { | |
| const view = new DataView(buffer); | |
| return view.getUint32(0, true) === GLB_MAGIC; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function isMeshyEncryptedBuffer(buffer) { | |
| if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 8) return false; | |
| try { | |
| const magic = new TextDecoder().decode(new Uint8Array(buffer, 0, 8)); | |
| return magic.startsWith('MESHY.AI'); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function glbSignature(buffer) { | |
| const head = Array.from(new Uint8Array(buffer, 0, Math.min(16, buffer.byteLength))); | |
| return `${buffer.byteLength}:${head.join(',')}`; | |
| } | |
| function arrayBufferFromUnknown(value) { | |
| if (value instanceof ArrayBuffer) { | |
| return value.slice(0); | |
| } | |
| if (ArrayBuffer.isView(value)) { | |
| return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); | |
| } | |
| return null; | |
| } | |
| function cacheGlbBuffer(taskId, name, buffer, source) { | |
| if (!isGLBBuffer(buffer)) return; | |
| const normalizedTaskId = normalizeTaskId(taskId); | |
| const signature = glbSignature(buffer); | |
| if (captureSignatures.has(signature)) return; | |
| captureSignatures.add(signature); | |
| const cloned = buffer.slice(0); | |
| lastCapturedGlb = cloned; | |
| lastCapturedTaskId = normalizedTaskId; | |
| if (normalizedTaskId) { | |
| capturedGlbByTask.set(normalizedTaskId, cloned); | |
| } | |
| const knownUrl = normalizedTaskId ? modelUrlByTask.get(normalizedTaskId) : ''; | |
| if (knownUrl) { | |
| capturedGlbByUrl.set(canonicalModelUrl(knownUrl), cloned); | |
| } | |
| log( | |
| `captured decrypted GLB from ${source}`, | |
| normalizedTaskId ? `(task ${normalizedTaskId})` : '(task unknown)', | |
| formatSize(cloned.byteLength) | |
| ); | |
| if (normalizedTaskId) { | |
| const existing = getModel(normalizedTaskId) || {}; | |
| saveModel(normalizedTaskId, { | |
| url: existing.url || '', | |
| name: name || existing.name || 'model', | |
| size: existing.size || formatSize(cloned.byteLength) | |
| }); | |
| injectButtons(normalizedTaskId, getModel(normalizedTaskId)); | |
| } else { | |
| const activeTask = currentTaskId(); | |
| if (activeTask) { | |
| injectButtons(activeTask, getModel(activeTask)); | |
| } | |
| } | |
| publishDebugState(); | |
| } | |
| function downloadBuffer(buffer, filename, mimeType = 'application/octet-stream') { | |
| const blob = new Blob([buffer], { type: mimeType }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| } | |
| function downloadText(text, filename) { | |
| downloadBuffer(new TextEncoder().encode(text), filename, 'text/plain;charset=utf-8'); | |
| } | |
| function readComponent(view, offset, componentType) { | |
| switch (componentType) { | |
| case 5120: return view.getInt8(offset); | |
| case 5121: return view.getUint8(offset); | |
| case 5122: return view.getInt16(offset, true); | |
| case 5123: return view.getUint16(offset, true); | |
| case 5125: return view.getUint32(offset, true); | |
| case 5126: return view.getFloat32(offset, true); | |
| default: throw new Error(`unsupported component type ${componentType}`); | |
| } | |
| } | |
| function parseGLB(buffer) { | |
| const view = new DataView(buffer); | |
| if (view.byteLength < 20) throw new Error('invalid GLB: too small'); | |
| const magic = view.getUint32(0, true); | |
| if (magic !== GLB_MAGIC) throw new Error('invalid GLB: bad magic'); | |
| const version = view.getUint32(4, true); | |
| if (version !== 2) throw new Error(`unsupported GLB version ${version}`); | |
| const declaredLength = view.getUint32(8, true); | |
| if (declaredLength > buffer.byteLength) throw new Error('invalid GLB: truncated'); | |
| let offset = 12; | |
| let json = null; | |
| let bin = null; | |
| while (offset + 8 <= declaredLength) { | |
| const chunkLength = view.getUint32(offset, true); | |
| const chunkType = view.getUint32(offset + 4, true); | |
| offset += 8; | |
| if (offset + chunkLength > buffer.byteLength) { | |
| throw new Error('invalid GLB: bad chunk size'); | |
| } | |
| if (chunkType === CHUNK_TYPE_JSON) { | |
| const jsonBytes = new Uint8Array(buffer, offset, chunkLength); | |
| const jsonText = new TextDecoder().decode(jsonBytes).replace(/\u0000+$/g, ''); | |
| json = JSON.parse(jsonText); | |
| } else if (chunkType === CHUNK_TYPE_BIN) { | |
| bin = new Uint8Array(buffer, offset, chunkLength); | |
| } | |
| offset += chunkLength; | |
| } | |
| if (!json) throw new Error('invalid GLB: missing JSON chunk'); | |
| if (!bin) bin = new Uint8Array(); | |
| return { json, bin }; | |
| } | |
| function readAccessor(json, bin, accessorIndex) { | |
| const accessor = json.accessors?.[accessorIndex]; | |
| if (!accessor) throw new Error(`missing accessor ${accessorIndex}`); | |
| if (accessor.sparse) throw new Error('sparse accessors are not supported'); | |
| const bufferView = json.bufferViews?.[accessor.bufferView]; | |
| if (!bufferView) throw new Error(`missing bufferView for accessor ${accessorIndex}`); | |
| if ((bufferView.buffer || 0) !== 0) throw new Error('external buffers are not supported'); | |
| const componentType = accessor.componentType; | |
| const componentBytes = COMPONENT_TYPE_BYTES[componentType]; | |
| const components = TYPE_COMPONENTS[accessor.type]; | |
| const ctor = ARRAY_CTORS[componentType]; | |
| if (!componentBytes || !components || !ctor) { | |
| throw new Error(`unsupported accessor format (${componentType}, ${accessor.type})`); | |
| } | |
| const count = accessor.count; | |
| const packedStride = componentBytes * components; | |
| const byteStride = bufferView.byteStride || packedStride; | |
| const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0); | |
| const out = new ctor(count * components); | |
| if (byteStride === packedStride) { | |
| const src = new ctor(bin.buffer, bin.byteOffset + byteOffset, count * components); | |
| out.set(src); | |
| return out; | |
| } | |
| const dv = new DataView(bin.buffer, bin.byteOffset + byteOffset, count * byteStride); | |
| for (let i = 0; i < count; i++) { | |
| const rowOffset = i * byteStride; | |
| for (let c = 0; c < components; c++) { | |
| out[i * components + c] = readComponent(dv, rowOffset + c * componentBytes, componentType); | |
| } | |
| } | |
| return out; | |
| } | |
| function triangulate(indices, mode) { | |
| const src = Array.from(indices); | |
| if (mode === 4 || mode === undefined) { | |
| if (src.length % 3 !== 0) throw new Error('triangle index count is not divisible by 3'); | |
| return src; | |
| } | |
| const out = []; | |
| if (mode === 5) { | |
| for (let i = 0; i < src.length - 2; i++) { | |
| const a = src[i]; | |
| const b = src[i + 1]; | |
| const c = src[i + 2]; | |
| if (a === b || b === c || a === c) continue; | |
| if (i % 2 === 0) out.push(a, b, c); | |
| else out.push(b, a, c); | |
| } | |
| return out; | |
| } | |
| if (mode === 6) { | |
| for (let i = 1; i < src.length - 1; i++) { | |
| const a = src[0]; | |
| const b = src[i]; | |
| const c = src[i + 1]; | |
| if (a === b || b === c || a === c) continue; | |
| out.push(a, b, c); | |
| } | |
| return out; | |
| } | |
| throw new Error(`unsupported primitive mode ${mode}`); | |
| } | |
| function primitiveIndices(json, bin, primitive, vertexCount) { | |
| if (primitive.indices === undefined) { | |
| return Array.from({ length: vertexCount }, (_, i) => i); | |
| } | |
| const idx = readAccessor(json, bin, primitive.indices); | |
| return Array.from(idx, (n) => Number(n)); | |
| } | |
| function collectMeshPrimitives(json, bin) { | |
| const out = []; | |
| for (const mesh of json.meshes || []) { | |
| for (const primitive of mesh.primitives || []) { | |
| const positionAccessor = primitive.attributes?.POSITION; | |
| if (positionAccessor === undefined) continue; | |
| const positions = readAccessor(json, bin, positionAccessor); | |
| const vertexCount = json.accessors[positionAccessor].count; | |
| let normals = null; | |
| if (primitive.attributes?.NORMAL !== undefined) { | |
| normals = readAccessor(json, bin, primitive.attributes.NORMAL); | |
| if (normals.length / 3 !== vertexCount) normals = null; | |
| } | |
| let uvs = null; | |
| if (primitive.attributes?.TEXCOORD_0 !== undefined) { | |
| uvs = readAccessor(json, bin, primitive.attributes.TEXCOORD_0); | |
| if (uvs.length / 2 !== vertexCount) uvs = null; | |
| } | |
| const mode = primitive.mode === undefined ? 4 : primitive.mode; | |
| const idx = primitiveIndices(json, bin, primitive, vertexCount); | |
| const triangles = triangulate(idx, mode); | |
| out.push({ positions, normals, uvs, triangles, vertexCount }); | |
| } | |
| } | |
| if (out.length === 0) throw new Error('no mesh primitives found'); | |
| return out; | |
| } | |
| function calculateNormal(v0, v1, v2) { | |
| const ax = v1[0] - v0[0]; | |
| const ay = v1[1] - v0[1]; | |
| const az = v1[2] - v0[2]; | |
| const bx = v2[0] - v0[0]; | |
| const by = v2[1] - v0[1]; | |
| const bz = v2[2] - v0[2]; | |
| const nx = ay * bz - az * by; | |
| const ny = az * bx - ax * bz; | |
| const nz = ax * by - ay * bx; | |
| const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1; | |
| return [nx / len, ny / len, nz / len]; | |
| } | |
| function glbToStlBuffer(buffer) { | |
| const { json, bin } = parseGLB(buffer); | |
| const primitives = collectMeshPrimitives(json, bin); | |
| let triangleCount = 0; | |
| for (const prim of primitives) { | |
| triangleCount += prim.triangles.length / 3; | |
| } | |
| const out = new ArrayBuffer(84 + triangleCount * 50); | |
| const view = new DataView(out); | |
| const header = new Uint8Array(out, 0, 80); | |
| const headerText = 'binary stl - meshy easy download (tampermonkey)'; | |
| for (let i = 0; i < headerText.length && i < 80; i++) { | |
| header[i] = headerText.charCodeAt(i); | |
| } | |
| view.setUint32(80, triangleCount, true); | |
| let offset = 84; | |
| for (const prim of primitives) { | |
| const pos = prim.positions; | |
| for (let i = 0; i < prim.triangles.length; i += 3) { | |
| const a = prim.triangles[i] * 3; | |
| const b = prim.triangles[i + 1] * 3; | |
| const c = prim.triangles[i + 2] * 3; | |
| const v0 = [pos[a], pos[a + 1], pos[a + 2]]; | |
| const v1 = [pos[b], pos[b + 1], pos[b + 2]]; | |
| const v2 = [pos[c], pos[c + 1], pos[c + 2]]; | |
| const normal = calculateNormal(v0, v1, v2); | |
| view.setFloat32(offset, normal[0], true); offset += 4; | |
| view.setFloat32(offset, normal[1], true); offset += 4; | |
| view.setFloat32(offset, normal[2], true); offset += 4; | |
| for (const v of [v0, v1, v2]) { | |
| view.setFloat32(offset, v[0], true); offset += 4; | |
| view.setFloat32(offset, v[1], true); offset += 4; | |
| view.setFloat32(offset, v[2], true); offset += 4; | |
| } | |
| view.setUint16(offset, 0, true); offset += 2; | |
| } | |
| } | |
| return out; | |
| } | |
| function glbToObjText(buffer, objectName) { | |
| const { json, bin } = parseGLB(buffer); | |
| const primitives = collectMeshPrimitives(json, bin); | |
| const lines = []; | |
| lines.push('# generated by Meshy.ai Easy Download (Tampermonkey)'); | |
| lines.push(`o ${slugify(objectName || 'model')}`); | |
| let vertexOffset = 0; | |
| let uvOffset = 0; | |
| let normalOffset = 0; | |
| for (const prim of primitives) { | |
| const positions = prim.positions; | |
| const uvs = prim.uvs; | |
| const normals = prim.normals; | |
| const hasUV = Boolean(uvs); | |
| const hasNormal = Boolean(normals); | |
| for (let i = 0; i < prim.vertexCount; i++) { | |
| const p = i * 3; | |
| lines.push(`v ${positions[p]} ${positions[p + 1]} ${positions[p + 2]}`); | |
| } | |
| if (hasUV) { | |
| for (let i = 0; i < prim.vertexCount; i++) { | |
| const t = i * 2; | |
| lines.push(`vt ${uvs[t]} ${1 - uvs[t + 1]}`); | |
| } | |
| } | |
| if (hasNormal) { | |
| for (let i = 0; i < prim.vertexCount; i++) { | |
| const n = i * 3; | |
| lines.push(`vn ${normals[n]} ${normals[n + 1]} ${normals[n + 2]}`); | |
| } | |
| } | |
| for (let i = 0; i < prim.triangles.length; i += 3) { | |
| const a = prim.triangles[i] + 1; | |
| const b = prim.triangles[i + 1] + 1; | |
| const c = prim.triangles[i + 2] + 1; | |
| const va = vertexOffset + a; | |
| const vb = vertexOffset + b; | |
| const vc = vertexOffset + c; | |
| if (hasUV && hasNormal) { | |
| const ta = uvOffset + a; | |
| const tb = uvOffset + b; | |
| const tc = uvOffset + c; | |
| const na = normalOffset + a; | |
| const nb = normalOffset + b; | |
| const nc = normalOffset + c; | |
| lines.push(`f ${va}/${ta}/${na} ${vb}/${tb}/${nb} ${vc}/${tc}/${nc}`); | |
| } else if (hasUV) { | |
| const ta = uvOffset + a; | |
| const tb = uvOffset + b; | |
| const tc = uvOffset + c; | |
| lines.push(`f ${va}/${ta} ${vb}/${tb} ${vc}/${tc}`); | |
| } else if (hasNormal) { | |
| const na = normalOffset + a; | |
| const nb = normalOffset + b; | |
| const nc = normalOffset + c; | |
| lines.push(`f ${va}//${na} ${vb}//${nb} ${vc}//${nc}`); | |
| } else { | |
| lines.push(`f ${va} ${vb} ${vc}`); | |
| } | |
| } | |
| vertexOffset += prim.vertexCount; | |
| if (hasUV) uvOffset += prim.vertexCount; | |
| if (hasNormal) normalOffset += prim.vertexCount; | |
| } | |
| lines.push(''); | |
| return lines.join('\n'); | |
| } | |
| function cloneIfGlb(buffer) { | |
| if (buffer && isGLBBuffer(buffer)) { | |
| return buffer.slice(0); | |
| } | |
| return null; | |
| } | |
| function fallbackCapturedGlb(taskId, modelUrl) { | |
| const candidates = []; | |
| const normalizedTaskId = normalizeTaskId(taskId); | |
| if (taskId) candidates.push(capturedGlbByTask.get(taskId)); | |
| if (normalizedTaskId && normalizedTaskId !== taskId) { | |
| candidates.push(capturedGlbByTask.get(normalizedTaskId)); | |
| } | |
| if (modelUrl) { | |
| candidates.push(capturedGlbByUrl.get(canonicalModelUrl(modelUrl))); | |
| } | |
| if (lastCapturedTaskId && normalizedTaskId && lastCapturedTaskId === normalizedTaskId) { | |
| candidates.push(lastCapturedGlb); | |
| } | |
| candidates.push(lastCapturedGlb); | |
| if (capturedGlbByTask.size === 1) { | |
| candidates.push(capturedGlbByTask.values().next().value); | |
| } | |
| for (const buffer of candidates) { | |
| const cloned = cloneIfGlb(buffer); | |
| if (cloned) return cloned; | |
| } | |
| return null; | |
| } | |
| async function resolveGLBBuffer(taskId, modelUrl) { | |
| const fallback = fallbackCapturedGlb(taskId, modelUrl); | |
| if (fallback) return fallback; | |
| if (!modelUrl) { | |
| throw new Error('no model URL found yet - open the model once, then retry'); | |
| } | |
| const res = await fetch(modelUrl); | |
| const buffer = await res.arrayBuffer(); | |
| if (isGLBBuffer(buffer)) { | |
| return buffer; | |
| } | |
| if (isMeshyEncryptedBuffer(buffer)) { | |
| throw new Error('model URL is encrypted and no decrypted buffer was captured yet. Open the model preview, wait 2-3 seconds, then retry.'); | |
| } | |
| throw new Error('downloaded data is not a valid GLB'); | |
| } | |
| async function downloadAs(format, taskId, model, filename) { | |
| try { | |
| const glbBuffer = await resolveGLBBuffer(taskId, model?.url); | |
| if (format === 'glb') { | |
| downloadBuffer(glbBuffer, filename, 'model/gltf-binary'); | |
| return; | |
| } | |
| if (format === 'stl') { | |
| const stlBuffer = glbToStlBuffer(glbBuffer); | |
| downloadBuffer(stlBuffer, filename, 'model/stl'); | |
| return; | |
| } | |
| if (format === 'obj') { | |
| const objText = glbToObjText(glbBuffer, model?.name || 'model'); | |
| downloadText(objText, filename); | |
| return; | |
| } | |
| } catch (error) { | |
| log(`${format} export failed:`, error.message); | |
| alert(`${format.toUpperCase()} export failed: ${error.message}`); | |
| } | |
| } | |
| function findDownloadButton() { | |
| const path = document.querySelector('path[d^="M4.933 6.272h1.06V2.938"]'); | |
| return path ? path.closest('button') : null; | |
| } | |
| function buttonColors(format) { | |
| if (format === 'stl') return ['#ec4899', '#f472b6']; | |
| if (format === 'obj') return ['#0ea5e9', '#38bdf8']; | |
| return ['#8b5cf6', '#a855f7']; | |
| } | |
| function createButton(format, taskId, model) { | |
| const [from, to] = buttonColors(format); | |
| const safeName = slugify(model?.name || 'model'); | |
| const filename = `${safeName}_${taskId}.${format}`; | |
| const extraSize = format === 'glb' && model?.size ? ` ${model.size}` : ''; | |
| const anchor = document.createElement('a'); | |
| anchor.href = '#'; | |
| anchor.className = 'meshy-tm-btn'; | |
| anchor.dataset.taskId = taskId; | |
| anchor.dataset.format = format; | |
| anchor.style.marginLeft = '8px'; | |
| anchor.style.textDecoration = 'none'; | |
| anchor.innerHTML = ` | |
| <button type="button" style="background: linear-gradient(135deg, ${from} 0%, ${to} 100%); border: none;" | |
| class="group/button inline-flex items-center justify-center font-medium whitespace-nowrap transition duration-100 ease-out select-none px-3 h-7 rounded-lg gap-1.5 text-xs active:opacity-70 text-white hover:opacity-90 cursor-pointer"> | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M4.933 6.272h1.06V2.938c0-.366.3-.666.667-.666h2.667c.366 0 .666.3.666.666v3.334h1.06c.594 0 .894.72.474 1.14l-3.06 3.06a.665.665 0 0 1-.94 0l-3.06-3.06c-.42-.42-.127-1.14.466-1.14Zm-1.6 6.395c0 .366.3.666.667.666h8c.367 0 .667-.3.667-.666 0-.367-.3-.667-.667-.667H4c-.367 0-.667.3-.667.667Z" fill="white"/> | |
| </svg> | |
| <span>.${format}${extraSize}</span> | |
| </button> | |
| `; | |
| anchor.onclick = (event) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| downloadAs(format, taskId, model, filename); | |
| }; | |
| return anchor; | |
| } | |
| function injectButtons(taskId, model, retries = 6) { | |
| const downloadButton = findDownloadButton(); | |
| if (!downloadButton) { | |
| if (retries > 0) { | |
| setTimeout(() => injectButtons(taskId, model, retries - 1), 500); | |
| } | |
| return; | |
| } | |
| const wrapper = downloadButton.closest('div.relative.inline-flex'); | |
| if (!wrapper || !wrapper.parentNode) return; | |
| document.querySelectorAll('.meshy-tm-btn').forEach((btn) => btn.remove()); | |
| const normalizedModel = model || { | |
| name: 'model', | |
| url: '', | |
| size: capturedGlbByTask.get(taskId) ? formatSize(capturedGlbByTask.get(taskId).byteLength) : '' | |
| }; | |
| const glbBtn = createButton('glb', taskId, normalizedModel); | |
| const stlBtn = createButton('stl', taskId, normalizedModel); | |
| const objBtn = createButton('obj', taskId, normalizedModel); | |
| wrapper.parentNode.insertBefore(glbBtn, wrapper.nextSibling); | |
| wrapper.parentNode.insertBefore(stlBtn, glbBtn.nextSibling); | |
| wrapper.parentNode.insertBefore(objBtn, stlBtn.nextSibling); | |
| } | |
| function findModelUrlInPayload(node, depth = 0) { | |
| if (depth > 8 || node === null || node === undefined) return null; | |
| if (typeof node === 'string') { | |
| if (node.includes('.glb') || node.includes('misc/cdn-models')) return node; | |
| return null; | |
| } | |
| if (Array.isArray(node)) { | |
| for (const item of node) { | |
| const found = findModelUrlInPayload(item, depth + 1); | |
| if (found) return found; | |
| } | |
| return null; | |
| } | |
| if (typeof node === 'object') { | |
| for (const [key, value] of Object.entries(node)) { | |
| if (typeof value === 'string' && key.toLowerCase().includes('model') && value.includes('http')) { | |
| return value; | |
| } | |
| const found = findModelUrlInPayload(value, depth + 1); | |
| if (found) return found; | |
| } | |
| } | |
| return null; | |
| } | |
| function looksLikeModelUrl(url) { | |
| if (!url || typeof url !== 'string') return false; | |
| return url.includes('.glb') || url.includes('misc/cdn-models') || url.includes('/models/'); | |
| } | |
| async function processTaskPayload(taskId, payload, source) { | |
| if (!taskId || !payload) return; | |
| lastSeenTaskId = taskId; | |
| const result = payload.result || payload; | |
| const name = result?.name || result?.args?.draft?.prompt || 'model'; | |
| const modelUrl = findModelUrlInPayload(result); | |
| if (!modelUrl) return; | |
| modelUrlByTask.set(taskId, modelUrl); | |
| taskId = normalizeTaskId(taskId) || taskId; | |
| const size = await fetchSizeText(modelUrl); | |
| saveModel(taskId, { url: modelUrl, name, size }); | |
| log(`task metadata captured from ${source}:`, taskId, modelUrl); | |
| injectButtons(taskId, getModel(taskId)); | |
| publishDebugState(); | |
| } | |
| function installTaskInterceptors() { | |
| const taskUrlRegex = /api\.meshy\.ai\/web\/v2\/tasks\/([a-f0-9-]+)/i; | |
| const originalFetch = window.fetch; | |
| window.fetch = async function (...args) { | |
| const response = await originalFetch.apply(this, args); | |
| const url = typeof args[0] === 'string' ? args[0] : args[0]?.url; | |
| const match = url && url.match(taskUrlRegex); | |
| if (match) { | |
| const taskId = match[1]; | |
| response.clone().json() | |
| .then((payload) => processTaskPayload(taskId, payload, 'fetch')) | |
| .catch((error) => log('fetch payload parse failed:', error.message)); | |
| } | |
| if (looksLikeModelUrl(url)) { | |
| response.clone().arrayBuffer() | |
| .then((buffer) => { | |
| if (!isGLBBuffer(buffer)) return; | |
| const taskId = normalizeTaskId(null); | |
| const model = taskId ? getModel(taskId) : null; | |
| cacheGlbBuffer(taskId, model?.name || 'model', buffer, 'fetch-model'); | |
| }) | |
| .catch(() => {}); | |
| } | |
| return response; | |
| }; | |
| const originalOpen = XMLHttpRequest.prototype.open; | |
| const originalSend = XMLHttpRequest.prototype.send; | |
| XMLHttpRequest.prototype.open = function (method, url) { | |
| this.__meshyUrl = url; | |
| return originalOpen.apply(this, arguments); | |
| }; | |
| XMLHttpRequest.prototype.send = function () { | |
| this.addEventListener('load', () => { | |
| const url = this.__meshyUrl; | |
| const match = url && url.match(taskUrlRegex); | |
| if (!match) return; | |
| try { | |
| const payload = JSON.parse(this.responseText); | |
| processTaskPayload(match[1], payload, 'xhr'); | |
| } catch (error) { | |
| log('xhr payload parse failed:', error.message); | |
| } | |
| }); | |
| return originalSend.apply(this, arguments); | |
| }; | |
| } | |
| function installGlbCaptureHooks() { | |
| function tryCapture(raw, source, taskHint = null) { | |
| const buffer = arrayBufferFromUnknown(raw); | |
| if (!buffer || !isGLBBuffer(buffer)) return; | |
| const taskId = normalizeTaskId(taskHint); | |
| const model = taskId ? getModel(taskId) : null; | |
| cacheGlbBuffer(taskId, model?.name || 'model', buffer, source); | |
| } | |
| function walkAndCapture(value, source, visited = new WeakSet(), depth = 0) { | |
| if (depth > 6 || value === null || value === undefined) return false; | |
| const directBuffer = arrayBufferFromUnknown(value); | |
| if (directBuffer && isGLBBuffer(directBuffer)) { | |
| tryCapture(directBuffer, source); | |
| return true; | |
| } | |
| if (value instanceof Blob) { | |
| value.arrayBuffer() | |
| .then((buffer) => tryCapture(buffer, `${source}-blob`)) | |
| .catch(() => {}); | |
| return false; | |
| } | |
| if (typeof value !== 'object') return false; | |
| if (visited.has(value)) return false; | |
| visited.add(value); | |
| if (Array.isArray(value)) { | |
| for (const item of value) { | |
| if (walkAndCapture(item, source, visited, depth + 1)) return true; | |
| } | |
| return false; | |
| } | |
| for (const child of Object.values(value)) { | |
| if (walkAndCapture(child, source, visited, depth + 1)) return true; | |
| } | |
| return false; | |
| } | |
| const originalWorker = window.Worker; | |
| if (typeof originalWorker === 'function') { | |
| window.Worker = function (...args) { | |
| const worker = new originalWorker(...args); | |
| const spy = (event) => { | |
| const data = event?.data; | |
| walkAndCapture(data, 'worker-message'); | |
| }; | |
| const add = worker.addEventListener; | |
| worker.addEventListener = function (type, listener, options) { | |
| if (type === 'message' && typeof listener === 'function') { | |
| const wrapped = function (event) { | |
| try { spy(event); } catch {} | |
| return listener.call(this, event); | |
| }; | |
| return add.call(this, type, wrapped, options); | |
| } | |
| return add.call(this, type, listener, options); | |
| }; | |
| try { | |
| const proto = Object.getPrototypeOf(worker); | |
| const desc = Object.getOwnPropertyDescriptor(proto, 'onmessage'); | |
| if (desc?.set) { | |
| Object.defineProperty(worker, 'onmessage', { | |
| configurable: true, | |
| get() { | |
| return desc.get ? desc.get.call(worker) : undefined; | |
| }, | |
| set(fn) { | |
| if (typeof fn !== 'function') { | |
| desc.set.call(worker, fn); | |
| return; | |
| } | |
| desc.set.call(worker, function (event) { | |
| try { spy(event); } catch {} | |
| return fn.call(this, event); | |
| }); | |
| } | |
| }); | |
| } | |
| } catch (error) { | |
| log('worker onmessage hook failed:', error.message); | |
| } | |
| return worker; | |
| }; | |
| window.Worker.prototype = originalWorker.prototype; | |
| Object.defineProperty(window.Worker, Symbol.hasInstance, { | |
| value(instance) { return instance instanceof originalWorker; } | |
| }); | |
| } | |
| const originalCreateObjectURL = URL.createObjectURL.bind(URL); | |
| URL.createObjectURL = function (blob) { | |
| const url = originalCreateObjectURL(blob); | |
| if (blob instanceof Blob && blob.size > 1024) { | |
| blob.arrayBuffer() | |
| .then((buffer) => { | |
| tryCapture(buffer, 'createObjectURL'); | |
| }) | |
| .catch(() => {}); | |
| } | |
| return url; | |
| }; | |
| } | |
| function checkCurrentPageModel() { | |
| const taskId = normalizeTaskId(null); | |
| if (!taskId) return; | |
| injectButtons(taskId, getModel(taskId)); | |
| } | |
| function installSpaUrlWatcher() { | |
| let lastUrl = window.location.href; | |
| new MutationObserver(() => { | |
| const nextUrl = window.location.href; | |
| if (nextUrl !== lastUrl) { | |
| lastUrl = nextUrl; | |
| setTimeout(checkCurrentPageModel, 300); | |
| } | |
| }).observe(document, { subtree: true, childList: true }); | |
| } | |
| function init() { | |
| installTaskInterceptors(); | |
| installGlbCaptureHooks(); | |
| installSpaUrlWatcher(); | |
| setTimeout(checkCurrentPageModel, 1000); | |
| log('userscript initialized'); | |
| publishDebugState(); | |
| } | |
| init(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment