@olo-studio/studio-mcp 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,622 @@
1
+ /**
2
+ * Headless Chrome / Playwright deterministic video renderer and GLB inspector.
3
+ * Calls `window.OloStudioBridge.exportSceneBuffer` against local static `public/` or `https://www.olomni.com/studio`.
4
+ */
5
+ import fs from 'node:fs'
6
+ import fsp from 'node:fs/promises'
7
+ import http from 'node:http'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+ import { execFile } from 'node:child_process'
11
+ import { createRequire } from 'node:module'
12
+ import { promisify } from 'node:util'
13
+ import JSZip from 'jszip'
14
+ import { compileSceneSpec, findPublicStudioDir, generateSrt, parseBatchInput } from './compiler.js'
15
+
16
+ const execFileAsync = promisify(execFile)
17
+
18
+ export const VALID_FORMATS = ['mp4', 'webm', 'webm_alpha', 'alpha', 'chroma']
19
+ export const VALID_FPS = [24, 30, 60]
20
+ export const VALID_QUALITIES = ['fast', 'social', 'studio']
21
+
22
+ export function validateRenderFormat(rawFormat) {
23
+ const fmt = String(rawFormat || 'mp4').trim().toLowerCase()
24
+ if (!VALID_FORMATS.includes(fmt)) {
25
+ throw new Error(`Unsupported render format "${fmt}". Supported formats: ${VALID_FORMATS.join(', ')}`)
26
+ }
27
+ return fmt
28
+ }
29
+
30
+ export function validateRenderFps(rawFps) {
31
+ if (rawFps === undefined || rawFps === null || rawFps === '') return 30
32
+ const fps = Number(rawFps)
33
+ if (!VALID_FPS.includes(fps)) {
34
+ throw new Error(`Unsupported fps "${rawFps}". Supported fps: ${VALID_FPS.join(', ')}`)
35
+ }
36
+ return fps
37
+ }
38
+
39
+ export function validateRenderQuality(rawQuality) {
40
+ const quality = String(rawQuality || 'social').trim().toLowerCase()
41
+ if (!VALID_QUALITIES.includes(quality)) {
42
+ throw new Error(`Unsupported render quality "${quality}". Supported qualities: ${VALID_QUALITIES.join(', ')}`)
43
+ }
44
+ return quality
45
+ }
46
+
47
+ const MIME_TYPES = {
48
+ '.html': 'text/html; charset=utf-8',
49
+ '.js': 'application/javascript; charset=utf-8',
50
+ '.mjs': 'application/javascript; charset=utf-8',
51
+ '.css': 'text/css; charset=utf-8',
52
+ '.json': 'application/json; charset=utf-8',
53
+ '.png': 'image/png',
54
+ '.jpg': 'image/jpeg',
55
+ '.jpeg': 'image/jpeg',
56
+ '.webp': 'image/webp',
57
+ '.svg': 'image/svg+xml',
58
+ '.glb': 'model/gltf-binary',
59
+ '.gltf': 'model/gltf+json',
60
+ '.woff2': 'font/woff2',
61
+ '.wasm': 'application/wasm',
62
+ '.mp3': 'audio/mpeg',
63
+ '.wav': 'audio/wav',
64
+ }
65
+
66
+ export function findChromeExecutable(overridePath) {
67
+ const candidates = [
68
+ overridePath,
69
+ process.env.OLO_CHROME_PATH,
70
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
71
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
72
+ '/usr/bin/google-chrome',
73
+ '/usr/bin/chromium',
74
+ '/usr/bin/chromium-browser',
75
+ ].filter(Boolean)
76
+
77
+ for (const candidate of candidates) {
78
+ if (fs.existsSync(candidate)) {
79
+ return candidate
80
+ }
81
+ }
82
+ return null
83
+ }
84
+
85
+ async function loadPlaywright() {
86
+ try {
87
+ return await import('playwright')
88
+ } catch {
89
+ // Try resolving from cwd (e.g., monorepo root devDependencies)
90
+ try {
91
+ const req = createRequire(path.join(process.cwd(), 'package.json'))
92
+ return req('playwright')
93
+ } catch {
94
+ try {
95
+ return await import('playwright-core')
96
+ } catch {
97
+ throw new Error(
98
+ 'Playwright is required for headless video rendering. Install `playwright` in your workspace or set OLO_STUDIO_URL.',
99
+ )
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ export function startStaticServer(rootDir) {
106
+ return new Promise((resolve, reject) => {
107
+ const resolvedRoot = path.resolve(rootDir)
108
+ const server = http.createServer((req, res) => {
109
+ const urlPath = (req.url || '/').split('?')[0]
110
+ let relPath = decodeURIComponent(urlPath).replace(/^\/+/, '')
111
+ if (!relPath || relPath.endsWith('/')) relPath += 'index.html'
112
+ const absPath = path.resolve(resolvedRoot, relPath)
113
+
114
+ if (!absPath.startsWith(resolvedRoot)) {
115
+ res.writeHead(403)
116
+ res.end('Forbidden')
117
+ return
118
+ }
119
+
120
+ fs.stat(absPath, (err, stat) => {
121
+ if (err || !stat.isFile()) {
122
+ // Fallback to /studio/index.html if requesting /studio
123
+ const indexFallback = path.join(absPath, 'index.html')
124
+ if (fs.existsSync(indexFallback)) {
125
+ const data = fs.readFileSync(indexFallback)
126
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
127
+ res.end(data)
128
+ return
129
+ }
130
+ res.writeHead(404)
131
+ res.end('Not Found')
132
+ return
133
+ }
134
+
135
+ const ext = path.extname(absPath).toLowerCase()
136
+ res.writeHead(200, {
137
+ 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
138
+ 'Cache-Control': 'no-cache',
139
+ })
140
+ fs.createReadStream(absPath).pipe(res)
141
+ })
142
+ })
143
+
144
+ server.on('error', reject)
145
+ server.listen(0, '127.0.0.1', () => {
146
+ const addr = server.address()
147
+ resolve({
148
+ server,
149
+ port: addr.port,
150
+ baseUrl: `http://127.0.0.1:${addr.port}`,
151
+ close: () => new Promise((resClose) => server.close(() => resClose())),
152
+ })
153
+ })
154
+ })
155
+ }
156
+
157
+ export function resolveDefaultAngleBackend() {
158
+ if (process.env.OLO_CHROME_ANGLE) return process.env.OLO_CHROME_ANGLE
159
+ if (process.platform === 'darwin') return 'metal'
160
+ if (process.platform === 'win32') return 'd3d11'
161
+ if (process.platform === 'linux') return 'vulkan'
162
+ return 'swiftshader'
163
+ }
164
+
165
+ async function launchStudioAttempt(chromium, chromePath, targetUrl, angleBackend, opts) {
166
+ const browser = await chromium.launch({
167
+ headless: true,
168
+ ...(chromePath ? { executablePath: chromePath } : {}),
169
+ args: [
170
+ '--use-gl=angle',
171
+ `--use-angle=${angleBackend}`,
172
+ '--enable-webgl',
173
+ '--ignore-gpu-blocklist',
174
+ '--autoplay-policy=no-user-gesture-required',
175
+ '--no-sandbox',
176
+ ],
177
+ })
178
+
179
+ try {
180
+ const context = await browser.newContext({
181
+ viewport: { width: 1280, height: 900 },
182
+ })
183
+ const page = await context.newPage()
184
+
185
+ await page.goto(targetUrl, {
186
+ waitUntil: 'domcontentloaded',
187
+ timeout: opts.timeoutMs || 30000,
188
+ })
189
+
190
+ await page.waitForFunction(
191
+ () => Boolean(window.OloStudioBridge && window.OloStudioBridge.ready === true),
192
+ null,
193
+ { timeout: opts.timeoutMs || 25000 },
194
+ )
195
+
196
+ const waitModelPromise = page.evaluate(async () => {
197
+ if (window.OloStudioBridge && typeof window.OloStudioBridge.waitForModelReady === 'function') {
198
+ await window.OloStudioBridge.waitForModelReady(12000)
199
+ }
200
+ })
201
+ if (angleBackend === 'swiftshader') {
202
+ await waitModelPromise.catch(() => {})
203
+ } else {
204
+ await waitModelPromise
205
+ }
206
+
207
+ return { browser, context, page, angleBackend }
208
+ } catch (err) {
209
+ await browser.close().catch(() => {})
210
+ throw err
211
+ }
212
+ }
213
+
214
+ async function openStudioSession(opts = {}) {
215
+ const { chromium } = await loadPlaywright()
216
+ const chromePath = findChromeExecutable(opts.chromePath)
217
+
218
+ let staticSrv = null
219
+ let targetUrl = opts.studioUrl || process.env.OLO_STUDIO_URL || ''
220
+
221
+ if (!targetUrl) {
222
+ const publicDir = findPublicStudioDir(opts.publicDir)
223
+ if (publicDir) {
224
+ staticSrv = await startStaticServer(publicDir)
225
+ targetUrl = `${staticSrv.baseUrl}/studio/index.html`
226
+ } else {
227
+ console.error(
228
+ '[olo-studio-mcp] Warning: local public/studio directory not found; falling back to https://www.olomni.com/studio',
229
+ )
230
+ targetUrl = 'https://www.olomni.com/studio'
231
+ }
232
+ }
233
+
234
+ const primaryAngle = resolveDefaultAngleBackend()
235
+ try {
236
+ const attempt = await launchStudioAttempt(chromium, chromePath, targetUrl, primaryAngle, opts)
237
+ return { ...attempt, staticSrv, targetUrl, chromePath }
238
+ } catch (primaryErr) {
239
+ if (primaryAngle === 'swiftshader') {
240
+ if (staticSrv) await staticSrv.close().catch(() => {})
241
+ throw primaryErr
242
+ }
243
+ try {
244
+ const fallback = await launchStudioAttempt(chromium, chromePath, targetUrl, 'swiftshader', opts)
245
+ return { ...fallback, staticSrv, targetUrl, chromePath }
246
+ } catch (fallbackErr) {
247
+ if (staticSrv) await staticSrv.close().catch(() => {})
248
+ throw fallbackErr
249
+ }
250
+ }
251
+ }
252
+
253
+ async function synthesizeOfflineSpeechWavBase64(compiled) {
254
+ if (!compiled || !Array.isArray(compiled.beats)) return ''
255
+ const spoken = compiled.beats.map((b) => String((b && b.text) || '').trim()).filter(Boolean).join('. ')
256
+ if (!spoken) return ''
257
+ const isSpanish = /[áéíóúñ¿¡]|\b(hola|para|con|tu|marca|video|soy|listo|crear|desde)\b/i.test(spoken)
258
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'olo-tts-'))
259
+ const wavPath = path.join(tmpDir, 'speech.wav')
260
+ try {
261
+ if (process.platform === 'darwin') {
262
+ const voiceName = isSpanish ? 'Paulina' : 'Samantha'
263
+ const aiffPath = path.join(tmpDir, 'speech.aiff')
264
+ await execFileAsync('/usr/bin/say', ['-v', voiceName, '-o', aiffPath, spoken], { timeout: 15000 })
265
+ await execFileAsync('/usr/bin/afconvert', ['-f', 'WAVE', '-d', 'LEI16@48000', '-c', '1', aiffPath, wavPath], { timeout: 15000 })
266
+ } else if (process.platform === 'win32') {
267
+ const psScript = [
268
+ 'Add-Type -AssemblyName System.Speech',
269
+ '$s = New-Object System.Speech.Synthesis.SpeechSynthesizer',
270
+ '$s.SetOutputToWaveFile($env:OLO_TTS_WAV)',
271
+ '$s.Speak($env:OLO_TTS_TEXT)',
272
+ '$s.Dispose()',
273
+ ].join('; ')
274
+ await execFileAsync(
275
+ 'powershell.exe',
276
+ ['-NoProfile', '-NonInteractive', '-Command', psScript],
277
+ {
278
+ timeout: 15000,
279
+ env: { ...process.env, OLO_TTS_TEXT: spoken, OLO_TTS_WAV: wavPath },
280
+ },
281
+ )
282
+ } else if (process.platform === 'linux') {
283
+ const lang = isSpanish ? 'es' : 'en'
284
+ let synthesized = false
285
+ for (const bin of ['espeak-ng', 'espeak']) {
286
+ try {
287
+ await execFileAsync(bin, ['-v', lang, '-w', wavPath, spoken], { timeout: 15000 })
288
+ synthesized = true
289
+ break
290
+ } catch {
291
+ // try next available TTS binary
292
+ }
293
+ }
294
+ if (!synthesized) return ''
295
+ } else {
296
+ return ''
297
+ }
298
+ const wavBuf = await fsp.readFile(wavPath)
299
+ return wavBuf.toString('base64')
300
+ } catch {
301
+ return ''
302
+ } finally {
303
+ await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
304
+ }
305
+ }
306
+
307
+ async function loadGlbOnPage(page, { glbPath, glbUrl } = {}) {
308
+ if (glbPath) {
309
+ const absGlb = path.resolve(glbPath)
310
+ const buf = await fsp.readFile(absGlb)
311
+ const b64 = buf.toString('base64')
312
+ await page.evaluate(
313
+ async ({ base64, fileName }) => {
314
+ const bin = atob(base64)
315
+ const bytes = new Uint8Array(bin.length)
316
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
317
+ await window.OloStudioBridge.loadGlbFromArrayBuffer(bytes.buffer, fileName)
318
+ },
319
+ { base64: b64, fileName: path.basename(absGlb) },
320
+ )
321
+ } else if (glbUrl) {
322
+ await page.evaluate(async (url) => {
323
+ await window.OloStudioBridge.loadGlbFromUrl(url)
324
+ }, glbUrl)
325
+ }
326
+ }
327
+
328
+ async function renderOnPage(page, opts) {
329
+ const compileInput = opts.sceneSpec
330
+ ? {
331
+ ...opts.sceneSpec,
332
+ ...(opts.voice !== undefined ? { voice: opts.voice } : {}),
333
+ ...(opts.voiceId !== undefined ? { voiceId: opts.voiceId } : {}),
334
+ ...(opts.captions !== undefined ? { captions: opts.captions } : {}),
335
+ ...(opts.pipPath !== undefined ? { pipPath: opts.pipPath } : {}),
336
+ ...(opts.pipUrl !== undefined ? { pipUrl: opts.pipUrl } : {}),
337
+ }
338
+ : opts
339
+ const compiled = compileSceneSpec(compileInput)
340
+ const format = validateRenderFormat(opts.format)
341
+ const requestedFps = validateRenderFps(opts.fps ?? compiled.fps)
342
+ const requestedQuality = validateRenderQuality(opts.quality ?? compiled.quality)
343
+ const scenePayload = { ...compiled, fps: requestedFps, quality: requestedQuality }
344
+ const resolvedApiKey = opts.apiKey || process.env.OLO_STUDIO_API_KEY
345
+ if (resolvedApiKey) {
346
+ scenePayload.apiKey = resolvedApiKey
347
+ }
348
+ if (!scenePayload.customAudioWavBase64 && scenePayload.voice !== 'synth') {
349
+ const wavB64 = await synthesizeOfflineSpeechWavBase64(scenePayload)
350
+ if (wavB64) {
351
+ if (resolvedApiKey) {
352
+ scenePayload.fallbackAudioWavBase64 = wavB64
353
+ } else {
354
+ scenePayload.customAudioWavBase64 = wavB64
355
+ }
356
+ }
357
+ }
358
+
359
+ await loadGlbOnPage(page, opts)
360
+
361
+ const exportResult = await page.evaluate(
362
+ async ({ scene, fmt }) => {
363
+ const res = await window.OloStudioBridge.exportSceneBuffer(scene, fmt)
364
+ let base64 = ''
365
+ if (typeof FileReader !== 'undefined') {
366
+ base64 = await new Promise((resolve, reject) => {
367
+ const reader = new FileReader()
368
+ reader.onload = () => {
369
+ const dataUrl = String(reader.result || '')
370
+ const marker = 'base64,'
371
+ const idx = dataUrl.indexOf(marker)
372
+ resolve(idx >= 0 ? dataUrl.slice(idx + marker.length) : dataUrl)
373
+ }
374
+ reader.onerror = () => reject(reader.error || new Error('FileReader failed'))
375
+ reader.readAsDataURL(new Blob([res.arrayBuffer]))
376
+ })
377
+ } else {
378
+ const u8 = new Uint8Array(res.arrayBuffer)
379
+ const CHUNK = 0x8000
380
+ const parts = []
381
+ for (let i = 0; i < u8.length; i += CHUNK) {
382
+ parts.push(String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK)))
383
+ }
384
+ base64 = btoa(parts.join(''))
385
+ }
386
+ return {
387
+ base64,
388
+ mimeType: res.mimeType,
389
+ ext: res.ext,
390
+ durationSec: res.durationSec,
391
+ watermarked: res.watermarked,
392
+ beats: Array.isArray(res.beats) ? res.beats : null,
393
+ }
394
+ },
395
+ { scene: scenePayload, fmt: format },
396
+ )
397
+
398
+ const buffer = Buffer.from(exportResult.base64, 'base64')
399
+ const outPath = path.resolve(opts.outputPath || `olo-render-${compiled.sceneHash}.${exportResult.ext || 'mp4'}`)
400
+ await fsp.mkdir(path.dirname(outPath), { recursive: true })
401
+ await fsp.writeFile(outPath, buffer)
402
+
403
+ let srtPath = null
404
+ if (opts.srt !== false) {
405
+ const srtContent = generateSrt({
406
+ ...compiled,
407
+ beats: exportResult.beats || compiled.beats,
408
+ })
409
+ if (srtContent) {
410
+ const outputBase = outPath.slice(0, outPath.length - path.extname(outPath).length)
411
+ srtPath = `${outputBase}.srt`
412
+ await fsp.writeFile(srtPath, srtContent, 'utf8')
413
+ }
414
+ }
415
+
416
+ return {
417
+ ok: true,
418
+ outputPath: outPath,
419
+ srtPath,
420
+ bytes: buffer.byteLength,
421
+ mimeType: exportResult.mimeType,
422
+ ext: exportResult.ext,
423
+ durationSec: exportResult.durationSec,
424
+ watermarked: exportResult.watermarked,
425
+ sceneHash: compiled.sceneHash,
426
+ title: compiled.title,
427
+ }
428
+ }
429
+
430
+ export async function renderVideoWithBrowser(opts = {}) {
431
+ validateRenderFormat(opts.format)
432
+ if (opts.fps !== undefined) validateRenderFps(opts.fps)
433
+ if (opts.quality !== undefined) validateRenderQuality(opts.quality)
434
+ const session = await openStudioSession(opts)
435
+ try {
436
+ const res = await renderOnPage(session.page, opts)
437
+ return {
438
+ ...res,
439
+ studioUrl: session.targetUrl,
440
+ chromePath: session.chromePath || 'playwright-chromium',
441
+ }
442
+ } finally {
443
+ await session.browser.close().catch(() => {})
444
+ if (session.staticSrv) await session.staticSrv.close().catch(() => {})
445
+ }
446
+ }
447
+
448
+ export async function renderBatchWithBrowser(opts = {}) {
449
+ const format = validateRenderFormat(opts.format)
450
+ const batchFps = opts.fps !== undefined ? validateRenderFps(opts.fps) : undefined
451
+ const batchQuality = opts.quality !== undefined ? validateRenderQuality(opts.quality) : undefined
452
+ const usedDirectVariants = Array.isArray(opts.variants) && opts.variants.length > 0
453
+ let variants = usedDirectVariants ? opts.variants : []
454
+
455
+ if (variants.length === 0) {
456
+ let csvContent = opts.csv
457
+ if (!csvContent && opts.csvPath) {
458
+ csvContent = await fsp.readFile(path.resolve(opts.csvPath), 'utf8')
459
+ }
460
+ const batchDefaults = {
461
+ ...(opts.voice !== undefined ? { voice: opts.voice } : {}),
462
+ ...(opts.voiceId !== undefined ? { voiceId: opts.voiceId } : {}),
463
+ ...(opts.captions !== undefined ? { captions: opts.captions } : {}),
464
+ ...(opts.pipPath !== undefined ? { pipPath: opts.pipPath } : {}),
465
+ ...(opts.pipUrl !== undefined ? { pipUrl: opts.pipUrl } : {}),
466
+ }
467
+ variants = parseBatchInput({ specs: opts.specs, csv: csvContent, defaults: batchDefaults })
468
+ }
469
+
470
+ if (variants.length === 0) {
471
+ throw new Error('variants array must be non-empty')
472
+ }
473
+
474
+ const outputDir = path.resolve(opts.outputDir || './olo-batch-renders')
475
+ const ext = format.includes('webm') || format === 'alpha' ? 'webm' : 'mp4'
476
+
477
+ const session = await openStudioSession(opts)
478
+ const results = []
479
+ try {
480
+ const hasBatchGlb = Boolean(opts.glbPath || opts.glbUrl)
481
+ const hasVariantGlbOverride = variants.some((v) => Boolean(v.glbPath || v.glbUrl))
482
+ const hoistSharedGlb = hasBatchGlb && !hasVariantGlbOverride
483
+ if (hoistSharedGlb) {
484
+ await loadGlbOnPage(session.page, { glbPath: opts.glbPath, glbUrl: opts.glbUrl })
485
+ }
486
+
487
+ let activeGlbKey = hoistSharedGlb
488
+ ? (opts.glbPath ? `path:${path.resolve(opts.glbPath)}` : `url:${opts.glbUrl}`)
489
+ : null
490
+
491
+ for (const variant of variants) {
492
+ const outPath = path.join(outputDir, `${variant.outputName}.${ext}`)
493
+ const effectiveGlbPath = variant.glbPath || (!variant.glbUrl ? opts.glbPath : undefined)
494
+ const effectiveGlbUrl = variant.glbUrl || (!variant.glbPath ? opts.glbUrl : undefined)
495
+ const effectiveGlbKey = effectiveGlbPath
496
+ ? `path:${path.resolve(effectiveGlbPath)}`
497
+ : effectiveGlbUrl
498
+ ? `url:${effectiveGlbUrl}`
499
+ : null
500
+ const needsGlbLoad = Boolean(effectiveGlbKey && effectiveGlbKey !== activeGlbKey)
501
+ if (needsGlbLoad) {
502
+ activeGlbKey = effectiveGlbKey
503
+ }
504
+
505
+ const rendered = await renderOnPage(session.page, {
506
+ sceneSpec: variant.sceneSpec,
507
+ format,
508
+ fps: variant.fps ?? batchFps,
509
+ quality: variant.quality ?? batchQuality,
510
+ srt: opts.srt,
511
+ outputPath: outPath,
512
+ apiKey: variant.apiKey || opts.apiKey,
513
+ ...(usedDirectVariants
514
+ ? {
515
+ voice: variant.voice ?? (variant.sceneSpec?.voice === undefined ? opts.voice : undefined),
516
+ voiceId: variant.voiceId ?? (variant.sceneSpec?.voiceId === undefined ? opts.voiceId : undefined),
517
+ captions: variant.captions ?? (variant.sceneSpec?.captions === undefined ? opts.captions : undefined),
518
+ pipPath: variant.pipPath ?? (!variant.sceneSpec?.pipMeta ? opts.pipPath : undefined),
519
+ pipUrl: variant.pipUrl ?? (!variant.sceneSpec?.pipMeta ? opts.pipUrl : undefined),
520
+ }
521
+ : {}),
522
+ ...(needsGlbLoad ? { glbUrl: effectiveGlbUrl, glbPath: effectiveGlbPath } : {}),
523
+ })
524
+ results.push({
525
+ index: variant.index,
526
+ outputName: variant.outputName,
527
+ ...rendered,
528
+ })
529
+ }
530
+
531
+ await fsp.mkdir(outputDir, { recursive: true })
532
+ const manifestPath = path.join(outputDir, 'manifest.json')
533
+ const manifest = {
534
+ ok: true,
535
+ format,
536
+ count: results.length,
537
+ studioUrl: session.targetUrl,
538
+ generatedAt: new Date().toISOString(),
539
+ results,
540
+ }
541
+ await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
542
+
543
+ let zipPath = null
544
+ if (opts.zip !== false) {
545
+ const zip = new JSZip()
546
+ zip.file('manifest.json', await fsp.readFile(manifestPath))
547
+ for (const item of results) {
548
+ if (item.outputPath && fs.existsSync(item.outputPath)) {
549
+ zip.file(path.basename(item.outputPath), await fsp.readFile(item.outputPath))
550
+ }
551
+ if (item.srtPath && fs.existsSync(item.srtPath)) {
552
+ zip.file(path.basename(item.srtPath), await fsp.readFile(item.srtPath))
553
+ }
554
+ }
555
+ zipPath = path.join(outputDir, 'batch.zip')
556
+ const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
557
+ await fsp.writeFile(zipPath, zipBuffer)
558
+ }
559
+
560
+ return {
561
+ ok: true,
562
+ count: results.length,
563
+ outputDir,
564
+ manifestPath,
565
+ zipPath,
566
+ studioUrl: session.targetUrl,
567
+ results,
568
+ }
569
+ } finally {
570
+ await session.browser.close().catch(() => {})
571
+ if (session.staticSrv) await session.staticSrv.close().catch(() => {})
572
+ }
573
+ }
574
+
575
+ export async function inspectGlb(source) {
576
+ if (!source || typeof source !== 'string') {
577
+ throw new Error('GLB file path or https:// URL is required.')
578
+ }
579
+
580
+ let buffer
581
+ if (/^https?:\/\//i.test(source)) {
582
+ const res = await fetch(source, { signal: AbortSignal.timeout(15000) })
583
+ if (!res.ok) throw new Error(`Failed to fetch GLB (${res.status})`)
584
+ buffer = Buffer.from(await res.arrayBuffer())
585
+ } else {
586
+ buffer = await fsp.readFile(path.resolve(source))
587
+ }
588
+
589
+ if (buffer.byteLength < 20) {
590
+ throw new Error('File too small to be a valid GLB binary.')
591
+ }
592
+
593
+ const magic = buffer.readUInt32LE(0)
594
+ if (magic !== 0x46546c67) {
595
+ throw new Error(`Invalid GLB magic header: 0x${magic.toString(16)}`)
596
+ }
597
+
598
+ const version = buffer.readUInt32LE(4)
599
+ const totalLength = buffer.readUInt32LE(8)
600
+ const chunkLength = buffer.readUInt32LE(12)
601
+ const chunkType = buffer.readUInt32LE(16)
602
+
603
+ if (chunkType !== 0x4e4f534a) {
604
+ throw new Error('First GLB chunk is not JSON.')
605
+ }
606
+
607
+ const jsonStr = buffer.subarray(20, 20 + chunkLength).toString('utf8').replace(/\0+$/, '')
608
+ const gltf = JSON.parse(jsonStr)
609
+
610
+ return {
611
+ ok: true,
612
+ source,
613
+ version,
614
+ byteLength: totalLength,
615
+ generator: gltf.asset?.generator || null,
616
+ scenes: Array.isArray(gltf.scenes) ? gltf.scenes.length : 0,
617
+ nodes: Array.isArray(gltf.nodes) ? gltf.nodes.length : 0,
618
+ meshes: Array.isArray(gltf.meshes) ? gltf.meshes.map((m) => m.name || 'unnamed') : [],
619
+ animations: Array.isArray(gltf.animations) ? gltf.animations.map((a) => a.name || 'unnamed') : [],
620
+ materials: Array.isArray(gltf.materials) ? gltf.materials.map((m) => m.name || 'unnamed') : [],
621
+ }
622
+ }