@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,654 @@
1
+ /**
2
+ * SceneSpec v1 (`olomni.studio.scene.v1`) compiler, batch parser, and <olo-presence> embed builder.
3
+ * Uses Wave 1 `public/studio/engine/director.js` when available, with a deterministic offline fallback.
4
+ */
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import vm from 'node:vm'
8
+ import { createRequire } from 'node:module'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const require = createRequire(import.meta.url)
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
13
+
14
+ export const VALID_STATES = [
15
+ 'idle',
16
+ 'sleeping',
17
+ 'thinking',
18
+ 'working',
19
+ 'needs_approval',
20
+ 'done',
21
+ 'error',
22
+ 'alert',
23
+ 'reading',
24
+ 'writing',
25
+ 'searching',
26
+ 'running',
27
+ 'browsing',
28
+ 'planning',
29
+ 'delegating',
30
+ 'tool',
31
+ 'curious',
32
+ 'boop',
33
+ 'proud',
34
+ 'yawn',
35
+ 'look',
36
+ ]
37
+
38
+ export const VALID_SCENARIOS = [
39
+ 'studio',
40
+ 'lofi',
41
+ 'gamer',
42
+ 'podcast',
43
+ 'news',
44
+ 'car',
45
+ 'court',
46
+ 'comedy',
47
+ 'sunset',
48
+ ]
49
+
50
+ export const VALID_WEARS = [
51
+ 'none',
52
+ 'corona',
53
+ 'gafas_sol',
54
+ 'auriculares',
55
+ 'mago',
56
+ 'laurel',
57
+ 'boina',
58
+ 'casco',
59
+ 'gafas',
60
+ 'bufanda',
61
+ 'aureola',
62
+ 'flor',
63
+ 'lazo',
64
+ ]
65
+
66
+ export const VALID_TINTS = [
67
+ 'bone',
68
+ 'mint',
69
+ 'peach',
70
+ 'lilac',
71
+ 'sky',
72
+ 'butter',
73
+ 'rose',
74
+ 'graphite',
75
+ ]
76
+
77
+ export const VALID_FINISHES = [
78
+ 'vinyl',
79
+ 'chrome',
80
+ 'frosted',
81
+ 'gold24k',
82
+ 'obsidian',
83
+ 'cyberglow',
84
+ 'uranium',
85
+ ]
86
+
87
+ export const STATE_ALIASES = {
88
+ shocked: 'alert',
89
+ cringe: 'error',
90
+ happy: 'proud',
91
+ sarcastic: 'curious',
92
+ excited: 'proud',
93
+ sad: 'sleeping',
94
+ angry: 'error',
95
+ neutral: 'idle',
96
+ calm: 'idle',
97
+ think: 'thinking',
98
+ work: 'working',
99
+ read: 'reading',
100
+ write: 'writing',
101
+ search: 'searching',
102
+ run: 'running',
103
+ browse: 'browsing',
104
+ plan: 'planning',
105
+ delegate: 'delegating',
106
+ }
107
+
108
+ export const WEAR_ALIASES = {
109
+ gorra: 'boina',
110
+ cap: 'boina',
111
+ audifonos: 'auriculares',
112
+ headphones: 'auriculares',
113
+ lentes_pro: 'gafas',
114
+ glasses: 'gafas',
115
+ sunglasses: 'gafas_sol',
116
+ crown: 'corona',
117
+ wizard: 'mago',
118
+ helmet: 'casco',
119
+ scarf: 'bufanda',
120
+ halo: 'aureola',
121
+ flower: 'flor',
122
+ bow: 'lazo',
123
+ }
124
+
125
+ export const TINT_ALIASES = {
126
+ cobalt: 'sky',
127
+ jade: 'mint',
128
+ amber: 'butter',
129
+ obsidian: 'graphite',
130
+ }
131
+
132
+ export const SCENARIO_ALIASES = {
133
+ lofi_night: 'lofi',
134
+ cyber: 'gamer',
135
+ cyberpunk: 'gamer',
136
+ arcade: 'gamer',
137
+ neon_city: 'gamer',
138
+ minimal: 'studio',
139
+ stage: 'comedy',
140
+ }
141
+
142
+ export const FINISH_ALIASES = {
143
+ matte: 'vinyl',
144
+ glass: 'frosted',
145
+ gold: 'gold24k',
146
+ neon: 'cyberglow',
147
+ }
148
+
149
+ const PIP_MIME_TYPES = {
150
+ '.png': 'image/png',
151
+ '.jpg': 'image/jpeg',
152
+ '.jpeg': 'image/jpeg',
153
+ '.webp': 'image/webp',
154
+ '.mp4': 'video/mp4',
155
+ '.webm': 'video/webm',
156
+ }
157
+
158
+ /**
159
+ * Normalize an enum value against canonical values and legacy aliases.
160
+ */
161
+ function normalizeEnumValue(raw, validList, aliasMap, fallback) {
162
+ if (raw === undefined || raw === null || String(raw).trim() === '') return fallback
163
+ const clean = String(raw).trim().toLowerCase()
164
+ if (validList.includes(clean)) return clean
165
+ if (aliasMap && aliasMap[clean] && validList.includes(aliasMap[clean])) return aliasMap[clean]
166
+ return fallback
167
+ }
168
+
169
+ export function normalizeState(raw, fallback = null) {
170
+ return normalizeEnumValue(raw, VALID_STATES, STATE_ALIASES, fallback)
171
+ }
172
+
173
+ export function normalizeWear(raw, fallback = 'gafas_sol') {
174
+ return normalizeEnumValue(raw, VALID_WEARS, WEAR_ALIASES, fallback)
175
+ }
176
+
177
+ export function normalizeTint(raw, fallback = 'rose') {
178
+ return normalizeEnumValue(raw, VALID_TINTS, TINT_ALIASES, fallback)
179
+ }
180
+
181
+ export function normalizeFinish(raw, fallback = 'vinyl') {
182
+ return normalizeEnumValue(raw, VALID_FINISHES, FINISH_ALIASES, fallback)
183
+ }
184
+
185
+ export function normalizeScenario(raw, fallback = 'lofi') {
186
+ return normalizeEnumValue(raw, VALID_SCENARIOS, SCENARIO_ALIASES, fallback)
187
+ }
188
+
189
+ let cachedDirector = null
190
+
191
+ export function findPublicStudioDir(overrideDir) {
192
+ const candidates = [
193
+ overrideDir,
194
+ process.env.OLO_STUDIO_PUBLIC_DIR,
195
+ path.resolve(process.cwd(), 'public'),
196
+ path.resolve(__dirname, '../../../public'),
197
+ path.resolve(process.cwd(), 'dist'),
198
+ path.resolve(__dirname, '../../../dist'),
199
+ ].filter(Boolean)
200
+
201
+ for (const dir of candidates) {
202
+ if (fs.existsSync(path.join(dir, 'studio', 'studio.js'))) {
203
+ return dir
204
+ }
205
+ }
206
+ return null
207
+ }
208
+
209
+ function loadUmdModule(filePath) {
210
+ try {
211
+ const mod = require(filePath)
212
+ if (mod && (typeof mod.compileSceneFromScript === 'function' || typeof mod.compileScene === 'function')) {
213
+ return mod
214
+ }
215
+ } catch {
216
+ // Package "type": "module" prevents require() on .js CJS/UMD files in Node 20
217
+ }
218
+
219
+ // Node 22+ require(esm) returns {} for UMD scripts under "type": "module"; evaluate as CJS cleanly.
220
+ const code = fs.readFileSync(filePath, 'utf8')
221
+ const mod = { exports: {} }
222
+ const fn = vm.compileFunction(
223
+ code,
224
+ ['module', 'exports', 'require', '__filename', '__dirname'],
225
+ { filename: filePath },
226
+ )
227
+ fn(mod, mod.exports, require, filePath, path.dirname(filePath))
228
+ const exported = mod.exports
229
+ if (exported && (typeof exported.compileSceneFromScript === 'function' || typeof exported.compileScene === 'function')) {
230
+ return exported
231
+ }
232
+ return null
233
+ }
234
+
235
+ export function loadDirector(overridePublicDir) {
236
+ if (cachedDirector) return cachedDirector
237
+ const candidates = []
238
+ const publicDir = findPublicStudioDir(overridePublicDir)
239
+ if (publicDir) {
240
+ candidates.push(path.join(publicDir, 'studio', 'engine', 'director.js'))
241
+ }
242
+ // Bundled copy for external npx installs outside the repository
243
+ candidates.push(path.join(__dirname, 'director.js'))
244
+
245
+ for (const directorPath of candidates) {
246
+ if (!fs.existsSync(directorPath)) continue
247
+ try {
248
+ const loaded = loadUmdModule(directorPath)
249
+ if (loaded) {
250
+ cachedDirector = loaded
251
+ return cachedDirector
252
+ }
253
+ } catch {
254
+ // try next candidate
255
+ }
256
+ }
257
+ return null
258
+ }
259
+
260
+ function fnv1aHex(str) {
261
+ let h = 0x811c9dc5
262
+ for (let i = 0; i < str.length; i++) {
263
+ h ^= str.charCodeAt(i)
264
+ h = Math.imul(h, 0x01000193)
265
+ }
266
+ return (h >>> 0).toString(16).padStart(8, '0')
267
+ }
268
+
269
+ function resolvePipMeta(opts = {}) {
270
+ const base = opts.pipMeta && typeof opts.pipMeta === 'object' ? { ...opts.pipMeta } : null
271
+ let result = base
272
+
273
+ if (opts.pipUrl && String(opts.pipUrl).trim()) {
274
+ result = { ...(result || {}), url: String(opts.pipUrl).trim() }
275
+ }
276
+
277
+ if (opts.pipPath && String(opts.pipPath).trim()) {
278
+ const absPath = path.resolve(String(opts.pipPath).trim())
279
+ if (fs.existsSync(absPath)) {
280
+ const ext = path.extname(absPath).toLowerCase()
281
+ const mimeType = PIP_MIME_TYPES[ext]
282
+ if (!mimeType) {
283
+ throw new Error(`Unsupported pipPath extension "${ext}". Supported: ${Object.keys(PIP_MIME_TYPES).join(', ')}`)
284
+ }
285
+ const buf = fs.readFileSync(absPath)
286
+ const dataUrl = `data:${mimeType};base64,${buf.toString('base64')}`
287
+ result = {
288
+ ...(result || {}),
289
+ dataUrl,
290
+ mimeType,
291
+ fileName: path.basename(absPath),
292
+ mediaType: mimeType.startsWith('video/') ? 'video' : 'image',
293
+ }
294
+ }
295
+ }
296
+
297
+ return result
298
+ }
299
+
300
+ function normalizeCompileOpts(input = {}) {
301
+ const opts = input && typeof input === 'object' ? { ...input } : { script: String(input || '') }
302
+
303
+ if (!opts.script && !opts.scriptText && Array.isArray(opts.beats)) {
304
+ opts.script = opts.beats.map((b) => `${b.speaker || 'Olo'}: ${b.text || b.line || ''}`).join('\n')
305
+ }
306
+
307
+ if (opts.wear !== undefined) opts.wear = normalizeWear(opts.wear)
308
+ if (opts.tint !== undefined) opts.tint = normalizeTint(opts.tint)
309
+ if (opts.finish !== undefined || opts.collectorFinish !== undefined) {
310
+ opts.finish = normalizeFinish(opts.finish ?? opts.collectorFinish)
311
+ }
312
+ if (opts.scenario !== undefined) opts.scenario = normalizeScenario(opts.scenario)
313
+
314
+ const rawState = opts.stateOverride ?? opts.state ?? opts.emotion
315
+ if (rawState !== undefined && rawState !== null) {
316
+ opts.stateOverride = normalizeState(rawState, null)
317
+ }
318
+
319
+ opts.pipMeta = resolvePipMeta(opts)
320
+ return opts
321
+ }
322
+
323
+ function fallbackCompileScene(opts = {}) {
324
+ const rawScript = String(
325
+ opts.script ||
326
+ opts.scriptText ||
327
+ (Array.isArray(opts.beats)
328
+ ? opts.beats.map((b) => `${b.speaker || 'Olo'}: ${b.text || b.line || ''}`).join('\n')
329
+ : 'Olo: Hola mundo desde Olomni Studio.'),
330
+ ).trim()
331
+
332
+ const maxLines = Math.max(1, Math.min(30, Number(opts.maxLines) || 12))
333
+ const lines = rawScript.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, maxLines)
334
+
335
+ const title = String(opts.title || 'Olo Studio Show').slice(0, 60)
336
+ const category = String(opts.category || 'Show 9:16').slice(0, 32)
337
+ const wear = normalizeWear(opts.wear, 'gafas_sol')
338
+ const tint = normalizeTint(opts.tint, 'rose')
339
+ const finish = normalizeFinish(opts.finish || opts.collectorFinish, 'vinyl')
340
+ const vibe = opts.vibe || 'drama'
341
+ const scenario = normalizeScenario(opts.scenario, 'lofi')
342
+ const cameraOverride = opts.cameraOverride || 'auto'
343
+ const stateOverride = normalizeState(opts.stateOverride ?? opts.state ?? opts.emotion, null)
344
+
345
+ let tCursor = 0.45
346
+ const beats = lines.map((raw, idx) => {
347
+ let speaker = 'Olo'
348
+ let text = raw
349
+ const colonIdx = raw.indexOf(':')
350
+ if (colonIdx > 0 && colonIdx < 18) {
351
+ speaker = raw.slice(0, colonIdx).trim()
352
+ text = raw.slice(colonIdx + 1).trim()
353
+ }
354
+ const wordCount = text.split(/\s+/).filter(Boolean).length
355
+ const duration = Number(Math.max(2.1, Math.min(4.4, 0.75 + wordCount * 0.27)).toFixed(3))
356
+ const t0 = Number(tCursor.toFixed(3))
357
+ const t1 = Number((tCursor + duration).toFixed(3))
358
+ tCursor = t1 + 0.12
359
+
360
+ const overrideBeat = Array.isArray(opts.beats) ? opts.beats[idx] : null
361
+ const beatState = overrideBeat
362
+ ? normalizeState(overrideBeat.state ?? overrideBeat.emotion, null)
363
+ : null
364
+ return {
365
+ index: idx,
366
+ speaker,
367
+ isOlo: speaker.toLowerCase().includes('olo'),
368
+ text,
369
+ t0,
370
+ t1,
371
+ duration,
372
+ state: beatState || stateOverride || (idx === 0 ? 'alert' : idx === lines.length - 1 ? 'done' : 'working'),
373
+ cam: (overrideBeat && overrideBeat.cam) || (cameraOverride !== 'auto' ? cameraOverride : 'hero'),
374
+ sfx: (overrideBeat && overrideBeat.sfx) || 'pop',
375
+ words: [],
376
+ }
377
+ })
378
+
379
+ const outroStart = Number(tCursor.toFixed(3))
380
+ const totalDuration = Number((tCursor + 1.35).toFixed(3))
381
+ const sceneHash = fnv1aHex(JSON.stringify({ title, wear, tint, finish, scenario, beats: beats.map((b) => [b.speaker, b.text]) }))
382
+
383
+ const spec = {
384
+ schema: 'olomni.studio.scene.v1',
385
+ sceneHash,
386
+ title,
387
+ category,
388
+ wear: wear === 'none' ? null : wear,
389
+ tint,
390
+ finish,
391
+ vibe,
392
+ scenario,
393
+ cameraOverride,
394
+ stateOverride,
395
+ duelConfig: opts.duelConfig && typeof opts.duelConfig === 'object' ? { ...opts.duelConfig } : null,
396
+ voice: opts.voice ? String(opts.voice).trim() : null,
397
+ voiceId: opts.voiceId ? String(opts.voiceId).trim() : null,
398
+ captions: opts.captions !== false,
399
+ voiceFx: opts.voiceFx || 'none',
400
+ drillBeat: opts.drillBeat || null,
401
+ customOutro: opts.customOutro || null,
402
+ pipMeta: opts.pipMeta || null,
403
+ duration: totalDuration,
404
+ outroStart,
405
+ beats,
406
+ }
407
+ if (typeof opts.watermark === 'boolean') spec.watermark = opts.watermark
408
+ return spec
409
+ }
410
+
411
+ export function compileSceneSpec(input = {}) {
412
+ const opts = normalizeCompileOpts(input)
413
+
414
+ const director = loadDirector(opts.publicDir)
415
+ let scene
416
+ if (director && typeof director.compileSceneFromScript === 'function') {
417
+ scene = director.compileSceneFromScript(opts)
418
+ } else if (director && typeof director.compileScene === 'function') {
419
+ scene = director.compileScene(opts)
420
+ } else {
421
+ scene = fallbackCompileScene(opts)
422
+ }
423
+
424
+ // Preserve explicit per-beat overrides (state / emotion, cam, sfx) if caller supplied beats[]
425
+ if (Array.isArray(opts.beats) && Array.isArray(scene.beats)) {
426
+ scene.beats.forEach((b, idx) => {
427
+ const src = opts.beats[idx]
428
+ if (!src || typeof src !== 'object') return
429
+ const normalizedBeatState = normalizeState(src.state ?? src.emotion, null)
430
+ if (normalizedBeatState) b.state = normalizedBeatState
431
+ if (src.cam) b.cam = src.cam
432
+ if (src.sfx) b.sfx = src.sfx
433
+ })
434
+ }
435
+
436
+ scene.duelConfig = opts.duelConfig && typeof opts.duelConfig === 'object'
437
+ ? { ...opts.duelConfig }
438
+ : (scene.duelConfig || null)
439
+ scene.voice = opts.voice ? String(opts.voice).trim() : (scene.voice || null)
440
+ scene.voiceId = opts.voiceId ? String(opts.voiceId).trim() : (scene.voiceId || null)
441
+ scene.captions = opts.captions !== false
442
+ if (opts.pipMeta) scene.pipMeta = opts.pipMeta
443
+
444
+ if (typeof opts.watermark === 'boolean') {
445
+ scene.watermark = opts.watermark
446
+ }
447
+
448
+ return scene
449
+ }
450
+
451
+ function parseCsvLine(line) {
452
+ const cells = []
453
+ let current = ''
454
+ let inQuotes = false
455
+ for (let i = 0; i < line.length; i++) {
456
+ const ch = line[i]
457
+ if (ch === '"') {
458
+ if (inQuotes && line[i + 1] === '"') {
459
+ current += '"'
460
+ i++
461
+ } else {
462
+ inQuotes = !inQuotes
463
+ }
464
+ } else if (ch === ',' && !inQuotes) {
465
+ cells.push(current.trim())
466
+ current = ''
467
+ } else {
468
+ current += ch
469
+ }
470
+ }
471
+ cells.push(current.trim())
472
+ return cells
473
+ }
474
+
475
+ export function parseBatchInput({ specs, csv, defaults = {} }) {
476
+ const cleanDefaults = Object.fromEntries(
477
+ Object.entries(defaults || {}).filter(([, v]) => v !== undefined),
478
+ )
479
+
480
+ if (Array.isArray(specs) && specs.length > 0) {
481
+ return specs.map((item, idx) => {
482
+ const compiled = compileSceneSpec({ ...cleanDefaults, ...item })
483
+ return {
484
+ index: idx,
485
+ outputName: item.outputName || item.fileName || `variant-${idx + 1}-${compiled.sceneHash}`,
486
+ sceneSpec: compiled,
487
+ }
488
+ })
489
+ }
490
+
491
+ if (typeof csv === 'string' && csv.trim()) {
492
+ const rows = csv.split(/\r?\n/).map((r) => r.trim()).filter(Boolean)
493
+ if (rows.length < 2) {
494
+ throw new Error('CSV must include a header row and at least one data row.')
495
+ }
496
+ const headers = parseCsvLine(rows[0]).map((h) => h.toLowerCase())
497
+ return rows.slice(1).map((row, idx) => {
498
+ const values = parseCsvLine(row)
499
+ const record = {}
500
+ headers.forEach((h, colIdx) => {
501
+ if (values[colIdx] !== undefined && values[colIdx] !== '') {
502
+ record[h] = values[colIdx]
503
+ }
504
+ })
505
+ const compiled = compileSceneSpec({
506
+ ...cleanDefaults,
507
+ title: record.title || `Variant ${idx + 1}`,
508
+ script: record.script || record.hook || record.text || '',
509
+ wear: record.wear,
510
+ tint: record.tint,
511
+ finish: record.finish,
512
+ scenario: record.scenario,
513
+ vibe: record.vibe,
514
+ voice: record.voice ?? cleanDefaults.voice,
515
+ voiceId: record.voiceid || record.voice_id || cleanDefaults.voiceId,
516
+ emotion: record.emotion,
517
+ stateOverride: record.state || record.stateoverride,
518
+ captions: record.captions !== undefined
519
+ ? record.captions !== 'false' && record.captions !== '0'
520
+ : (cleanDefaults.captions !== undefined ? cleanDefaults.captions : true),
521
+ pipPath: record.pippath || record.pip_path || cleanDefaults.pipPath,
522
+ pipUrl: record.pipurl || record.pip_url || cleanDefaults.pipUrl,
523
+ category: record.category || record.language || 'Show 9:16',
524
+ })
525
+ const safeName = (record.outputname || record.output_name || record.id || `variant-${idx + 1}-${compiled.sceneHash}`)
526
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
527
+ return {
528
+ index: idx,
529
+ outputName: safeName,
530
+ sceneSpec: compiled,
531
+ }
532
+ })
533
+ }
534
+
535
+ throw new Error('Provide either a non-empty `specs` array or a `csv` string.')
536
+ }
537
+
538
+ function formatSrtTimestamp(seconds) {
539
+ const safeSec = Math.max(0, Number(seconds) || 0)
540
+ const totalMs = Math.round(safeSec * 1000)
541
+ const ms = totalMs % 1000
542
+ const totalS = Math.floor(totalMs / 1000)
543
+ const s = totalS % 60
544
+ const totalM = Math.floor(totalS / 60)
545
+ const m = totalM % 60
546
+ const h = Math.floor(totalM / 60)
547
+ const pad2 = (n) => String(n).padStart(2, '0')
548
+ const pad3 = (n) => String(n).padStart(3, '0')
549
+ return `${pad2(h)}:${pad2(m)}:${pad2(s)},${pad3(ms)}`
550
+ }
551
+
552
+ /**
553
+ * Build valid .srt timed captions from scene.timeline, scene.beats, or scene.script.
554
+ */
555
+ export function generateSrt(scene) {
556
+ if (!scene) return ''
557
+
558
+ let cues = []
559
+ if (typeof scene === 'string') {
560
+ cues = compileSceneSpec({ script: scene }).beats || []
561
+ } else if (Array.isArray(scene.timeline) && scene.timeline.length > 0) {
562
+ cues = scene.timeline
563
+ } else if (Array.isArray(scene.beats) && scene.beats.length > 0) {
564
+ cues = scene.beats
565
+ } else if (typeof scene.script === 'string' && scene.script.trim()) {
566
+ cues = compileSceneSpec({ script: scene.script }).beats || []
567
+ } else if (typeof scene.scriptText === 'string' && scene.scriptText.trim()) {
568
+ cues = compileSceneSpec({ script: scene.scriptText }).beats || []
569
+ }
570
+
571
+ let cursor = 0
572
+ const blocks = []
573
+ for (const item of cues) {
574
+ if (!item || typeof item !== 'object') continue
575
+ const rawText = String(item.text || item.line || item.caption || '').trim()
576
+ if (!rawText) continue
577
+ const speaker = item.speaker ? String(item.speaker).trim() : ''
578
+ const lineText = speaker && !rawText.startsWith(`${speaker}:`) ? `${speaker}: ${rawText}` : rawText
579
+
580
+ const t0 = Number.isFinite(Number(item.t0 ?? item.start ?? item.startSec))
581
+ ? Number(item.t0 ?? item.start ?? item.startSec)
582
+ : cursor
583
+ const rawT1 = item.t1 ?? item.end ?? item.endSec
584
+ const t1 = Number.isFinite(Number(rawT1))
585
+ ? Math.max(t0 + 0.1, Number(rawT1))
586
+ : t0 + Math.max(0.5, Number(item.duration) || 2.5)
587
+ cursor = t1 + 0.12
588
+
589
+ blocks.push(
590
+ `${blocks.length + 1}\n${formatSrtTimestamp(t0)} --> ${formatSrtTimestamp(t1)}\n${lineText}`,
591
+ )
592
+ }
593
+
594
+ return blocks.length > 0 ? `${blocks.join('\n\n')}\n` : ''
595
+ }
596
+
597
+ function escapeHtmlAttr(val) {
598
+ return String(val)
599
+ .replace(/&/g, '&amp;')
600
+ .replace(/"/g, '&quot;')
601
+ .replace(/</g, '&lt;')
602
+ .replace(/>/g, '&gt;')
603
+ }
604
+
605
+ const EMBED_ATTR_MAP = {
606
+ mascotId: 'mascot-id',
607
+ brand: 'brand',
608
+ glbUrl: 'glb-url',
609
+ skin: 'skin',
610
+ wear: 'wear',
611
+ tint: 'tint',
612
+ themeColor: 'theme-color',
613
+ greeting: 'greeting',
614
+ ctaLabel: 'cta-label',
615
+ ctaUrl: 'cta-url',
616
+ voice: 'voice',
617
+ voiceId: 'voice-id',
618
+ position: 'position',
619
+ apiBase: 'api-base',
620
+ apiEndpoint: 'api-endpoint',
621
+ ttsEndpoint: 'tts-endpoint',
622
+ systemPrompt: 'system-prompt',
623
+ knowledge: 'knowledge',
624
+ }
625
+
626
+ export function buildPublishEmbed(opts = {}) {
627
+ const scriptSrc = opts.scriptSrc || 'https://www.olomni.com/studio/embed/olo-presence.js'
628
+ const attrs = []
629
+
630
+ for (const [key, attrName] of Object.entries(EMBED_ATTR_MAP)) {
631
+ const raw = opts[key] ?? opts[attrName]
632
+ if (raw === undefined || raw === null) continue
633
+ const val = typeof raw === 'object' ? JSON.stringify(raw) : String(raw)
634
+ if (val.trim() !== '') {
635
+ attrs.push(` ${attrName}="${escapeHtmlAttr(val)}"`)
636
+ }
637
+ }
638
+ if (opts.open === true) {
639
+ attrs.push(' open')
640
+ }
641
+
642
+ const attrBlock = attrs.length > 0 ? `\n${attrs.join('\n')}\n` : ''
643
+ const html = [
644
+ `<script src="${escapeHtmlAttr(scriptSrc)}" defer></script>`,
645
+ `<olo-presence${attrBlock}></olo-presence>`,
646
+ ].join('\n')
647
+
648
+ return {
649
+ ok: true,
650
+ scriptSrc,
651
+ tagName: 'olo-presence',
652
+ html,
653
+ }
654
+ }