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

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