@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/compiler.js
ADDED
|
@@ -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, '&')
|
|
600
|
+
.replace(/"/g, '"')
|
|
601
|
+
.replace(/</g, '<')
|
|
602
|
+
.replace(/>/g, '>')
|
|
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
|
+
}
|