-
-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathfetch-mixamo-avatars.mjs
More file actions
398 lines (353 loc) · 14.3 KB
/
Copy pathfetch-mixamo-avatars.mjs
File metadata and controls
398 lines (353 loc) · 14.3 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
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
#!/usr/bin/env node
// Bulk-download every Mixamo character avatar as FBX (With Skin).
// Resumable: re-run to pick up where it left off.
//
// Listing the catalog needs no auth (Mixamo's /products?type=Character feed is
// public). Exporting + downloading the actual FBX does need a Mixamo session
// token, same as the animation fetchers.
//
// Usage:
// node scripts/fetch-mixamo-avatars.mjs # catalog only, no token needed
// MIXAMO_TOKEN=eyJ... node scripts/fetch-mixamo-avatars.mjs --download
// # or put MIXAMO_TOKEN=... in .env.local
//
// Optional flags:
// --download run the export+download phase (needs MIXAMO_TOKEN)
// --concurrency=N parallel export jobs (default 2)
// --limit=N stop after N successful downloads (default: all)
// --format=fbx7|fbx6 output format (default fbx7)
//
// Prerequisites for --download:
// node scripts/get-mixamo-token.mjs (first-time login, needs ADOBE_EMAIL/ADOBE_PASSWORD)
import { mkdirSync, writeFileSync, existsSync, readFileSync, renameSync } from 'node:fs';
import { join } from 'node:path';
// ── Config ────────────────────────────────────────────────────────────────
const API = 'https://www.mixamo.com/api/v1';
const PAGE_LIMIT = 96;
const POLL_INTERVAL_MS = 3000;
const POLL_MAX_ATTEMPTS = 60;
const args = Object.fromEntries(
process.argv.slice(2).map((a) => {
const m = a.match(/^--([^=]+)(?:=(.*))?$/);
return m ? [m[1], m[2] ?? true] : [a, true];
}),
);
const RUN_DOWNLOAD = !!args.download;
const CONCURRENCY = Number(args.concurrency) || 2;
const MAX_DOWNLOADS = args.limit ? Number(args.limit) : Infinity;
const FORMAT = args.format || 'fbx7';
let globalCooldownUntil = 0;
const RATE_LIMIT_BASE_MS = 30_000;
const RATE_LIMIT_MAX_MS = 300_000;
// ── Env loading (.env.local fallback, same convention as the other mixamo scripts) ──
function loadEnvVar(key) {
if (process.env[key]) return process.env[key].trim();
const envPath = join(process.cwd(), '.env.local');
if (existsSync(envPath)) {
const line = readFileSync(envPath, 'utf8').split('\n').find((l) => l.startsWith(`${key}=`));
if (line) return line.slice(key.length + 1).trim().replace(/^["']|["']$/g, '');
}
return null;
}
const TOKEN = loadEnvVar('MIXAMO_TOKEN');
// ── R2 config — S3_* is the name production (Cloud Run) and mixamo-all.mjs use;
// R2_* accepted as a fallback for older local setups. ──────────────────────────
const S3_ENDPOINT = loadEnvVar('S3_ENDPOINT') ||
(loadEnvVar('R2_ACCOUNT_ID') ? `https://${loadEnvVar('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com` : null);
const S3_ACCESS_KEY_ID = loadEnvVar('S3_ACCESS_KEY_ID') || loadEnvVar('R2_ACCESS_KEY_ID');
const S3_SECRET_ACCESS_KEY = loadEnvVar('S3_SECRET_ACCESS_KEY') || loadEnvVar('R2_SECRET_ACCESS_KEY');
const S3_BUCKET = loadEnvVar('S3_BUCKET') || loadEnvVar('R2_BUCKET') || 'test';
const USE_R2 = !!(S3_ENDPOINT && S3_ACCESS_KEY_ID && S3_SECRET_ACCESS_KEY);
let r2 = null;
async function getR2Client() {
if (r2) return r2;
const { S3Client } = await import('@aws-sdk/client-s3');
r2 = new S3Client({
region: 'auto',
endpoint: S3_ENDPOINT,
credentials: { accessKeyId: S3_ACCESS_KEY_ID, secretAccessKey: S3_SECRET_ACCESS_KEY },
});
return r2;
}
async function existsInR2(key) {
const { HeadObjectCommand } = await import('@aws-sdk/client-s3');
try {
await (await getR2Client()).send(new HeadObjectCommand({ Bucket: S3_BUCKET, Key: key }));
return true;
} catch {
return false;
}
}
async function uploadToR2(key, buf) {
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
await (await getR2Client()).send(
new PutObjectCommand({ Bucket: S3_BUCKET, Key: key, Body: buf, ContentType: 'application/octet-stream' }),
);
}
const authHeaders = {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Api-Key': 'mixamo2',
...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
};
// ── Output paths ──────────────────────────────────────────────────────────
const OUT_DIR = join(process.cwd(), 'public', 'avatars', 'mixamo');
const CATALOG_PATH = join(OUT_DIR, 'catalog.json');
mkdirSync(OUT_DIR, { recursive: true });
const catalog = existsSync(CATALOG_PATH)
? JSON.parse(readFileSync(CATALOG_PATH, 'utf8'))
: { generated_at: null, avatars: {} };
function saveCatalog() {
catalog.generated_at = new Date().toISOString();
const tmp = `${CATALOG_PATH}.tmp`;
writeFileSync(tmp, JSON.stringify(catalog, null, 2));
renameSync(tmp, CATALOG_PATH);
}
// ── Helpers ───────────────────────────────────────────────────────────────
// Export API rejects gms_hash whose `params` is the raw [[name, value], …]
// array — it wants the values flattened to a comma-joined string (e.g. "0").
// Sending the raw array still gets a 202 queued response but the job then
// fails async with "Error while generating the animation".
function flattenGmsHash(g) {
if (!g) return null;
const params = Array.isArray(g.params) ? g.params.map((p) => p[1]).join(',') : (g.params ?? '0');
return { ...g, params };
}
const slugify = (s) =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitForCooldown() {
while (Date.now() < globalCooldownUntil) {
await sleep(Math.min(2000, globalCooldownUntil - Date.now()));
}
}
function triggerCooldown(retryAfterSec, attempt) {
const explicit = retryAfterSec ? Number(retryAfterSec) * 1000 : 0;
const backoff = Math.min(RATE_LIMIT_BASE_MS * 2 ** attempt, RATE_LIMIT_MAX_MS);
const wait = Math.max(explicit, backoff);
const until = Date.now() + wait;
if (until > globalCooldownUntil) {
globalCooldownUntil = until;
console.log(`Rate limited — pausing ${(wait / 1000).toFixed(0)}s`);
}
}
async function rlFetch(url, init = {}, attempt = 0) {
await waitForCooldown();
const res = await fetch(url, init);
if (res.status === 429) {
triggerCooldown(res.headers.get('retry-after'), attempt);
if (attempt >= 6) throw new Error('429 (max retries)');
return rlFetch(url, init, attempt + 1);
}
return res;
}
async function api(path, init = {}) {
const res = await rlFetch(`${API}${path}`, { ...init, headers: { ...authHeaders, ...init.headers } });
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status} ${path} ${body.slice(0, 200)}`);
}
return res.json();
}
// ── Step 1: list all Character products — public feed, no auth required ───
async function listAllCharacters() {
const all = [];
let page = 1;
while (true) {
process.stdout.write(`\rListing characters: page ${page} (${all.length} so far)... `);
const data = await api(
`/products?page=${page}&limit=${PAGE_LIMIT}&type=Character&order=relevance`,
);
const results = data.results || [];
all.push(...results);
const totalPages =
data.pagination?.num_pages ?? Math.ceil((data.pagination?.num_results ?? 0) / PAGE_LIMIT);
if (!totalPages || page >= totalPages || results.length === 0) break;
page += 1;
await sleep(200);
}
process.stdout.write('\n');
// Dedupe by product id — same pattern as the animation catalog fetchers,
// Mixamo's paginated feed can repeat a product across pages.
const seen = new Set();
const unique = all.filter((a) => (seen.has(a.id) ? false : (seen.add(a.id), true)));
return unique;
}
// Mixamo has no dedicated "download this character's mesh" endpoint —
// `/characters/export` 404s (not a real route). The actual mechanism the
// mixamo.com frontend uses is exporting a reference Motion (any base pose,
// here "Standing Idle") *targeted at the character* via the same
// `/animations/export` endpoint the animation fetchers use, with
// `skin: 'true'`. That bundles the character's own skinned/textured mesh
// into the exported FBX — confirmed by content-length (12.6 MB vs 900 KB
// for a skinless animation-only clip of the same character.
const REFERENCE_MOTION_ID = 'c9c972d1-b96c-11e4-a802-0aaa78deedf9'; // "Standing Idle"
// ── Step 2: export + poll + download a single character (needs MIXAMO_TOKEN) ──
async function downloadOne(product) {
const slug = slugify(product.description || product.name || product.id);
const r2Key = `avatars/mixamo/${slug}.fbx`;
const localPath = join(OUT_DIR, `${slug}.fbx`);
const existing = catalog.avatars[product.id];
if (existing?.status === 'completed') {
const alreadyExists = USE_R2 ? await existsInR2(r2Key) : existsSync(localPath);
if (alreadyExists) return { skipped: true, slug, reason: 'already-downloaded' };
}
if (existing?.status === 'permanent_fail') {
return { skipped: true, slug, reason: 'permanent-fail' };
}
// Fetch the reference motion's gms_hash, targeted at this character.
const productDetails = await api(`/products/${REFERENCE_MOTION_ID}?character_id=${product.id}`);
const gmsHash = productDetails?.details?.gms_hash;
if (!gmsHash) {
catalog.avatars[product.id] = {
id: product.id,
name: product.description || product.name,
status: 'permanent_fail',
reason: 'no_gms_hash',
failed_at: new Date().toISOString(),
};
saveCatalog();
throw new Error('no gms_hash');
}
const exportRes = await rlFetch(`${API}/animations/export`, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({
character_id: product.id,
product_id: REFERENCE_MOTION_ID,
product_name: 'Standing Idle',
type: 'Motion',
gms_hash: [flattenGmsHash(gmsHash)],
preferences: { format: FORMAT, skin: 'true', fps: '30', reducekf: '0' },
}),
});
if (!exportRes.ok) {
const status = exportRes.status;
if (status === 400 || status === 404) {
catalog.avatars[product.id] = {
id: product.id,
name: product.description || product.name,
status: 'permanent_fail',
http: status,
failed_at: new Date().toISOString(),
};
saveCatalog();
}
throw new Error(`export ${status}`);
}
// The character monitor endpoint is the per-character job status —
// same one the export's own job_type: 'character_export' reports through.
let downloadUrl = null;
for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) {
await sleep(POLL_INTERVAL_MS);
const status = await api(`/characters/${product.id}/monitor`);
if (status.status === 'completed' && status.job_result) {
downloadUrl = status.job_result;
break;
}
if (status.status === 'failed') throw new Error('export failed');
}
if (!downloadUrl) throw new Error('poll timeout');
const fileRes = await rlFetch(downloadUrl);
if (!fileRes.ok) throw new Error(`download ${fileRes.status}`);
const buf = Buffer.from(await fileRes.arrayBuffer());
if (USE_R2) {
await uploadToR2(r2Key, buf);
} else {
writeFileSync(localPath, buf);
}
catalog.avatars[product.id] = {
id: product.id,
name: product.description || product.name,
file: USE_R2 ? r2Key : `${slug}.fbx`,
bytes: buf.length,
downloaded_at: new Date().toISOString(),
status: 'completed',
storage: USE_R2 ? 'r2' : 'local',
};
saveCatalog();
return { slug, bytes: buf.length };
}
// ── Step 3: concurrency-limited worker pool ─────────────────────────────
async function runPool(products) {
let cursor = 0;
let ok = 0;
let fail = 0;
let skipped = 0;
async function worker() {
while (cursor < products.length && ok + fail < MAX_DOWNLOADS) {
const i = cursor++;
const product = products[i];
const label = `[${i + 1}/${products.length}]`;
try {
const result = await downloadOne(product);
if (result.skipped) {
skipped++;
console.log(`${label} skip ${result.slug} (${result.reason})`);
} else {
ok++;
console.log(`${label} done ${result.slug} (${(result.bytes / 1024).toFixed(0)} KB)`);
await sleep(500);
}
} catch (err) {
fail++;
console.warn(`${label} fail ${product.description}: ${err.message}`);
if (err.message.includes('HTTP 401') || err.message.includes('HTTP 403')) {
console.error('Auth failure — token expired. Refresh MIXAMO_TOKEN and re-run.');
process.exit(2);
}
}
}
}
const workers = Array.from({ length: CONCURRENCY }, () => worker());
await Promise.all(workers);
return { ok, fail, skipped };
}
// ── Main ──────────────────────────────────────────────────────────────────
(async () => {
console.log(`Mixamo avatar fetcher`);
console.log(` Storage: ${USE_R2 ? `R2 -> ${S3_BUCKET}/avatars/mixamo/` : OUT_DIR}`);
const products = await listAllCharacters();
catalog.catalog_size = products.length;
saveCatalog();
console.log(`Catalog: ${products.length} characters\n`);
if (products.length === 0) {
console.log('No characters found — Mixamo API may be unreachable.');
process.exit(1);
}
if (!RUN_DOWNLOAD) {
console.log('Catalog-only run (pass --download to export + download FBX files).');
if (!TOKEN) {
console.log('\nMIXAMO_TOKEN not set — the download phase needs it.');
console.log('Get one: node scripts/get-mixamo-token.mjs (needs ADOBE_EMAIL/ADOBE_PASSWORD in .env.local)');
}
console.log(`\nCatalog saved: ${CATALOG_PATH}`);
return;
}
if (!TOKEN) {
console.error('MIXAMO_TOKEN not set. Run: node scripts/get-mixamo-token.mjs');
process.exit(1);
}
console.log(` Format: ${FORMAT} (with skin)`);
console.log(` Concurrency: ${CONCURRENCY}\n`);
const t0 = Date.now();
const { ok, fail, skipped } = await runPool(products);
const mins = ((Date.now() - t0) / 60000).toFixed(1);
console.log(`\n${'='.repeat(43)}`);
console.log(`Downloaded: ${ok}`);
console.log(`Skipped: ${skipped}`);
console.log(`Failed: ${fail}`);
console.log(`Time: ${mins} min`);
console.log(`Output: ${USE_R2 ? `R2:${S3_BUCKET}/avatars/mixamo/` : OUT_DIR}`);
console.log(`\nConvert FBX -> GLB with fbx2gltf:`);
console.log(` for f in ${OUT_DIR}/*.fbx; do`);
console.log(` fbx2gltf -i "$f" -o "\${f%.fbx}.glb"`);
console.log(` done`);
})().catch((err) => {
console.error(err);
process.exit(1);
});