@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.
- package/README.md +42 -2
- package/bin/olo-studio-mcp.js +145 -0
- package/package.json +48 -4
- package/src/api-client.js +271 -0
- package/src/compiler.js +654 -0
- package/src/director.js +1098 -0
- package/src/renderer.js +622 -0
- package/src/server.js +277 -0
package/src/renderer.js
ADDED
|
@@ -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
|
+
}
|