@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/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
- async function openStudioSession(opts = {}) {
134
- const { chromium } = await loadPlaywright()
135
- const chromePath = findChromeExecutable(opts.chromePath)
136
-
137
- let staticSrv = null
138
- let targetUrl = opts.studioUrl || process.env.OLO_STUDIO_URL || ''
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
- '--use-angle=swiftshader',
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
- return { browser, context, page, staticSrv, targetUrl, chromePath }
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 renderOnPage(page, opts) {
189
- const compiled = compileSceneSpec(opts.sceneSpec || opts)
190
- const format = validateRenderFormat(opts.format)
214
+ async function openStudioSession(opts = {}) {
215
+ const { chromium } = await loadPlaywright()
216
+ const chromePath = findChromeExecutable(opts.chromePath)
191
217
 
192
- if (opts.glbPath) {
193
- const absGlb = path.resolve(opts.glbPath)
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 (opts.glbUrl) {
321
+ } else if (glbUrl) {
206
322
  await page.evaluate(async (url) => {
207
323
  await window.OloStudioBridge.loadGlbFromUrl(url)
208
- }, opts.glbUrl)
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
- const bytes = Array.from(new Uint8Array(res.arrayBuffer))
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
- bytes,
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: compiled, fmt: format },
395
+ { scene: scenePayload, fmt: format },
224
396
  )
225
397
 
226
- const buffer = Buffer.from(exportResult.bytes)
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(compiled)
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
- let variants = Array.isArray(opts.variants) ? opts.variants : []
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
- variants = parseBatchInput({ specs: opts.specs, csv: csvContent })
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
- glbUrl: variant.glbUrl || opts.glbUrl,
301
- glbPath: variant.glbPath || opts.glbPath,
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
- * @olomni/studio-mcp — Local-first MCP Server (JSON-RPC 2.0 over stdio)
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: '@olomni/studio-mcp',
47
- version: '1.0.0',
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(args)
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(args)
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(