Files
rshtirmer 707206cc9f feat: add GLB optimization pipeline for web-ready 3D assets
Meshy AI generates raw GLBs (1-25MB) with uncompressed textures and high
polycounts. New optimize-glb.mjs script automatically reduces file sizes
by 80-95% via texture resize, WebP conversion, and meshopt compression.

- Add scripts/optimize-glb.mjs (standalone, zero-dep, uses npx gltf-transform)
- Auto-optimize after every GLB download in meshy-generate.mjs and find-3d-asset.mjs
- Add MeshoptDecoder to AssetLoader.js template for runtime decompression
- Lower default polycount from 30,000 to 10,000 for game-ready topology
- Add --no-optimize flag to skip optimization when needed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-27 15:16:26 -05:00

713 lines
23 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* meshy-generate.mjs — Generate 3D models with Meshy AI, rig, and animate them.
*
* Zero npm dependencies. Uses Node.js built-in fetch, fs, path.
*
* Modes:
* text-to-3d — Generate a 3D model from a text prompt (preview → refine)
* image-to-3d — Generate a 3D model from an image
* rig — Auto-rig a humanoid model for skeletal animation
* animate — Apply an animation to a rigged model
* status — Check the status of any task
*
* Usage:
* # Text to 3D (full pipeline: preview → wait → refine → wait → download)
* MESHY_API_KEY=<key> node scripts/meshy-generate.mjs \
* --mode text-to-3d --prompt "a cartoon knight with sword and shield" \
* --output public/assets/models/ --slug knight
*
* # Image to 3D
* MESHY_API_KEY=<key> node scripts/meshy-generate.mjs \
* --mode image-to-3d --image "https://example.com/photo.png" \
* --output public/assets/models/ --slug character
*
* # Rig an existing model (from Meshy task or external URL)
* MESHY_API_KEY=<key> node scripts/meshy-generate.mjs \
* --mode rig --task-id <meshy-task-id> \
* --output public/assets/models/ --slug character-rigged
*
* # Animate a rigged model
* MESHY_API_KEY=<key> node scripts/meshy-generate.mjs \
* --mode animate --task-id <rig-task-id> --action-id 1 \
* --output public/assets/models/ --slug character-walk
*
* # Check status of any task
* MESHY_API_KEY=<key> node scripts/meshy-generate.mjs \
* --mode status --task-id <task-id> --task-type text-to-3d
*
* # Preview only (skip refine)
* MESHY_API_KEY=<key> node scripts/meshy-generate.mjs \
* --mode text-to-3d --prompt "a barrel" --preview-only \
* --output public/assets/models/ --slug barrel
*
* Output:
* {slug}.glb — the 3D model file
* {slug}.meta.json — source, prompt, task IDs, timestamps
*
* Environment:
* MESHY_API_KEY — Required. Get one at https://app.meshy.ai → API Keys
*/
import { writeFileSync, readFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
import { resolve, join, basename, dirname, fileURLToPath } from 'node:path';
import { execSync } from 'node:child_process';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const API_BASE = 'https://api.meshy.ai/openapi';
const POLL_INTERVAL_MS = 5000;
const MAX_POLL_ATTEMPTS = 360; // 30 minutes max
// ---------------------------------------------------------------------------
// CLI arg parsing
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
function getArg(name, fallback = null) {
const idx = args.indexOf(`--${name}`);
if (idx === -1 || idx + 1 >= args.length) return fallback;
return args[idx + 1];
}
const hasFlag = (name) => args.includes(`--${name}`);
const mode = getArg('mode');
const prompt = getArg('prompt');
const imageUrl = getArg('image');
const outputDir = getArg('output', 'public/assets/models');
const slug = getArg('slug');
const taskId = getArg('task-id');
const taskType = getArg('task-type', 'text-to-3d');
const aiModel = getArg('ai-model', 'latest');
const actionId = parseInt(getArg('action-id', '0'), 10);
const previewOnly = hasFlag('preview-only');
const enablePbr = hasFlag('pbr');
const topology = getArg('topology', 'triangle');
const targetPolycount = parseInt(getArg('polycount', '10000'), 10);
const texturePrompt = getArg('texture-prompt');
const heightMeters = parseFloat(getArg('height', '1.7'));
const noPoll = hasFlag('no-poll');
const noOptimize = hasFlag('no-optimize');
const optimizeTextureSize = parseInt(getArg('texture-size', '1024'), 10);
const API_KEY = process.env.MESHY_API_KEY;
function requireApiKey() {
if (!API_KEY) {
throw new Error(
'MESHY_API_KEY environment variable is required.\n' +
'Get an API key at: https://app.meshy.ai → Settings → API Keys'
);
}
}
if (!mode) {
console.error(`Usage: MESHY_API_KEY=<key> node scripts/meshy-generate.mjs --mode <mode> [options]
Modes:
text-to-3d Generate 3D model from text prompt
image-to-3d Generate 3D model from image
rig Auto-rig a humanoid model
animate Apply animation to rigged model
status Check task status
Options:
--prompt <text> Text prompt (text-to-3d)
--image <url|path> Image URL or base64 data URI (image-to-3d)
--output <dir> Output directory (default: public/assets/models)
--slug <name> Output filename slug
--task-id <id> Task ID (for refine, rig, animate, status)
--task-type <type> Task type for status check (text-to-3d, image-to-3d, rigging, animations)
--ai-model <name> AI model: meshy-5, meshy-6, latest (default: latest)
--action-id <int> Animation ID (animate mode)
--preview-only Skip refine step (text-to-3d)
--pbr Enable PBR texture maps
--topology <type> quad or triangle (default: triangle)
--polycount <n> Target polygon count (default: 10000)
--texture-prompt <t> Additional texture guidance
--height <meters> Character height for rigging (default: 1.7)
--no-poll Submit task and exit without waiting
--no-optimize Skip GLB optimization (optimization ON by default)
--texture-size <n> Max texture dimension for optimization (default: 1024)
Environment:
MESHY_API_KEY API key from https://app.meshy.ai`);
process.exit(1);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function slugify(text) {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 60);
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function headers() {
return {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
};
}
async function apiPost(path, body) {
const url = `${API_BASE}${path}`;
const res = await fetch(url, {
method: 'POST',
headers: headers(),
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`POST ${path} → HTTP ${res.status}: ${text}`);
}
return res.json();
}
async function apiGet(path) {
const url = `${API_BASE}${path}`;
const res = await fetch(url, { headers: headers() });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`GET ${path} → HTTP ${res.status}: ${text}`);
}
return res.json();
}
async function apiDelete(path) {
const url = `${API_BASE}${path}`;
const res = await fetch(url, { method: 'DELETE', headers: headers() });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`DELETE ${path} → HTTP ${res.status}: ${text}`);
}
return res.status;
}
async function downloadFile(url, dest) {
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`Download failed: HTTP ${res.status} from ${url}`);
const buffer = Buffer.from(await res.arrayBuffer());
writeFileSync(dest, buffer);
return buffer.length;
}
function writeMeta(outPath, meta) {
writeFileSync(outPath, JSON.stringify(meta, null, 2) + '\n');
console.log(` meta → ${outPath}`);
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/**
* Run optimize-glb.mjs on a downloaded GLB file.
* Logs before/after sizes. Skips if --no-optimize flag is set.
*/
function optimizeGlbFile(glbPath) {
if (noOptimize || !glbPath || !glbPath.endsWith('.glb')) return;
const scriptDir = dirname(fileURLToPath(import.meta.url));
const optimizeScript = join(scriptDir, 'optimize-glb.mjs');
if (!existsSync(optimizeScript)) {
console.log(' [meshy] optimize-glb.mjs not found — skipping optimization');
return;
}
try {
execSync(`node "${optimizeScript}" "${glbPath}" --texture-size ${optimizeTextureSize}`, {
stdio: 'inherit',
timeout: 120_000,
});
} catch {
console.log(' [meshy] GLB optimization failed — keeping original file');
}
}
// ---------------------------------------------------------------------------
// Poll for task completion
// ---------------------------------------------------------------------------
async function pollTask(taskPath, label) {
console.log(` [meshy] Polling ${label}...`);
let lastProgress = -1;
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
const task = await apiGet(taskPath);
if (task.progress !== lastProgress) {
lastProgress = task.progress;
const bar = '█'.repeat(Math.floor(task.progress / 5)) + '░'.repeat(20 - Math.floor(task.progress / 5));
process.stdout.write(`\r [meshy] ${bar} ${task.progress}% — ${task.status}`);
}
if (task.status === 'SUCCEEDED') {
console.log(`\n [meshy] ${label} complete!`);
return task;
}
if (task.status === 'FAILED') {
const errMsg = task.task_error?.message || JSON.stringify(task.task_error) || 'Unknown error';
throw new Error(`${label} failed: ${errMsg}`);
}
if (task.status === 'CANCELED') {
throw new Error(`${label} was canceled`);
}
await sleep(POLL_INTERVAL_MS);
}
throw new Error(`${label} timed out after ${MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS / 60000} minutes`);
}
// ---------------------------------------------------------------------------
// Download model from completed task
// ---------------------------------------------------------------------------
async function downloadModel(task, outDir, fileSlug) {
const modelUrls = task.model_urls || task.result || {};
const glbUrl = modelUrls.glb || modelUrls.rigged_character_glb_url || modelUrls.animation_glb_url;
if (!glbUrl) {
console.log(' [meshy] No GLB URL in task result. Available URLs:');
console.log(JSON.stringify(modelUrls, null, 2));
return null;
}
const dest = join(outDir, `${fileSlug}.glb`);
console.log(` [meshy] Downloading GLB...`);
const size = await downloadFile(glbUrl, dest);
console.log(` model → ${dest} (${formatBytes(size)})`);
return dest;
}
// ---------------------------------------------------------------------------
// Text to 3D
// ---------------------------------------------------------------------------
async function textTo3D() {
if (!prompt) {
console.error('Error: --prompt is required for text-to-3d mode');
process.exit(1);
}
const fileSlug = slug || slugify(prompt);
const outDir = resolve(outputDir);
mkdirSync(outDir, { recursive: true });
console.log(`\n=== Meshy Text-to-3D: "${prompt}" ===\n`);
// Step 1: Create preview task
console.log(' [meshy] Creating preview task...');
const previewPayload = {
mode: 'preview',
prompt,
ai_model: aiModel,
topology,
target_polycount: targetPolycount,
};
const previewResult = await apiPost('/v2/text-to-3d', previewPayload);
const previewId = previewResult.result;
console.log(` [meshy] Preview task: ${previewId}`);
if (noPoll) {
console.log(`\n Task submitted. Check status with:`);
console.log(` node scripts/meshy-generate.mjs --mode status --task-id ${previewId} --task-type text-to-3d`);
return;
}
// Step 2: Poll preview
const previewTask = await pollTask(`/v2/text-to-3d/${previewId}`, 'Preview');
if (previewOnly) {
const modelPath = await downloadModel(previewTask, outDir, fileSlug);
optimizeGlbFile(modelPath);
writeMeta(join(outDir, `${fileSlug}.meta.json`), {
slug: fileSlug,
source: 'meshy-ai',
mode: 'text-to-3d-preview',
prompt,
aiModel,
previewTaskId: previewId,
status: 'preview-only',
modelPath: modelPath ? basename(modelPath) : null,
thumbnail: previewTask.thumbnail_url || null,
createdAt: new Date().toISOString(),
});
console.log(`\n=== Done (preview only): ${modelPath || 'no GLB'} ===\n`);
return;
}
// Step 3: Create refine task
console.log('\n [meshy] Creating refine task...');
const refinePayload = {
mode: 'refine',
preview_task_id: previewId,
enable_pbr: enablePbr,
};
if (texturePrompt) refinePayload.texture_prompt = texturePrompt;
const refineResult = await apiPost('/v2/text-to-3d', refinePayload);
const refineId = refineResult.result;
console.log(` [meshy] Refine task: ${refineId}`);
// Step 4: Poll refine
const refineTask = await pollTask(`/v2/text-to-3d/${refineId}`, 'Refine');
// Step 5: Download
const modelPath = await downloadModel(refineTask, outDir, fileSlug);
optimizeGlbFile(modelPath);
writeMeta(join(outDir, `${fileSlug}.meta.json`), {
slug: fileSlug,
source: 'meshy-ai',
mode: 'text-to-3d',
prompt,
aiModel,
previewTaskId: previewId,
refineTaskId: refineId,
enablePbr,
topology,
targetPolycount,
modelUrls: refineTask.model_urls || {},
textureUrls: refineTask.texture_urls || [],
thumbnail: refineTask.thumbnail_url || null,
modelPath: modelPath ? basename(modelPath) : null,
createdAt: new Date().toISOString(),
});
console.log(`\n=== Done: ${modelPath || 'no GLB'} ===\n`);
}
// ---------------------------------------------------------------------------
// Image to 3D
// ---------------------------------------------------------------------------
async function imageTo3D() {
if (!imageUrl) {
console.error('Error: --image is required for image-to-3d mode');
process.exit(1);
}
const fileSlug = slug || 'image-to-3d';
const outDir = resolve(outputDir);
mkdirSync(outDir, { recursive: true });
// Handle local file paths — convert to base64 data URI
let resolvedImageUrl = imageUrl;
if (!imageUrl.startsWith('http') && !imageUrl.startsWith('data:')) {
const imagePath = resolve(imageUrl);
if (!existsSync(imagePath)) {
console.error(`Error: Image file not found: ${imagePath}`);
process.exit(1);
}
const ext = imagePath.split('.').pop().toLowerCase();
const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
const base64 = readFileSync(imagePath).toString('base64');
resolvedImageUrl = `data:${mime};base64,${base64}`;
console.log(` [meshy] Converted local image to base64 data URI`);
}
console.log(`\n=== Meshy Image-to-3D ===\n`);
console.log(' [meshy] Creating image-to-3d task...');
const payload = {
image_url: resolvedImageUrl,
ai_model: aiModel,
topology,
target_polycount: targetPolycount,
enable_pbr: enablePbr,
should_texture: true,
};
const result = await apiPost('/v1/image-to-3d', payload);
const taskIdResult = result.result;
console.log(` [meshy] Task: ${taskIdResult}`);
if (noPoll) {
console.log(`\n Task submitted. Check status with:`);
console.log(` node scripts/meshy-generate.mjs --mode status --task-id ${taskIdResult} --task-type image-to-3d`);
return;
}
const task = await pollTask(`/v1/image-to-3d/${taskIdResult}`, 'Image-to-3D');
const modelPath = await downloadModel(task, outDir, fileSlug);
optimizeGlbFile(modelPath);
writeMeta(join(outDir, `${fileSlug}.meta.json`), {
slug: fileSlug,
source: 'meshy-ai',
mode: 'image-to-3d',
imageUrl: imageUrl.startsWith('data:') ? '(base64)' : imageUrl,
aiModel,
taskId: taskIdResult,
enablePbr,
topology,
targetPolycount,
modelUrls: task.model_urls || {},
textureUrls: task.texture_urls || [],
thumbnail: task.thumbnail_url || null,
modelPath: modelPath ? basename(modelPath) : null,
createdAt: new Date().toISOString(),
});
console.log(`\n=== Done: ${modelPath || 'no GLB'} ===\n`);
}
// ---------------------------------------------------------------------------
// Rig
// ---------------------------------------------------------------------------
async function rigModel() {
if (!taskId) {
console.error('Error: --task-id is required for rig mode (Meshy task ID or model URL via --image)');
process.exit(1);
}
const fileSlug = slug || 'rigged';
const outDir = resolve(outputDir);
mkdirSync(outDir, { recursive: true });
console.log(`\n=== Meshy Auto-Rig ===\n`);
console.log(' [meshy] Creating rigging task...');
const payload = {
input_task_id: taskId,
height_meters: heightMeters,
};
const result = await apiPost('/v1/rigging', payload);
const rigTaskId = result.result;
console.log(` [meshy] Rig task: ${rigTaskId}`);
if (noPoll) {
console.log(`\n Task submitted. Check status with:`);
console.log(` node scripts/meshy-generate.mjs --mode status --task-id ${rigTaskId} --task-type rigging`);
return;
}
const task = await pollTask(`/v1/rigging/${rigTaskId}`, 'Rigging');
// Download rigged GLB
const rigResult = task.result || {};
const glbUrl = rigResult.rigged_character_glb_url;
let modelPath = null;
if (glbUrl) {
const dest = join(outDir, `${fileSlug}.glb`);
console.log(` [meshy] Downloading rigged GLB...`);
const size = await downloadFile(glbUrl, dest);
console.log(` model → ${dest} (${formatBytes(size)})`);
optimizeGlbFile(dest);
modelPath = dest;
}
// Auto-download basic animations (walking + running GLBs)
const basicAnims = rigResult.basic_animations || {};
const downloadedAnims = {};
if (basicAnims.walking_glb_url) {
const walkDest = join(outDir, `${fileSlug}-walk.glb`);
console.log(` [meshy] Downloading walking animation...`);
try {
const size = await downloadFile(basicAnims.walking_glb_url, walkDest);
console.log(` walk → ${walkDest} (${formatBytes(size)})`);
optimizeGlbFile(walkDest);
downloadedAnims.walk = basename(walkDest);
} catch (err) {
console.log(` [meshy] Walk download failed: ${err.message}`);
}
}
if (basicAnims.running_glb_url) {
const runDest = join(outDir, `${fileSlug}-run.glb`);
console.log(` [meshy] Downloading running animation...`);
try {
const size = await downloadFile(basicAnims.running_glb_url, runDest);
console.log(` run → ${runDest} (${formatBytes(size)})`);
optimizeGlbFile(runDest);
downloadedAnims.run = basename(runDest);
} catch (err) {
console.log(` [meshy] Run download failed: ${err.message}`);
}
}
writeMeta(join(outDir, `${fileSlug}.meta.json`), {
slug: fileSlug,
source: 'meshy-ai',
mode: 'rigging',
inputTaskId: taskId,
rigTaskId,
heightMeters,
result: rigResult,
basicAnimations: basicAnims,
downloadedAnimations: downloadedAnims,
modelPath: modelPath ? basename(modelPath) : null,
createdAt: new Date().toISOString(),
});
console.log(`\n=== Done: ${modelPath || 'no GLB'} ===`);
if (downloadedAnims.walk) console.log(` Walking: ${downloadedAnims.walk}`);
if (downloadedAnims.run) console.log(` Running: ${downloadedAnims.run}`);
console.log(`\n To add custom animations, run:`);
console.log(` node scripts/meshy-generate.mjs --mode animate --task-id ${rigTaskId} --action-id <id> --slug ${fileSlug}-anim\n`);
}
// ---------------------------------------------------------------------------
// Animate
// ---------------------------------------------------------------------------
async function animateModel() {
if (!taskId) {
console.error('Error: --task-id is required (rig task ID)');
process.exit(1);
}
if (!actionId) {
console.error('Error: --action-id is required (animation ID number)');
process.exit(1);
}
const fileSlug = slug || 'animated';
const outDir = resolve(outputDir);
mkdirSync(outDir, { recursive: true });
console.log(`\n=== Meshy Animate ===\n`);
console.log(` [meshy] Creating animation task (action ${actionId})...`);
const payload = {
rig_task_id: taskId,
action_id: actionId,
};
const result = await apiPost('/v1/animations', payload);
const animTaskId = result.result;
console.log(` [meshy] Animation task: ${animTaskId}`);
if (noPoll) {
console.log(`\n Task submitted. Check status with:`);
console.log(` node scripts/meshy-generate.mjs --mode status --task-id ${animTaskId} --task-type animations`);
return;
}
const task = await pollTask(`/v1/animations/${animTaskId}`, 'Animation');
const animResult = task.result || {};
const glbUrl = animResult.animation_glb_url;
let modelPath = null;
if (glbUrl) {
const dest = join(outDir, `${fileSlug}.glb`);
console.log(` [meshy] Downloading animated GLB...`);
const size = await downloadFile(glbUrl, dest);
console.log(` model → ${dest} (${formatBytes(size)})`);
optimizeGlbFile(dest);
modelPath = dest;
}
writeMeta(join(outDir, `${fileSlug}.meta.json`), {
slug: fileSlug,
source: 'meshy-ai',
mode: 'animation',
rigTaskId: taskId,
animTaskId,
actionId,
result: animResult,
modelPath: modelPath ? basename(modelPath) : null,
createdAt: new Date().toISOString(),
});
console.log(`\n=== Done: ${modelPath || 'no GLB'} ===\n`);
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
async function checkStatus() {
if (!taskId) {
console.error('Error: --task-id is required');
process.exit(1);
}
const pathMap = {
'text-to-3d': `/v2/text-to-3d/${taskId}`,
'image-to-3d': `/v1/image-to-3d/${taskId}`,
'rigging': `/v1/rigging/${taskId}`,
'animations': `/v1/animations/${taskId}`,
};
const path = pathMap[taskType];
if (!path) {
console.error(`Error: Unknown task type "${taskType}". Use: text-to-3d, image-to-3d, rigging, animations`);
process.exit(1);
}
console.log(`\n=== Meshy Task Status ===\n`);
const task = await apiGet(path);
console.log(` ID: ${task.id}`);
console.log(` Status: ${task.status}`);
console.log(` Progress: ${task.progress}%`);
if (task.model_urls) {
console.log(` Model URLs:`);
for (const [fmt, url] of Object.entries(task.model_urls)) {
if (url) console.log(` ${fmt}: ${url}`);
}
}
if (task.result) {
console.log(` Result:`);
for (const [key, val] of Object.entries(task.result)) {
if (val && typeof val === 'string') console.log(` ${key}: ${val}`);
}
}
if (task.thumbnail_url) console.log(` Thumbnail: ${task.thumbnail_url}`);
if (task.task_error) console.log(` Error: ${JSON.stringify(task.task_error)}`);
console.log();
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
requireApiKey();
switch (mode) {
case 'text-to-3d':
return textTo3D();
case 'image-to-3d':
return imageTo3D();
case 'rig':
return rigModel();
case 'animate':
return animateModel();
case 'status':
return checkStatus();
default:
console.error(`Unknown mode: "${mode}". Use: text-to-3d, image-to-3d, rig, animate, status`);
process.exit(1);
}
}
main().catch((err) => {
console.error(`\nFatal error: ${err.message}`);
process.exit(1);
});