@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/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@olo-studio/studio-mcp`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Local-first MCP server and CLI for **Olomni Studio** — compile `olomni.studio.scene.v1` scenes offline, generate 9:16 video scripts, render deterministic `.mp4` / `.webm` / alpha / chroma videos via headless Chrome (`window.OloStudioBridge.exportSceneBuffer`), and publish `<olo-presence>` 3D brand mascot embeds.
|
|
4
|
+
|
|
5
|
+
## Installation & MCP Client Config
|
|
6
|
+
|
|
7
|
+
Add to Claude Desktop, Cursor, Windsurf, or any MCP-compatible host:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"olomni-studio": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "@olo-studio/studio-mcp"],
|
|
15
|
+
"env": {
|
|
16
|
+
"OLO_STUDIO_API_KEY": "olo_pat_your_token_here"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Tools (7)
|
|
24
|
+
|
|
25
|
+
| Tool | Description |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `olo_get_entitlements` | Inspect account tier, capabilities (`maxDuration`, `allowGLB`, `allowBatch`, `allowAlphaOrChroma`), and upgrade URLs (`/studio`, `/mcp`). Falls back to free-tier capabilities offline. |
|
|
28
|
+
| `olo_generate_script` | Generate 9:16 scripts (`mode: "product" \| "chisme" \| "changelog"`) via `/functions/v1/studio-chisme-ai` with deterministic offline fallback. |
|
|
29
|
+
| `olo_compile_scene` | Compile and validate `olomni.studio.scene.v1` SceneSpec JSON offline without a browser. |
|
|
30
|
+
| `olo_render_video` | Render a real `.mp4`, `.webm`, `alpha`, or `chroma` video via headless Chrome (`OloStudioBridge.exportSceneBuffer`) to `outputPath`. |
|
|
31
|
+
| `olo_render_batch` | Render multi-variant hooks/languages from a SceneSpec array or CSV in a single browser session. |
|
|
32
|
+
| `olo_voice_clone_list` | List or delete cloned voices (`OLO_STUDIO_API_KEY` required) via `studio-entitlements` & `studio-voice-clone`. |
|
|
33
|
+
| `olo_publish_embed` | Generate the `<script src="https://www.olomni.com/studio/embed/olo-presence.js" defer></script>` + `<olo-presence ...>` snippet. |
|
|
34
|
+
|
|
35
|
+
## CLI Usage
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
olo-studio-mcp --help
|
|
39
|
+
olo-studio-mcp serve
|
|
40
|
+
olo-studio-mcp compile --title "Launch" --script "Olo: Ship 3D reels in seconds." --out scene.json
|
|
41
|
+
olo-studio-mcp render --file scene.json --out demo.mp4 --format mp4
|
|
42
|
+
olo-studio-mcp inspect-glb ./mascot.glb
|
|
43
|
+
```
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @olo-studio/studio-mcp CLI & Stdio MCP Server Entrypoint
|
|
4
|
+
* Usage:
|
|
5
|
+
* olo-studio-mcp [serve] Start JSON-RPC 2.0 stdio MCP server
|
|
6
|
+
* olo-studio-mcp compile [opts] Compile SceneSpec v1 JSON offline
|
|
7
|
+
* olo-studio-mcp render [opts] Render MP4/WebM via headless Chrome
|
|
8
|
+
* olo-studio-mcp inspect-glb <src> Inspect .glb header & JSON chunk
|
|
9
|
+
* olo-studio-mcp --help Show help
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import fsp from 'node:fs/promises'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { compileSceneSpec } from '../src/compiler.js'
|
|
15
|
+
import { inspectGlb, renderVideoWithBrowser } from '../src/renderer.js'
|
|
16
|
+
import { startStdioServer } from '../src/server.js'
|
|
17
|
+
|
|
18
|
+
const PKG_VERSION = JSON.parse(
|
|
19
|
+
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
20
|
+
).version
|
|
21
|
+
|
|
22
|
+
function parseFlags(argv) {
|
|
23
|
+
const flags = {}
|
|
24
|
+
const positional = []
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
const arg = argv[i]
|
|
27
|
+
if (arg === '--help' || arg === '-h') {
|
|
28
|
+
flags.help = true
|
|
29
|
+
} else if (arg.startsWith('--')) {
|
|
30
|
+
const key = arg.slice(2)
|
|
31
|
+
const next = argv[i + 1]
|
|
32
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
33
|
+
flags[key] = next
|
|
34
|
+
i++
|
|
35
|
+
} else {
|
|
36
|
+
flags[key] = true
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
positional.push(arg)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { flags, positional }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function printHelp() {
|
|
46
|
+
console.log(`@olo-studio/studio-mcp v${PKG_VERSION} — Local-First MCP Server & CLI for Olomni Studio
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
olo-studio-mcp [serve]
|
|
50
|
+
Start the JSON-RPC 2.0 stdio MCP server (default when no subcommand is given).
|
|
51
|
+
|
|
52
|
+
olo-studio-mcp compile [--script "..."] [--file scene.json] [--title "..."] [--out out.json]
|
|
53
|
+
Compile and validate an olomni.studio.scene.v1 SceneSpec without a browser.
|
|
54
|
+
|
|
55
|
+
olo-studio-mcp render [--script "..."] [--file scene.json] [--out out.mp4] [--format mp4|webm|alpha|chroma] [--fps 24|30|60] [--quality fast|social|studio] [--glb path-or-url]
|
|
56
|
+
Render a deterministic 3D video via headless Chrome + OloStudioBridge.
|
|
57
|
+
|
|
58
|
+
olo-studio-mcp inspect-glb <path-or-url>
|
|
59
|
+
Inspect a .glb binary header, scenes, nodes, meshes, animations, and materials.
|
|
60
|
+
|
|
61
|
+
Environment Variables:
|
|
62
|
+
OLO_STUDIO_API_KEY Personal Access Token (olo_pat_...) for Pro/Agency entitlements
|
|
63
|
+
OLO_STUDIO_API_BASE API base URL (default: https://www.olomni.com)
|
|
64
|
+
OLO_STUDIO_URL Override Studio URL for headless rendering
|
|
65
|
+
OLO_STUDIO_PUBLIC_DIR Override local public/ directory for static serving
|
|
66
|
+
OLO_CHROME_PATH Override Chrome/Chromium executable path`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function loadInputSpec(flags) {
|
|
70
|
+
if (flags.file) {
|
|
71
|
+
const raw = await fsp.readFile(path.resolve(String(flags.file)), 'utf8')
|
|
72
|
+
return JSON.parse(raw)
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
...(flags.title ? { title: String(flags.title) } : {}),
|
|
76
|
+
...(flags.script ? { script: String(flags.script) } : {}),
|
|
77
|
+
...(flags.wear ? { wear: String(flags.wear) } : {}),
|
|
78
|
+
...(flags.tint ? { tint: String(flags.tint) } : {}),
|
|
79
|
+
...(flags.finish ? { finish: String(flags.finish) } : {}),
|
|
80
|
+
...(flags.scenario ? { scenario: String(flags.scenario) } : {}),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function main() {
|
|
85
|
+
const { flags, positional } = parseFlags(process.argv.slice(2))
|
|
86
|
+
const cmd = positional[0] || 'serve'
|
|
87
|
+
|
|
88
|
+
if (flags.help || cmd === 'help') {
|
|
89
|
+
printHelp()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (cmd === 'serve') {
|
|
94
|
+
await startStdioServer()
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (cmd === 'compile') {
|
|
99
|
+
const input = await loadInputSpec(flags)
|
|
100
|
+
const sceneSpec = compileSceneSpec(input)
|
|
101
|
+
const json = JSON.stringify(sceneSpec, null, 2)
|
|
102
|
+
if (flags.out) {
|
|
103
|
+
const outPath = path.resolve(String(flags.out))
|
|
104
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true })
|
|
105
|
+
await fsp.writeFile(outPath, json, 'utf8')
|
|
106
|
+
console.log(JSON.stringify({ ok: true, outPath, sceneHash: sceneSpec.sceneHash, duration: sceneSpec.duration }))
|
|
107
|
+
} else {
|
|
108
|
+
console.log(json)
|
|
109
|
+
}
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (cmd === 'render') {
|
|
114
|
+
const input = await loadInputSpec(flags)
|
|
115
|
+
const glbArg = flags.glb ? String(flags.glb) : undefined
|
|
116
|
+
const isUrl = glbArg && /^https?:\/\//i.test(glbArg)
|
|
117
|
+
const result = await renderVideoWithBrowser({
|
|
118
|
+
sceneSpec: input,
|
|
119
|
+
format: flags.format ? String(flags.format) : 'mp4',
|
|
120
|
+
outputPath: flags.out ? String(flags.out) : './olo-render.mp4',
|
|
121
|
+
...(flags.fps !== undefined ? { fps: Number(flags.fps) } : {}),
|
|
122
|
+
...(flags.quality ? { quality: String(flags.quality) } : {}),
|
|
123
|
+
...(isUrl ? { glbUrl: glbArg } : glbArg ? { glbPath: glbArg } : {}),
|
|
124
|
+
...(flags.studioUrl ? { studioUrl: String(flags.studioUrl) } : {}),
|
|
125
|
+
})
|
|
126
|
+
console.log(JSON.stringify(result, null, 2))
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (cmd === 'inspect-glb') {
|
|
131
|
+
const target = positional[1] || flags.file || flags.url
|
|
132
|
+
const info = await inspectGlb(String(target || ''))
|
|
133
|
+
console.log(JSON.stringify(info, null, 2))
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.error(`Unknown subcommand: ${cmd}`)
|
|
138
|
+
printHelp()
|
|
139
|
+
process.exitCode = 1
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
main().catch((err) => {
|
|
143
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
144
|
+
process.exit(1)
|
|
145
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olo-studio/studio-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Local-first MCP server and CLI for Olomni Studio 3D video rendering, SceneSpec v1 compilation, AI scripting, and <olo-presence> embeds.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/server.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"prepack": "node -e \"const fs=require('fs'),p='../../public/studio/engine/director.js';if(fs.existsSync(p))fs.copyFileSync(p,'./src/director.js')\""
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/server.js",
|
|
12
|
+
"./compiler": "./src/compiler.js",
|
|
13
|
+
"./renderer": "./src/renderer.js"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"olo-studio-mcp": "bin/olo-studio-mcp.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin/",
|
|
20
|
+
"src/",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"mcp",
|
|
25
|
+
"olomni",
|
|
26
|
+
"olo-studio",
|
|
27
|
+
"3d-video",
|
|
28
|
+
"webgl",
|
|
29
|
+
"webcodecs",
|
|
30
|
+
"ai-agents"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/faustodariopenamondragon-web/OlomniAI.git",
|
|
39
|
+
"directory": "packages/studio-mcp"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20.0.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
46
|
+
"jszip": "^3.10.1",
|
|
47
|
+
"playwright": "^1.64.1",
|
|
48
|
+
"zod": "^3.24.2"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Olomni Studio API Client (entitlements, AI script generation, voice clones).
|
|
3
|
+
* Supports OLO_STUDIO_API_KEY (olo_pat_...) and offline deterministic fallbacks.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_API_BASE = 'https://www.olomni.com'
|
|
7
|
+
|
|
8
|
+
export function resolveApiBase(overrideBase) {
|
|
9
|
+
const raw = overrideBase || process.env.OLO_STUDIO_API_BASE || process.env.OLO_API_BASE || DEFAULT_API_BASE
|
|
10
|
+
return String(raw).replace(/\/+$/, '')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveApiKey(overrideKey) {
|
|
14
|
+
const key = overrideKey || process.env.OLO_STUDIO_API_KEY || ''
|
|
15
|
+
return String(key).trim()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getFreeTierEntitlements(extra = {}) {
|
|
19
|
+
return {
|
|
20
|
+
authenticated: false,
|
|
21
|
+
tier: 'free',
|
|
22
|
+
isPass: false,
|
|
23
|
+
isPro: false,
|
|
24
|
+
isAgency: false,
|
|
25
|
+
maxClones: 0,
|
|
26
|
+
maxDuration: 30,
|
|
27
|
+
allowAlphaOrChroma: false,
|
|
28
|
+
allowGLB: false,
|
|
29
|
+
allowBatch: false,
|
|
30
|
+
voiceMinutesLimit: 0,
|
|
31
|
+
voiceMinutesUsed: 0,
|
|
32
|
+
clonedVoices: [],
|
|
33
|
+
stripeEnabled: false,
|
|
34
|
+
upgradeUrl: 'https://www.olomni.com/studio',
|
|
35
|
+
mcpDocsUrl: 'https://www.olomni.com/mcp',
|
|
36
|
+
...extra,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getEntitlements(opts = {}) {
|
|
41
|
+
const apiBase = resolveApiBase(opts.apiBase)
|
|
42
|
+
const apiKey = resolveApiKey(opts.apiKey)
|
|
43
|
+
const fetchFn = opts.fetchFn || globalThis.fetch
|
|
44
|
+
|
|
45
|
+
if (!apiKey || typeof fetchFn !== 'function') {
|
|
46
|
+
return getFreeTierEntitlements({
|
|
47
|
+
source: apiKey ? 'offline_no_fetch' : 'offline_free_tier',
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetchFn(`${apiBase}/functions/v1/studio-entitlements`, {
|
|
53
|
+
method: 'GET',
|
|
54
|
+
headers: {
|
|
55
|
+
Authorization: `Bearer ${apiKey}`,
|
|
56
|
+
Accept: 'application/json',
|
|
57
|
+
},
|
|
58
|
+
signal: AbortSignal.timeout(opts.timeoutMs || 8000),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
return getFreeTierEntitlements({
|
|
63
|
+
source: 'remote_error',
|
|
64
|
+
httpStatus: res.status,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const data = await res.json()
|
|
69
|
+
return {
|
|
70
|
+
...getFreeTierEntitlements(),
|
|
71
|
+
...data,
|
|
72
|
+
stripeEnabled: false,
|
|
73
|
+
upgradeUrl: 'https://www.olomni.com/studio',
|
|
74
|
+
mcpDocsUrl: 'https://www.olomni.com/mcp',
|
|
75
|
+
source: 'remote',
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return getFreeTierEntitlements({
|
|
79
|
+
source: 'offline_fallback',
|
|
80
|
+
error: err instanceof Error ? err.message : String(err),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const OFFLINE_TEMPLATES = {
|
|
86
|
+
product(topic, language) {
|
|
87
|
+
const isEs = String(language || 'es').toLowerCase().startsWith('es')
|
|
88
|
+
if (isEs) {
|
|
89
|
+
return {
|
|
90
|
+
title: topic ? `${topic} — Demo 9:16` : 'Lanzamiento de Producto 9:16',
|
|
91
|
+
beats: [
|
|
92
|
+
{ speaker: 'Olo', line: `¿Todavía pierdes horas haciendo ${topic || 'esto'} a mano?`, state: 'alert', emotion: 'shocked' },
|
|
93
|
+
{ speaker: 'Yo', line: 'Sí, cada lanzamiento me toma días enteros.', state: 'error', emotion: 'cringe' },
|
|
94
|
+
{ speaker: 'Olo', line: `Con ${topic || 'Olomni Studio'} lo automatizas en 30 segundos.`, state: 'proud', emotion: 'proud' },
|
|
95
|
+
{ speaker: 'Olo', line: 'Pruébalo hoy gratis y publica tu primer reel.', state: 'done', emotion: 'happy' },
|
|
96
|
+
],
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
title: topic ? `${topic} — 9:16 Demo` : 'Product Launch 9:16',
|
|
101
|
+
beats: [
|
|
102
|
+
{ speaker: 'Olo', line: `Still spending hours doing ${topic || 'this'} manually?`, state: 'alert', emotion: 'shocked' },
|
|
103
|
+
{ speaker: 'Yo', line: 'Yeah, every release takes way too long.', state: 'error', emotion: 'cringe' },
|
|
104
|
+
{ speaker: 'Olo', line: `With ${topic || 'Olomni Studio'} you ship it in 30 seconds.`, state: 'proud', emotion: 'proud' },
|
|
105
|
+
{ speaker: 'Olo', line: 'Try it free today and publish your first 3D reel.', state: 'done', emotion: 'happy' },
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
changelog(topic, language) {
|
|
110
|
+
const isEs = String(language || 'es').toLowerCase().startsWith('es')
|
|
111
|
+
if (isEs) {
|
|
112
|
+
return {
|
|
113
|
+
title: topic ? `Novedades: ${topic}` : 'Changelog Release 9:16',
|
|
114
|
+
beats: [
|
|
115
|
+
{ speaker: 'Olo', line: `Acabamos de lanzar ${topic || 'la nueva versión'} y cambia todo.`, state: 'alert', emotion: 'alert' },
|
|
116
|
+
{ speaker: 'Olo', line: 'Exportación determinista, lip-sync neural y cero fricción.', state: 'proud', emotion: 'proud' },
|
|
117
|
+
{ speaker: 'Olo', line: 'Actualiza ahora y pruébalo en tu próximo deploy.', state: 'done', emotion: 'happy' },
|
|
118
|
+
],
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
title: topic ? `What's New: ${topic}` : 'Changelog Release 9:16',
|
|
123
|
+
beats: [
|
|
124
|
+
{ speaker: 'Olo', line: `We just shipped ${topic || 'our latest release'} and it changes everything.`, state: 'alert', emotion: 'alert' },
|
|
125
|
+
{ speaker: 'Olo', line: 'Deterministic export, neural lip-sync, zero friction.', state: 'proud', emotion: 'proud' },
|
|
126
|
+
{ speaker: 'Olo', line: 'Update now and test it on your next deploy.', state: 'done', emotion: 'happy' },
|
|
127
|
+
],
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
chisme(topic, language) {
|
|
131
|
+
const isEs = String(language || 'es').toLowerCase().startsWith('es')
|
|
132
|
+
if (isEs) {
|
|
133
|
+
return {
|
|
134
|
+
title: topic ? `Chisme: ${topic}` : 'Storytime 9:16',
|
|
135
|
+
beats: [
|
|
136
|
+
{ speaker: 'Yo', line: `No vas a creer lo que pasó con ${topic || 'el cliente hoy'}.`, state: 'curious', emotion: 'curious' },
|
|
137
|
+
{ speaker: 'Contacto', line: 'Cuéntamelo todo sin censura, ¿qué hizo ahora?', state: 'alert', emotion: 'shocked' },
|
|
138
|
+
{ speaker: 'Olo', line: 'Spoiler: terminó pidiendo perdón y pagando el doble.', state: 'curious', emotion: 'sarcastic' },
|
|
139
|
+
{ speaker: 'Olo', line: 'Guarda este video antes de que lo borren.', state: 'proud', emotion: 'proud' },
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
title: topic ? `Storytime: ${topic}` : 'Storytime 9:16',
|
|
145
|
+
beats: [
|
|
146
|
+
{ speaker: 'Yo', line: `You will not believe what happened with ${topic || 'our launch today'}.`, state: 'curious', emotion: 'curious' },
|
|
147
|
+
{ speaker: 'Contacto', line: 'Wait, spill everything right now.', state: 'alert', emotion: 'shocked' },
|
|
148
|
+
{ speaker: 'Olo', line: 'Spoiler: they came back asking for an enterprise plan.', state: 'curious', emotion: 'sarcastic' },
|
|
149
|
+
{ speaker: 'Olo', line: 'Save this clip before it disappears.', state: 'proud', emotion: 'proud' },
|
|
150
|
+
],
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function generateScript(opts = {}) {
|
|
156
|
+
const mode = ['product', 'chisme', 'changelog'].includes(opts.mode) ? opts.mode : 'product'
|
|
157
|
+
const topic = String(opts.topic || opts.product || opts.text || '').trim()
|
|
158
|
+
const language = String(opts.language || 'es').trim()
|
|
159
|
+
const apiBase = resolveApiBase(opts.apiBase)
|
|
160
|
+
const apiKey = resolveApiKey(opts.apiKey)
|
|
161
|
+
const fetchFn = opts.fetchFn || globalThis.fetch
|
|
162
|
+
|
|
163
|
+
if (typeof fetchFn === 'function' && opts.offlineOnly !== true) {
|
|
164
|
+
try {
|
|
165
|
+
const headers = {
|
|
166
|
+
'Content-Type': 'application/json',
|
|
167
|
+
Accept: 'application/json',
|
|
168
|
+
}
|
|
169
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
|
|
170
|
+
|
|
171
|
+
const res = await fetchFn(`${apiBase}/functions/v1/studio-chisme-ai`, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers,
|
|
174
|
+
body: JSON.stringify({
|
|
175
|
+
mode,
|
|
176
|
+
text: topic || `Generate a ${mode} 9:16 script in ${language}`,
|
|
177
|
+
language,
|
|
178
|
+
}),
|
|
179
|
+
signal: AbortSignal.timeout(opts.timeoutMs || 12000),
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
if (res.ok) {
|
|
183
|
+
const data = await res.json()
|
|
184
|
+
if (data && typeof data.script === 'string' && data.script.trim()) {
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
mode: data.mode || mode,
|
|
188
|
+
title: data.title || topic || 'Olo Studio Show',
|
|
189
|
+
beats: Array.isArray(data.beats) ? data.beats : [],
|
|
190
|
+
script: data.script,
|
|
191
|
+
provider: data.provider || 'remote',
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// Fall through to deterministic offline generator
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const builder = OFFLINE_TEMPLATES[mode] || OFFLINE_TEMPLATES.product
|
|
201
|
+
const generated = builder(topic, language)
|
|
202
|
+
const script = generated.beats.map((b) => `${b.speaker}: ${b.line}`).join('\n')
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
mode,
|
|
206
|
+
title: generated.title,
|
|
207
|
+
beats: generated.beats,
|
|
208
|
+
script,
|
|
209
|
+
provider: 'offline-deterministic',
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function manageVoiceClones(opts = {}) {
|
|
214
|
+
const action = opts.action === 'delete' ? 'delete' : 'list'
|
|
215
|
+
const apiBase = resolveApiBase(opts.apiBase)
|
|
216
|
+
const apiKey = resolveApiKey(opts.apiKey)
|
|
217
|
+
const fetchFn = opts.fetchFn || globalThis.fetch
|
|
218
|
+
|
|
219
|
+
if (!apiKey) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error: 'missing_api_key',
|
|
223
|
+
message: 'Set OLO_STUDIO_API_KEY (olo_pat_...) to list or manage cloned voices.',
|
|
224
|
+
clonedVoices: [],
|
|
225
|
+
maxClones: 0,
|
|
226
|
+
upgradeUrl: 'https://www.olomni.com/studio',
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (typeof fetchFn !== 'function') {
|
|
231
|
+
return { ok: false, error: 'fetch_unavailable', clonedVoices: [] }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (action === 'delete') {
|
|
235
|
+
const voiceId = String(opts.voiceId || '').trim()
|
|
236
|
+
if (!voiceId) {
|
|
237
|
+
return { ok: false, error: 'missing_voice_id', message: 'voiceId is required when action is delete.' }
|
|
238
|
+
}
|
|
239
|
+
const res = await fetchFn(
|
|
240
|
+
`${apiBase}/functions/v1/studio-voice-clone?voiceId=${encodeURIComponent(voiceId)}`,
|
|
241
|
+
{
|
|
242
|
+
method: 'DELETE',
|
|
243
|
+
headers: {
|
|
244
|
+
Authorization: `Bearer ${apiKey}`,
|
|
245
|
+
Accept: 'application/json',
|
|
246
|
+
},
|
|
247
|
+
signal: AbortSignal.timeout(opts.timeoutMs || 10000),
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
const body = await res.json().catch(() => ({}))
|
|
251
|
+
return {
|
|
252
|
+
ok: res.ok,
|
|
253
|
+
status: res.status,
|
|
254
|
+
action: 'delete',
|
|
255
|
+
voiceId,
|
|
256
|
+
...body,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const entitlements = await getEntitlements({ apiBase, apiKey, fetchFn, timeoutMs: opts.timeoutMs })
|
|
261
|
+
return {
|
|
262
|
+
ok: Boolean(entitlements.authenticated),
|
|
263
|
+
action: 'list',
|
|
264
|
+
tier: entitlements.tier,
|
|
265
|
+
maxClones: entitlements.maxClones || 0,
|
|
266
|
+
voiceMinutesLimit: entitlements.voiceMinutesLimit || 0,
|
|
267
|
+
voiceMinutesUsed: entitlements.voiceMinutesUsed || 0,
|
|
268
|
+
clonedVoices: Array.isArray(entitlements.clonedVoices) ? entitlements.clonedVoices : [],
|
|
269
|
+
upgradeUrl: 'https://www.olomni.com/studio',
|
|
270
|
+
}
|
|
271
|
+
}
|