@olo-studio/studio-mcp 1.0.0 → 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 +2 -2
- package/bin/olo-studio-mcp.js +10 -3
- package/package.json +5 -2
- package/src/compiler.js +60 -13
- package/src/director.js +1098 -0
- package/src/renderer.js +256 -35
- package/src/server.js +33 -6
package/src/renderer.js
CHANGED
|
@@ -5,12 +5,19 @@
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import fsp from 'node:fs/promises'
|
|
7
7
|
import http from 'node:http'
|
|
8
|
+
import os from 'node:os'
|
|
8
9
|
import path from 'node:path'
|
|
10
|
+
import { execFile } from 'node:child_process'
|
|
9
11
|
import { createRequire } from 'node:module'
|
|
12
|
+
import { promisify } from 'node:util'
|
|
10
13
|
import JSZip from 'jszip'
|
|
11
14
|
import { compileSceneSpec, findPublicStudioDir, generateSrt, parseBatchInput } from './compiler.js'
|
|
12
15
|
|
|
16
|
+
const execFileAsync = promisify(execFile)
|
|
17
|
+
|
|
13
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']
|
|
14
21
|
|
|
15
22
|
export function validateRenderFormat(rawFormat) {
|
|
16
23
|
const fmt = String(rawFormat || 'mp4').trim().toLowerCase()
|
|
@@ -20,6 +27,23 @@ export function validateRenderFormat(rawFormat) {
|
|
|
20
27
|
return fmt
|
|
21
28
|
}
|
|
22
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
|
+
|
|
23
47
|
const MIME_TYPES = {
|
|
24
48
|
'.html': 'text/html; charset=utf-8',
|
|
25
49
|
'.js': 'application/javascript; charset=utf-8',
|
|
@@ -130,29 +154,21 @@ export function startStaticServer(rootDir) {
|
|
|
130
154
|
})
|
|
131
155
|
}
|
|
132
156
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (!targetUrl) {
|
|
141
|
-
const publicDir = findPublicStudioDir(opts.publicDir)
|
|
142
|
-
if (publicDir) {
|
|
143
|
-
staticSrv = await startStaticServer(publicDir)
|
|
144
|
-
targetUrl = `${staticSrv.baseUrl}/studio/index.html`
|
|
145
|
-
} else {
|
|
146
|
-
targetUrl = 'https://www.olomni.com/studio'
|
|
147
|
-
}
|
|
148
|
-
}
|
|
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
|
+
}
|
|
149
164
|
|
|
165
|
+
async function launchStudioAttempt(chromium, chromePath, targetUrl, angleBackend, opts) {
|
|
150
166
|
const browser = await chromium.launch({
|
|
151
167
|
headless: true,
|
|
152
168
|
...(chromePath ? { executablePath: chromePath } : {}),
|
|
153
169
|
args: [
|
|
154
170
|
'--use-gl=angle',
|
|
155
|
-
|
|
171
|
+
`--use-angle=${angleBackend}`,
|
|
156
172
|
'--enable-webgl',
|
|
157
173
|
'--ignore-gpu-blocklist',
|
|
158
174
|
'--autoplay-policy=no-user-gesture-required',
|
|
@@ -177,20 +193,120 @@ async function openStudioSession(opts = {}) {
|
|
|
177
193
|
{ timeout: opts.timeoutMs || 25000 },
|
|
178
194
|
)
|
|
179
195
|
|
|
180
|
-
|
|
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 }
|
|
181
208
|
} catch (err) {
|
|
182
209
|
await browser.close().catch(() => {})
|
|
183
|
-
if (staticSrv) await staticSrv.close().catch(() => {})
|
|
184
210
|
throw err
|
|
185
211
|
}
|
|
186
212
|
}
|
|
187
213
|
|
|
188
|
-
async function
|
|
189
|
-
const
|
|
190
|
-
const
|
|
214
|
+
async function openStudioSession(opts = {}) {
|
|
215
|
+
const { chromium } = await loadPlaywright()
|
|
216
|
+
const chromePath = findChromeExecutable(opts.chromePath)
|
|
191
217
|
|
|
192
|
-
|
|
193
|
-
|
|
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)
|
|
194
310
|
const buf = await fsp.readFile(absGlb)
|
|
195
311
|
const b64 = buf.toString('base64')
|
|
196
312
|
await page.evaluate(
|
|
@@ -202,35 +318,94 @@ async function renderOnPage(page, opts) {
|
|
|
202
318
|
},
|
|
203
319
|
{ base64: b64, fileName: path.basename(absGlb) },
|
|
204
320
|
)
|
|
205
|
-
} else if (
|
|
321
|
+
} else if (glbUrl) {
|
|
206
322
|
await page.evaluate(async (url) => {
|
|
207
323
|
await window.OloStudioBridge.loadGlbFromUrl(url)
|
|
208
|
-
},
|
|
324
|
+
}, glbUrl)
|
|
209
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)
|
|
210
360
|
|
|
211
361
|
const exportResult = await page.evaluate(
|
|
212
362
|
async ({ scene, fmt }) => {
|
|
213
363
|
const res = await window.OloStudioBridge.exportSceneBuffer(scene, fmt)
|
|
214
|
-
|
|
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
|
+
}
|
|
215
386
|
return {
|
|
216
|
-
|
|
387
|
+
base64,
|
|
217
388
|
mimeType: res.mimeType,
|
|
218
389
|
ext: res.ext,
|
|
219
390
|
durationSec: res.durationSec,
|
|
220
391
|
watermarked: res.watermarked,
|
|
392
|
+
beats: Array.isArray(res.beats) ? res.beats : null,
|
|
221
393
|
}
|
|
222
394
|
},
|
|
223
|
-
{ scene:
|
|
395
|
+
{ scene: scenePayload, fmt: format },
|
|
224
396
|
)
|
|
225
397
|
|
|
226
|
-
const buffer = Buffer.from(exportResult.
|
|
398
|
+
const buffer = Buffer.from(exportResult.base64, 'base64')
|
|
227
399
|
const outPath = path.resolve(opts.outputPath || `olo-render-${compiled.sceneHash}.${exportResult.ext || 'mp4'}`)
|
|
228
400
|
await fsp.mkdir(path.dirname(outPath), { recursive: true })
|
|
229
401
|
await fsp.writeFile(outPath, buffer)
|
|
230
402
|
|
|
231
403
|
let srtPath = null
|
|
232
404
|
if (opts.srt !== false) {
|
|
233
|
-
const srtContent = generateSrt(
|
|
405
|
+
const srtContent = generateSrt({
|
|
406
|
+
...compiled,
|
|
407
|
+
beats: exportResult.beats || compiled.beats,
|
|
408
|
+
})
|
|
234
409
|
if (srtContent) {
|
|
235
410
|
const outputBase = outPath.slice(0, outPath.length - path.extname(outPath).length)
|
|
236
411
|
srtPath = `${outputBase}.srt`
|
|
@@ -254,6 +429,8 @@ async function renderOnPage(page, opts) {
|
|
|
254
429
|
|
|
255
430
|
export async function renderVideoWithBrowser(opts = {}) {
|
|
256
431
|
validateRenderFormat(opts.format)
|
|
432
|
+
if (opts.fps !== undefined) validateRenderFps(opts.fps)
|
|
433
|
+
if (opts.quality !== undefined) validateRenderQuality(opts.quality)
|
|
257
434
|
const session = await openStudioSession(opts)
|
|
258
435
|
try {
|
|
259
436
|
const res = await renderOnPage(session.page, opts)
|
|
@@ -270,14 +447,24 @@ export async function renderVideoWithBrowser(opts = {}) {
|
|
|
270
447
|
|
|
271
448
|
export async function renderBatchWithBrowser(opts = {}) {
|
|
272
449
|
const format = validateRenderFormat(opts.format)
|
|
273
|
-
|
|
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 : []
|
|
274
454
|
|
|
275
455
|
if (variants.length === 0) {
|
|
276
456
|
let csvContent = opts.csv
|
|
277
457
|
if (!csvContent && opts.csvPath) {
|
|
278
458
|
csvContent = await fsp.readFile(path.resolve(opts.csvPath), 'utf8')
|
|
279
459
|
}
|
|
280
|
-
|
|
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 })
|
|
281
468
|
}
|
|
282
469
|
|
|
283
470
|
if (variants.length === 0) {
|
|
@@ -290,15 +477,49 @@ export async function renderBatchWithBrowser(opts = {}) {
|
|
|
290
477
|
const session = await openStudioSession(opts)
|
|
291
478
|
const results = []
|
|
292
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
|
+
|
|
293
491
|
for (const variant of variants) {
|
|
294
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
|
+
|
|
295
505
|
const rendered = await renderOnPage(session.page, {
|
|
296
506
|
sceneSpec: variant.sceneSpec,
|
|
297
507
|
format,
|
|
508
|
+
fps: variant.fps ?? batchFps,
|
|
509
|
+
quality: variant.quality ?? batchQuality,
|
|
298
510
|
srt: opts.srt,
|
|
299
511
|
outputPath: outPath,
|
|
300
|
-
|
|
301
|
-
|
|
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 } : {}),
|
|
302
523
|
})
|
|
303
524
|
results.push({
|
|
304
525
|
index: variant.index,
|
package/src/server.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @
|
|
2
|
+
* @olo-studio/studio-mcp — Local-first MCP Server (JSON-RPC 2.0 over stdio)
|
|
3
3
|
* Registers the 7 canonical Olomni Studio tools via @modelcontextprotocol/sdk.
|
|
4
4
|
*/
|
|
5
|
+
import fs from 'node:fs'
|
|
5
6
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
6
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
7
8
|
import { z } from 'zod'
|
|
8
9
|
import { getEntitlements, generateScript, manageVoiceClones } from './api-client.js'
|
|
10
|
+
|
|
11
|
+
const PKG_VERSION = JSON.parse(
|
|
12
|
+
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
13
|
+
).version
|
|
9
14
|
import {
|
|
10
15
|
compileSceneSpec,
|
|
11
16
|
buildPublishEmbed,
|
|
@@ -20,7 +25,7 @@ import {
|
|
|
20
25
|
FINISH_ALIASES,
|
|
21
26
|
SCENARIO_ALIASES,
|
|
22
27
|
} from './compiler.js'
|
|
23
|
-
import { VALID_FORMATS, renderVideoWithBrowser, renderBatchWithBrowser } from './renderer.js'
|
|
28
|
+
import { VALID_FORMATS, VALID_QUALITIES, renderVideoWithBrowser, renderBatchWithBrowser } from './renderer.js'
|
|
24
29
|
|
|
25
30
|
function enumWithAliases(canonical, aliases, label) {
|
|
26
31
|
return z
|
|
@@ -43,8 +48,8 @@ function jsonToolResult(payload, isError = false) {
|
|
|
43
48
|
|
|
44
49
|
export function createStudioMcpServer(serverOpts = {}) {
|
|
45
50
|
const server = new McpServer({
|
|
46
|
-
name: '@
|
|
47
|
-
version:
|
|
51
|
+
name: '@olo-studio/studio-mcp',
|
|
52
|
+
version: PKG_VERSION,
|
|
48
53
|
})
|
|
49
54
|
|
|
50
55
|
// 1. olo_get_entitlements
|
|
@@ -131,6 +136,8 @@ export function createStudioMcpServer(serverOpts = {}) {
|
|
|
131
136
|
{
|
|
132
137
|
outputPath: z.string().min(1).describe('Destination file path for the rendered video (e.g. ./out/demo.mp4).'),
|
|
133
138
|
format: z.enum(VALID_FORMATS).default('mp4').describe('Output container/mode: mp4, webm, webm_alpha, alpha, chroma.'),
|
|
139
|
+
fps: z.union([z.literal(24), z.literal(30), z.literal(60)]).default(30).describe('Frames per second: 24, 30, or 60 (default: 30).'),
|
|
140
|
+
quality: z.enum(VALID_QUALITIES).default('social').describe('Render quality preset: fast, social, or studio (default: social).'),
|
|
134
141
|
srt: z.boolean().default(true).describe('Write <outputBase>.srt timed captions alongside the video (default: true).'),
|
|
135
142
|
sceneSpec: z.record(z.any()).optional().describe('Pre-compiled olomni.studio.scene.v1 JSON.'),
|
|
136
143
|
script: z.string().optional().describe('Raw script text if sceneSpec is omitted.'),
|
|
@@ -139,14 +146,23 @@ export function createStudioMcpServer(serverOpts = {}) {
|
|
|
139
146
|
tint: z.string().optional(),
|
|
140
147
|
finish: z.string().optional(),
|
|
141
148
|
scenario: z.string().optional(),
|
|
149
|
+
voice: z.string().optional().describe('Voice preset or TTS voice name.'),
|
|
150
|
+
voiceId: z.string().optional().describe('Cloned voice ID for neural TTS.'),
|
|
151
|
+
captions: z.boolean().optional().describe('Include timed captions/subtitles (default: true).'),
|
|
152
|
+
pipPath: z.string().optional().describe('Local file path for Picture-in-Picture media (png, jpg, webp, mp4, webm).'),
|
|
153
|
+
pipUrl: z.string().optional().describe('Remote URL for Picture-in-Picture media.'),
|
|
142
154
|
glbUrl: z.string().optional().describe('Optional custom mascot .glb/.gltf URL.'),
|
|
143
155
|
glbPath: z.string().optional().describe('Optional local .glb/.gltf file path.'),
|
|
144
156
|
studioUrl: z.string().optional().describe('Optional Studio URL (defaults to local static server or https://www.olomni.com/studio).'),
|
|
157
|
+
apiKey: z.string().optional().describe('Optional olo_pat_... token (defaults to OLO_STUDIO_API_KEY env var).'),
|
|
145
158
|
timeoutMs: z.number().int().positive().optional(),
|
|
146
159
|
},
|
|
147
160
|
async (args) => {
|
|
148
161
|
try {
|
|
149
|
-
const result = await renderVideoWithBrowser(
|
|
162
|
+
const result = await renderVideoWithBrowser({
|
|
163
|
+
...args,
|
|
164
|
+
apiKey: args.apiKey || serverOpts.apiKey,
|
|
165
|
+
})
|
|
150
166
|
return jsonToolResult(result)
|
|
151
167
|
} catch (err) {
|
|
152
168
|
return jsonToolResult(
|
|
@@ -164,19 +180,30 @@ export function createStudioMcpServer(serverOpts = {}) {
|
|
|
164
180
|
{
|
|
165
181
|
outputDir: z.string().min(1).describe('Directory where rendered variant videos will be saved.'),
|
|
166
182
|
format: z.enum(VALID_FORMATS).default('mp4').describe('Output container/mode: mp4, webm, webm_alpha, alpha, chroma.'),
|
|
183
|
+
fps: z.union([z.literal(24), z.literal(30), z.literal(60)]).default(30).describe('Frames per second: 24, 30, or 60 (default: 30).'),
|
|
184
|
+
quality: z.enum(VALID_QUALITIES).default('social').describe('Render quality preset: fast, social, or studio (default: social).'),
|
|
167
185
|
specs: z.array(z.record(z.any())).optional().describe('Array of SceneSpec or compile-option objects.'),
|
|
168
186
|
csv: z.string().optional().describe('CSV string with header row (e.g. title,language,script,wear,tint,scenario,voice,voiceId,outputName).'),
|
|
169
187
|
csvPath: z.string().optional().describe('Path to a local CSV file with variant rows.'),
|
|
170
188
|
zip: z.boolean().default(true).describe('Package rendered videos, .srt files, and manifest.json into <outputDir>/batch.zip (default: true).'),
|
|
171
189
|
srt: z.boolean().default(true).describe('Write .srt timed captions alongside each video (default: true).'),
|
|
190
|
+
voice: z.string().optional().describe('Default voice preset or TTS voice name for variants.'),
|
|
191
|
+
voiceId: z.string().optional().describe('Default cloned voice ID for neural TTS.'),
|
|
192
|
+
captions: z.boolean().optional().describe('Include timed captions/subtitles by default (default: true).'),
|
|
193
|
+
pipPath: z.string().optional().describe('Default local file path for Picture-in-Picture media (png, jpg, webp, mp4, webm).'),
|
|
194
|
+
pipUrl: z.string().optional().describe('Default remote URL for Picture-in-Picture media.'),
|
|
172
195
|
glbUrl: z.string().optional(),
|
|
173
196
|
glbPath: z.string().optional(),
|
|
174
197
|
studioUrl: z.string().optional(),
|
|
198
|
+
apiKey: z.string().optional().describe('Optional olo_pat_... token (defaults to OLO_STUDIO_API_KEY env var).'),
|
|
175
199
|
timeoutMs: z.number().int().positive().optional(),
|
|
176
200
|
},
|
|
177
201
|
async (args) => {
|
|
178
202
|
try {
|
|
179
|
-
const result = await renderBatchWithBrowser(
|
|
203
|
+
const result = await renderBatchWithBrowser({
|
|
204
|
+
...args,
|
|
205
|
+
apiKey: args.apiKey || serverOpts.apiKey,
|
|
206
|
+
})
|
|
180
207
|
return jsonToolResult(result)
|
|
181
208
|
} catch (err) {
|
|
182
209
|
return jsonToolResult(
|