@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,1098 @@
1
+ /* Olo Studio — Director Engine v3.0 (21 Cycles 3D States + Canonical character.js POP/Fade + motion.js Choreography)
2
+ * Pure, deterministic compiler & frame evaluator for Browser (window.OloDirector) and Node.js (CommonJS).
3
+ * Built strictly on Olomni's canonical principles (`presence-widget/src/character.js`, `motion.js`, `minis.js`, `tools/olo3d/render.py`):
4
+ * - All 21 Cycles-rendered 3D states (including 3D props: book, notepad+pencil, magnifying glass, globe, clipboard, gear, mini-Olos, heart, stars, and the 5×3 3D `look` gaze grid)
5
+ * - Cohesive 1:1 sprite + mask + wear registration (zero fake 2D face overlays, zero holdout detachment)
6
+ * - Canonical `POP` anticipation squash/rebound curves anchored at Olo's feet (`FEET = 0.955`)
7
+ * - Canonical `motion.js` events per beat (`orbStep`, `paper`, `memory`, `orbBurst`, `goldAura`)
8
+ */
9
+ (function (root, factory) {
10
+ if (typeof module === 'object' && module.exports) module.exports = factory()
11
+ else root.OloDirector = factory()
12
+ })(typeof self !== 'undefined' ? self : typeof globalThis !== 'undefined' ? globalThis : this, function () {
13
+ 'use strict'
14
+
15
+ // Los 21 estados 3D reales renderizados en Blender Cycles (`presence-widget/src-tauri/characters/olo/manifest.json`)
16
+ const OLO_STATES = {
17
+ idle: { row: 0, frames: 16, fps: 8, loop: true, screen: '#3b6cff', label: 'En calma', category: 'base' },
18
+ sleeping: { row: 1, frames: 12, fps: 5, loop: true, screen: '#4d86ff', label: 'Dormido (zZ)', category: 'base' },
19
+ thinking: { row: 2, frames: 12, fps: 8, loop: true, screen: '#4d86ff', label: 'Pensando (...)', category: 'base' },
20
+ working: { row: 3, frames: 12, fps: 12, loop: true, screen: '#3b6cff', label: 'Trabajando (>_)', category: 'base' },
21
+ needs_approval: { row: 4, frames: 12, fps: 10, loop: true, screen: '#f2c14e', label: '¿Aprobación? (?)', category: 'base' },
22
+ done: { row: 5, frames: 10, fps: 12, loop: false, screen: '#3ddc97', label: 'Terminado (✓)', category: 'base' },
23
+ error: { row: 6, frames: 12, fps: 12, loop: false, screen: '#ff5d7a', label: 'Error (X)', category: 'base' },
24
+ alert: { row: 7, frames: 12, fps: 9, loop: true, screen: '#f2c14e', label: 'Alerta (!)', category: 'base' },
25
+ reading: { row: 8, frames: 16, fps: 8, loop: true, screen: '#4d86ff', label: 'Leyendo Libro 3D', category: 'expr' },
26
+ writing: { row: 9, frames: 12, fps: 10, loop: true, screen: '#3b6cff', label: 'Libreta y Lápiz', category: 'expr' },
27
+ searching: { row: 10, frames: 12, fps: 8, loop: true, screen: '#4d86ff', label: 'Lupa 3D', category: 'expr' },
28
+ running: { row: 11, frames: 15, fps: 10, loop: true, screen: '#3b6cff', label: 'Terminal en Vivo', category: 'expr' },
29
+ browsing: { row: 12, frames: 12, fps: 8, loop: true, screen: '#4d86ff', label: 'Globo Terráqueo', category: 'expr' },
30
+ planning: { row: 13, frames: 12, fps: 5, loop: true, screen: '#3ddc97', label: 'Checklist (✓)', category: 'expr' },
31
+ delegating: { row: 14, frames: 12, fps: 8, loop: true, screen: '#3b6cff', label: 'Mini-Olos 3D', category: 'expr' },
32
+ tool: { row: 15, frames: 10, fps: 10, loop: true, screen: '#3b6cff', label: 'Engrane 3D', category: 'expr' },
33
+ curious: { row: 16, frames: 10, fps: 10, loop: false, screen: '#f2c14e', label: 'Curioso', category: 'react' },
34
+ boop: { row: 17, frames: 10, fps: 16, loop: false, screen: '#ff5d7a', label: 'Boop (Corazón)', category: 'react' },
35
+ proud: { row: 18, frames: 12, fps: 12, loop: false, screen: '#3ddc97', label: 'Orgulloso (★)', category: 'react' },
36
+ yawn: { row: 19, frames: 12, fps: 8, loop: false, screen: '#4d86ff', label: 'Bostezo', category: 'react' },
37
+ look: { row: 20, frames: 16, fps: 1, loop: false, screen: '#3b6cff', label: 'Mirada 3D (5×3)', category: 'gaze' },
38
+ }
39
+
40
+ // Anticipación canónica de `character.js` (línea 44): [escalaX, escalaY] al entrar en un estado
41
+ const STATE_POP = {
42
+ needs_approval: [1.06, 0.92],
43
+ done: [1.05, 0.93],
44
+ error: [1.07, 0.90],
45
+ alert: [1.06, 0.92],
46
+ proud: [1.05, 0.94],
47
+ curious: [1.04, 0.95],
48
+ boop: [1.12, 0.86],
49
+ searching: [1.03, 0.96],
50
+ }
51
+
52
+ const OLO_TINTS = {
53
+ bone: { hex: null, name: 'Vinilo Hueso' },
54
+ mint: { hex: '#c4f2dc', name: 'Menta Neón' },
55
+ peach: { hex: '#ffd6bf', name: 'Durazno' },
56
+ lilac: { hex: '#ddd1ff', name: 'Lavanda' },
57
+ sky: { hex: '#c6e3ff', name: 'Cielo' },
58
+ butter: { hex: '#fff1b0', name: 'Oro Mantequilla' },
59
+ rose: { hex: '#ffcfdc', name: 'Rosa Drama' },
60
+ graphite: { hex: '#8e8e9c', name: 'Grafito' },
61
+ }
62
+
63
+ const OLO_WEARS = [
64
+ 'none', 'corona', 'gafas_sol', 'auriculares', 'mago', 'laurel',
65
+ 'boina', 'casco', 'gafas', 'bufanda', 'aureola', 'flor', 'lazo'
66
+ ]
67
+
68
+ // 6 Visemas Fonéticos para Síntesis de Formantes (F1, F2) y pulso corporal silábico
69
+ const OLO_VISEMES = {
70
+ REST: { id: 'REST', open: 0.00, width: 1.00, round: 0.00, teeth: 0.00, f1: 300, f2: 1200, label: 'Reposo' },
71
+ A_AH: { id: 'A_AH', open: 0.92, width: 1.16, round: 0.22, teeth: 0.38, f1: 750, f2: 1180, label: 'Vocal Abierta (A)' },
72
+ E_EE: { id: 'E_EE', open: 0.50, width: 1.38, round: 0.05, teeth: 0.78, f1: 410, f2: 2150, label: 'Palatal (E/I)' },
73
+ O_U: { id: 'O_U', open: 0.78, width: 0.60, round: 0.96, teeth: 0.00, f1: 370, f2: 760, label: 'Redondeada (O/U)' },
74
+ M_B_P: { id: 'M_B_P', open: 0.08, width: 1.25, round: 0.00, teeth: 0.00, f1: 250, f2: 880, label: 'Bilabial (M/B/P)' },
75
+ F_V: { id: 'F_V', open: 0.28, width: 1.10, round: 0.12, teeth: 0.88, f1: 340, f2: 1680, label: 'Fricativa (F/S/T)' },
76
+ }
77
+
78
+ const OLO_CAMERA_MODES = {
79
+ auto: { id: 'auto', name: 'Auto DOP (Inteligente por Beat)', yawDeg: 0 },
80
+ crash_zoom: { id: 'crash_zoom', name: 'Crash Zoom Dramático', yawDeg: 0 },
81
+ three_quarter_left: { id: 'three_quarter_left', name: 'Perspectiva 3/4 Izquierda', yawDeg: -14 },
82
+ three_quarter_right: { id: 'three_quarter_right', name: 'Perspectiva 3/4 Derecha', yawDeg: 14 },
83
+ orbit_turntable: { id: 'orbit_turntable', name: 'Vaivén 3D Suave', yawDeg: 0 },
84
+ snorricam: { id: 'snorricam', name: 'Snorricam (Anclada a Olo)', yawDeg: 0 },
85
+ hero_low: { id: 'hero_low', name: 'Contrapicado Héroe', yawDeg: -6 },
86
+ handheld_doc: { id: 'handheld_doc', name: 'Cámara en Mano (Perlin DOP)', yawDeg: 5 },
87
+ }
88
+
89
+ // Presets de alto impacto que aprovechan los 21 estados con utilería 3D de Olo
90
+ const PRESET_SHOWS = {
91
+ whatsapp_ex: {
92
+ id: 'whatsapp_ex',
93
+ title: 'El audio de las 3:14 AM',
94
+ category: 'Drama WhatsApp',
95
+ wear: 'gafas_sol',
96
+ tint: 'rose',
97
+ vibe: 'drama',
98
+ script: [
99
+ 'Yo: Olo, mi ex me escribió "hola perdido" a las 3:14 de la mañana.',
100
+ 'Olo: ¡Alerta roja! Bloquea el teclado y suelta ese teléfono ahora mismo.',
101
+ 'Yo: Es que solo le voy a contestar "quién eres" para verme indignado.',
102
+ 'Olo: ¡Mentira! En 4 minutos le estás escribiendo un testamento de 90 líneas.',
103
+ 'Olo: Dignidad verificada en cero. Te decomiso el WhatsApp por 24 horas.'
104
+ ].join('\n')
105
+ },
106
+ jefe_viernes: {
107
+ id: 'jefe_viernes',
108
+ title: 'Viernes 6:58 PM en la oficina',
109
+ category: 'Rugido de Oficina',
110
+ wear: 'casco',
111
+ tint: 'butter',
112
+ vibe: 'hype',
113
+ script: [
114
+ 'Jefe: Equipo, ¿quién me apoya con un cambiecito súper rápido de 5 minutos?',
115
+ 'Olo: Buscando en el diccionario: significa que vas a cenar frente al monitor.',
116
+ 'Yo: Jefe, justo iba saliendo porque tengo una urgencia familiar.',
117
+ 'Jefe: Pero si te vi subiendo historias en los tacos hace diez minutos.',
118
+ 'Olo: ¡Impacto crítico! Cierra la terminal y corre sin mirar atrás.'
119
+ ].join('\n')
120
+ },
121
+ cliente_caro: {
122
+ id: 'cliente_caro',
123
+ title: 'Mi sobrino lo hace más barato',
124
+ category: 'Cierre de Ventas',
125
+ wear: 'corona',
126
+ tint: 'mint',
127
+ vibe: 'victory',
128
+ script: [
129
+ 'Cliente: Me encanta tu propuesta, pero mi sobrino me cobra 500 pesos en Canva.',
130
+ 'Olo: Revisando el plan: respira hondo y no le lances el escritorio todavía.',
131
+ 'Yo: Perfecto, encárgaselo a tu sobrino y nos vemos en un mes cuando falle.',
132
+ 'Cliente: Bueno, espera, ¿si te pago hoy me incluyes los videos para TikTok?',
133
+ 'Olo: ¡Jaque mate! Factura emitida con 30 por ciento extra por daño psicológico.'
134
+ ].join('\n')
135
+ },
136
+ espejo_roast: {
137
+ id: 'espejo_roast',
138
+ title: 'Lo que tu IA calla de ti',
139
+ category: 'Espejo de IA',
140
+ wear: 'mago',
141
+ tint: 'lilac',
142
+ vibe: 'roast',
143
+ script: [
144
+ 'Olo: Escaneé con lupa tus últimos 400 chats de inteligencia artificial.',
145
+ 'Olo: Leyendo tu historial: el 68 por ciento son a las 2 de la mañana.',
146
+ 'Yo: Es que soy un estratega global optimizando mi tiempo con mi equipo.',
147
+ 'Olo: Le dijiste "por favor y gracias" 94 veces por miedo a que te conquiste.',
148
+ 'Olo: ¡Nivel legendario! Top 3 por ciento de Latinoamérica. Reta a tu grupo.'
149
+ ].join('\n')
150
+ }
151
+ }
152
+
153
+ const SPARRING_MISSIONS = {
154
+ sueldo_25: {
155
+ id: 'sueldo_25',
156
+ title: 'Súbeme el sueldo +25%',
157
+ subtitle: 'Negociación bajo presión · 60s',
158
+ lang: 'es-MX',
159
+ wear: 'corona',
160
+ tint: 'rose',
161
+ openingLine: 'No hay presupuesto este trimestre. Dime en 45 segundos por qué debería subirte el sueldo 25% hoy y no el próximo año.',
162
+ powerKeywords: ['resultados', 'ventas', 'ingresos', 'ahorro', 'clientes', 'entregué', 'meta', 'valor', 'mercado', 'oferta', 'retorno', 'lideré', 'producción', 'porcentaje', 'doble'],
163
+ weakKeywords: ['creo', 'tal vez', 'quizás', 'perdón', 'disculpa', 'necesito', 'gastos', 'renta', 'favor', 'ojalá'],
164
+ fillers: ['ehh', 'este', 'mmm', 'osea', 'bueno', 'pues'],
165
+ rebuttals: [
166
+ { atScore: 35, state: 'needs_approval', text: 'Todos trabajan duro aquí. Dame un número concreto que hayas movido.' },
167
+ { atScore: 65, state: 'planning', text: 'Okay, ese resultado sí me duele perderlo. ¿Qué propones cerrar este mes si te lo apruebo?' },
168
+ { atScore: 85, state: 'proud', text: '¡Trato cerrado! Tienes tu 25% a partir del día primero.' }
169
+ ]
170
+ },
171
+ cliente_objecion: {
172
+ id: 'cliente_objecion',
173
+ title: 'Rebate: "Está muy caro"',
174
+ subtitle: 'Cierre de ventas en frío · 60s',
175
+ lang: 'es-MX',
176
+ wear: 'gafas_sol',
177
+ tint: 'butter',
178
+ openingLine: 'Me gusta lo que haces, pero tu competencia cobra la mitad. Convénceme en 45 segundos sin bajar tu precio.',
179
+ powerKeywords: ['garantía', 'retorno', 'riesgo', 'tiempo', 'calidad', 'clientes', 'resultados', 'costo', 'inversión', 'barato', 'caro', 'mes', 'hoy', 'implementación'],
180
+ weakKeywords: ['descuento', 'rebaja', 'barato', 'mitad', 'bueno te bajo', 'perdón', 'quizás'],
181
+ fillers: ['ehh', 'este', 'mmm', 'osea', 'digamos'],
182
+ rebuttals: [
183
+ { atScore: 35, state: 'alert', text: 'No me hables de características. ¿Cuánto dinero pierdo si me voy con el barato?' },
184
+ { atScore: 65, state: 'searching', text: 'Tienes un punto con el costo del retrabajo. ¿En cuántos días veo el primer entregable?' },
185
+ { atScore: 85, state: 'proud', text: 'Mándame la liga de pago ahora mismo. Cerrado a precio completo.' }
186
+ ]
187
+ },
188
+ english_interview: {
189
+ id: 'english_interview',
190
+ title: 'Silicon Valley Interview',
191
+ subtitle: 'English Under Pressure · 60s',
192
+ lang: 'en-US',
193
+ wear: 'auriculares',
194
+ tint: 'sky',
195
+ openingLine: 'We have 200 applicants for this remote role. In 45 seconds, tell me the hardest problem you solved and why we should hire you today.',
196
+ powerKeywords: ['built', 'shipped', 'scaled', 'reduced', 'increased', 'users', 'revenue', 'latency', 'production', 'team', 'automated', 'solved', 'impact', 'percent', 'days'],
197
+ weakKeywords: ['maybe', 'sorry', 'try', 'little', 'hoping', 'guess', 'dont know'],
198
+ fillers: ['um', 'uh', 'like', 'basically', 'literally'],
199
+ rebuttals: [
200
+ { atScore: 35, state: 'needs_approval', text: 'Skip the resume buzzwords. What actually broke and how did you fix it?' },
201
+ { atScore: 65, state: 'running', text: 'Strong execution. How fast can you ship your first feature if you start Monday?' },
202
+ { atScore: 85, state: 'proud', text: 'You are hired. Expect the offer letter in your inbox tonight!' }
203
+ ]
204
+ }
205
+ }
206
+
207
+ function fnv1aHex(str) {
208
+ let h1 = 0xdeadbeef ^ str.length
209
+ let h2 = 0x41c6ce57 ^ str.length
210
+ for (let i = 0; i < str.length; i++) {
211
+ const ch = str.charCodeAt(i)
212
+ h1 = Math.imul(h1 ^ ch, 2654435761)
213
+ h2 = Math.imul(h2 ^ ch, 1597334677)
214
+ }
215
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
216
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
217
+ return (h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0')
218
+ }
219
+
220
+ function hashFloat(n) {
221
+ const x = Math.sin(n * 127.1 + 311.7) * 43758.5453123
222
+ return x - Math.floor(x)
223
+ }
224
+
225
+ function noise1D(x) {
226
+ const i = Math.floor(x)
227
+ const f = x - i
228
+ const u = f * f * (3 - 2 * f)
229
+ return (hashFloat(i) * (1 - u) + hashFloat(i + 1) * u) * 2 - 1
230
+ }
231
+
232
+ function fractalNoise1D(x, seed = 0) {
233
+ const s = x + seed * 19.37
234
+ return 0.58 * noise1D(s) + 0.28 * noise1D(s * 2.13 + 7.1) + 0.14 * noise1D(s * 4.41 + 13.7)
235
+ }
236
+
237
+ function computeVisemesForWord(cleanWord) {
238
+ const w = (cleanWord || '').toLowerCase()
239
+ if (!w) return [{ viseme: 'A_AH', p0: 0, p1: 1 }]
240
+
241
+ const tokens = []
242
+ let i = 0
243
+ while (i < w.length) {
244
+ const ch = w[i]
245
+ const next = w[i + 1] || ''
246
+ if ('mbp'.includes(ch)) {
247
+ tokens.push('M_B_P')
248
+ i += next === ch ? 2 : 1
249
+ } else if ('fvszcjxtdlrnñ'.includes(ch)) {
250
+ tokens.push('F_V')
251
+ while (i + 1 < w.length && 'fvszcjxtdlrnñh'.includes(w[i + 1])) i++
252
+ i++
253
+ } else if ('aáä'.includes(ch)) {
254
+ tokens.push('A_AH')
255
+ i++
256
+ } else if ('eéëiíïy'.includes(ch)) {
257
+ tokens.push('E_EE')
258
+ i++
259
+ } else if ('oóöuúüw'.includes(ch)) {
260
+ tokens.push('O_U')
261
+ i++
262
+ } else if (/\d/.test(ch)) {
263
+ tokens.push((ch.charCodeAt(0) % 2 === 0) ? 'O_U' : 'E_EE')
264
+ i++
265
+ } else {
266
+ i++
267
+ }
268
+ }
269
+
270
+ if (!tokens.length) tokens.push('A_AH')
271
+ const compact = []
272
+ for (let k = 0; k < tokens.length; k++) {
273
+ if (compact[compact.length - 1] !== tokens[k]) compact.push(tokens[k])
274
+ if (compact.length >= 6) break
275
+ }
276
+
277
+ const weights = compact.map((v) => (v === 'M_B_P' ? 0.55 : v === 'F_V' ? 0.65 : 1.35))
278
+ const totalW = weights.reduce((a, b) => a + b, 0)
279
+ let cursor = 0
280
+ return compact.map((v, idx) => {
281
+ const span = weights[idx] / totalW
282
+ const seg = {
283
+ viseme: v,
284
+ p0: Number(cursor.toFixed(3)),
285
+ p1: Number(Math.min(1, cursor + span).toFixed(3)),
286
+ f1: OLO_VISEMES[v].f1,
287
+ f2: OLO_VISEMES[v].f2,
288
+ }
289
+ cursor += span
290
+ return seg
291
+ })
292
+ }
293
+
294
+ /**
295
+ * Clasifica cada línea del guion usando los 21 estados 3D reales de Olo (`manifest.json`)
296
+ * y asigna los efectos de `motion.js` (`orb`, `paper`, `memory`, `burst`, `gold`).
297
+ */
298
+ function classifyBeat(lineText, speaker, idx, total) {
299
+ const low = lineText.toLowerCase()
300
+ const isExclaim = lineText.includes('!') || lineText.includes('¡')
301
+ const isQuestion = lineText.includes('?') || lineText.includes('¿')
302
+
303
+ let state = 'idle'
304
+ let cam = 'medium'
305
+ let sfx = 'blip'
306
+ let highlightColor = '#f2c14e'
307
+ let turntableYaw = 0
308
+ let prosody = 'neutral'
309
+ let motionEvent = 'step' // 'step' | 'paper' | 'memory' | 'burst' | 'error' | 'gold'
310
+
311
+ if (/alerta|bloquea|peligro|cuidado|urgencia|impacto/.test(low)) {
312
+ state = 'alert'
313
+ cam = 'crash_zoom'
314
+ sfx = 'impact'
315
+ highlightColor = '#ff5d7a'
316
+ turntableYaw = -10
317
+ prosody = 'excited'
318
+ motionEvent = 'step'
319
+ } else if (/mentira|colapso|error|cero|decomiso|daño|falle|miedo|quiebra|crisis|fatal/.test(low)) {
320
+ state = 'error'
321
+ cam = 'shake'
322
+ sfx = 'thunk'
323
+ highlightColor = '#ff5d7a'
324
+ turntableYaw = 12
325
+ prosody = 'tense'
326
+ motionEvent = 'error'
327
+ } else if (/increíble|increible|wow|épico|epico|brutal|celebrar|victoria|ganamos|celebrate|amazing|legendary/.test(low)) {
328
+ state = 'proud'
329
+ cam = 'hero_low'
330
+ sfx = 'bell'
331
+ highlightColor = '#3ddc97'
332
+ turntableYaw = -12
333
+ prosody = 'confident'
334
+ motionEvent = 'gold'
335
+ } else if (/jaque mate|nivel legendario|top |coronando|ganaste|contratado|hired/.test(low)) {
336
+ state = 'proud'
337
+ cam = 'hero_low'
338
+ sfx = 'bell'
339
+ highlightColor = '#3ddc97'
340
+ turntableYaw = -12
341
+ prosody = 'confident'
342
+ motionEvent = 'gold'
343
+ } else if (/cerrado|trato|éxito|perfecto|terminada/.test(low)) {
344
+ state = 'done'
345
+ cam = 'hero_low'
346
+ sfx = 'bell'
347
+ highlightColor = '#3ddc97'
348
+ turntableYaw = -8
349
+ prosody = 'confident'
350
+ motionEvent = 'burst'
351
+ } else if (/boop|corazón|corazon|te quiero|abracito|cariño|amor|love/.test(low)) {
352
+ state = 'boop'
353
+ cam = 'closeup'
354
+ sfx = 'bell'
355
+ highlightColor = '#ff5d7a'
356
+ turntableYaw = 0
357
+ prosody = 'confident'
358
+ motionEvent = 'burst'
359
+ } else if (/bostezo|bostezar|aburrido|flojera|sueñito|yawn|boring/.test(low)) {
360
+ state = 'yawn'
361
+ cam = 'medium'
362
+ sfx = 'whoosh'
363
+ highlightColor = '#4d86ff'
364
+ turntableYaw = 6
365
+ prosody = 'neutral'
366
+ motionEvent = 'step'
367
+ } else if (/dormir|durmiendo|a la cama|buenas noches|sleep|sleeping|zzz/.test(low)) {
368
+ state = 'sleeping'
369
+ cam = 'wide_in'
370
+ sfx = 'whoosh'
371
+ highlightColor = '#4d86ff'
372
+ turntableYaw = 4
373
+ prosody = 'neutral'
374
+ motionEvent = 'step'
375
+ } else if (/escaneé|lupa|buscando|diccionario|auditoría|investigué/.test(low)) {
376
+ state = 'searching'
377
+ cam = 'closeup'
378
+ sfx = 'whoosh'
379
+ highlightColor = '#4d86ff'
380
+ turntableYaw = 10
381
+ prosody = 'neutral'
382
+ motionEvent = 'memory'
383
+ } else if (/leyendo|historial|libro|leí|mensajes/.test(low)) {
384
+ state = 'reading'
385
+ cam = 'dutch_right'
386
+ sfx = 'pluck'
387
+ highlightColor = '#8fb3ff'
388
+ turntableYaw = -8
389
+ prosody = 'neutral'
390
+ motionEvent = 'paper'
391
+ } else if (/escribiendo|testamento|redacten|correos|libreta/.test(low)) {
392
+ state = 'writing'
393
+ cam = 'closeup'
394
+ sfx = 'blip'
395
+ highlightColor = '#4d86ff'
396
+ turntableYaw = 12
397
+ prosody = 'excited'
398
+ motionEvent = 'paper'
399
+ } else if (/revisando el plan|propuesta|factura|checklist|meta/.test(low)) {
400
+ state = 'planning'
401
+ cam = 'dutch_right'
402
+ sfx = 'pluck'
403
+ highlightColor = '#3ddc97'
404
+ turntableYaw = -10
405
+ prosody = 'confident'
406
+ motionEvent = 'paper'
407
+ } else if (/terminal|código|monitor|sistema|pipeline/.test(low)) {
408
+ state = 'running'
409
+ cam = 'crash_zoom'
410
+ sfx = 'whoosh'
411
+ highlightColor = '#3b6cff'
412
+ turntableYaw = 8
413
+ prosody = 'excited'
414
+ motionEvent = 'step'
415
+ } else if (/global|latinoamérica|mundo|internacional|tacos/.test(low)) {
416
+ state = 'browsing'
417
+ cam = 'medium'
418
+ sfx = 'whoosh'
419
+ highlightColor = '#4d86ff'
420
+ turntableYaw = -9
421
+ prosody = 'neutral'
422
+ motionEvent = 'memory'
423
+ } else if (/equipo|grupo|subagentes|apoya|todos/.test(low)) {
424
+ state = 'delegating'
425
+ cam = 'wide_in'
426
+ sfx = 'pluck'
427
+ highlightColor = '#8fb3ff'
428
+ turntableYaw = 8
429
+ prosody = 'neutral'
430
+ motionEvent = 'step'
431
+ } else if (/optimizando|engrane|herramienta|producción/.test(low)) {
432
+ state = 'tool'
433
+ cam = 'dutch_right'
434
+ sfx = 'blip'
435
+ highlightColor = '#3b6cff'
436
+ turntableYaw = 10
437
+ prosody = 'neutral'
438
+ motionEvent = 'step'
439
+ } else if (/quién eres|indignado|hola perdido/.test(low)) {
440
+ state = 'curious'
441
+ cam = 'closeup'
442
+ sfx = 'pluck'
443
+ highlightColor = '#f2c14e'
444
+ turntableYaw = -12
445
+ prosody = 'question'
446
+ motionEvent = 'memory'
447
+ } else if (isQuestion && /mira|ves|viste|fíjate|fijate|ojos|look|see/.test(low)) {
448
+ state = 'look'
449
+ cam = 'closeup'
450
+ sfx = 'pluck'
451
+ highlightColor = '#3b6cff'
452
+ turntableYaw = -8
453
+ prosody = 'question'
454
+ motionEvent = 'memory'
455
+ } else if (isQuestion && /quién|quien|cómo|como|por qué|por que|cuál|cual|dónde|donde|cuándo|cuando|who|how|why|what|where/.test(low)) {
456
+ state = 'curious'
457
+ cam = 'closeup'
458
+ sfx = 'pluck'
459
+ highlightColor = '#f2c14e'
460
+ turntableYaw = -10
461
+ prosody = 'question'
462
+ motionEvent = 'memory'
463
+ } else if (isQuestion || /cambiecito|sobrino|espera|seguro/.test(low)) {
464
+ state = 'needs_approval'
465
+ cam = 'closeup'
466
+ sfx = 'pluck'
467
+ highlightColor = '#f2c14e'
468
+ turntableYaw = -10
469
+ prosody = 'question'
470
+ motionEvent = 'step'
471
+ } else if (idx === 0) {
472
+ state = 'thinking'
473
+ cam = 'wide_in'
474
+ sfx = 'whoosh'
475
+ highlightColor = '#8fb3ff'
476
+ turntableYaw = 6
477
+ prosody = 'neutral'
478
+ motionEvent = 'memory'
479
+ } else if (isExclaim) {
480
+ state = 'alert'
481
+ cam = 'closeup'
482
+ sfx = 'impact'
483
+ highlightColor = '#f2c14e'
484
+ turntableYaw = -8
485
+ prosody = 'excited'
486
+ motionEvent = 'step'
487
+ }
488
+
489
+ if (idx === total - 1 && (state === 'idle' || state === 'working')) {
490
+ state = 'proud'
491
+ cam = 'crash_zoom'
492
+ sfx = 'bell'
493
+ highlightColor = '#3ddc97'
494
+ turntableYaw = 0
495
+ prosody = 'confident'
496
+ motionEvent = 'burst'
497
+ }
498
+
499
+ const speakerLow = (speaker || '').toLowerCase()
500
+ let voicePitch = 290
501
+ if (speakerLow.includes('olo')) voicePitch = 340
502
+ else if (speakerLow.includes('jefe') || speakerLow.includes('cliente') || speakerLow.includes('ex')) voicePitch = 195
503
+ else voicePitch = 250
504
+
505
+ if (prosody === 'excited') voicePitch = Math.round(voicePitch * Math.pow(2, 3.0 / 12))
506
+ else if (prosody === 'tense') voicePitch = Math.round(voicePitch * Math.pow(2, 1.5 / 12))
507
+ else if (prosody === 'confident') voicePitch = Math.round(voicePitch * Math.pow(2, -1.0 / 12))
508
+
509
+ return { state, cam, sfx, highlightColor, voicePitch, turntableYaw, prosody, motionEvent }
510
+ }
511
+
512
+ function buildWordTimings(text, tStart, tEnd) {
513
+ const rawWords = text.trim().split(/\s+/).filter(Boolean)
514
+ if (!rawWords.length) return []
515
+ const span = Math.max(0.6, tEnd - tStart - 0.18)
516
+ const weights = rawWords.map((w) => {
517
+ let wt = Math.max(1.2, Math.min(4.2, w.length * 0.38))
518
+ if (/[,.:;!?¡¿]/.test(w)) wt += 0.65
519
+ return wt
520
+ })
521
+ const totalW = weights.reduce((a, b) => a + b, 0)
522
+ let cursor = tStart + 0.06
523
+ return rawWords.map((w, i) => {
524
+ const dur = (weights[i] / totalW) * span
525
+ const clean = w.replace(/[^\p{L}\p{N}%]/gu, '')
526
+ const visemes = computeVisemesForWord(clean)
527
+ const item = {
528
+ word: w,
529
+ clean,
530
+ t0: Number(cursor.toFixed(3)),
531
+ t1: Number((cursor + dur).toFixed(3)),
532
+ emphasis: /[A-ZÁÉÍÓÚ]{3,}|[!¡?¿%0-9]/.test(w) || w.length >= 8,
533
+ isQuestionWord: /[?¿]/.test(w),
534
+ isExclaimWord: /[!¡]/.test(w),
535
+ visemes,
536
+ }
537
+ cursor += dur
538
+ return item
539
+ })
540
+ }
541
+
542
+ const VALID_SCENARIOS = [
543
+ 'studio', 'lofi', 'gamer', 'podcast', 'news', 'car', 'court', 'comedy', 'sunset'
544
+ ]
545
+
546
+ function normalizeScenario(rawScenario) {
547
+ if (!rawScenario || typeof rawScenario !== 'string') return 'lofi'
548
+ const clean = rawScenario.trim().toLowerCase()
549
+ if (clean === 'lofi_night') return 'lofi'
550
+ return VALID_SCENARIOS.includes(clean) ? clean : 'lofi'
551
+ }
552
+
553
+ /**
554
+ * Compila un guion o conversación en un `SceneSpec v1` determinista con los 21 estados 3D de Olo.
555
+ */
556
+ function compileScene(opts = {}) {
557
+ const rawScript = String(opts.script || opts.scriptText || PRESET_SHOWS.whatsapp_ex.script).trim()
558
+ const requestedMaxLines = Number(opts.maxLines)
559
+ const maxLines = Number.isFinite(requestedMaxLines) && requestedMaxLines > 0
560
+ ? Math.max(1, Math.min(30, Math.floor(requestedMaxLines)))
561
+ : 10
562
+ const rawLines = rawScript
563
+ .split(/\r?\n/)
564
+ .map((l) => l.trim())
565
+ .filter(Boolean)
566
+ .slice(0, maxLines)
567
+
568
+ const wear = OLO_WEARS.includes(opts.wear) ? opts.wear : 'gafas_sol'
569
+ const tint = OLO_TINTS[opts.tint] ? opts.tint : 'rose'
570
+ const finish = opts.finish || opts.collectorFinish || 'vinyl'
571
+ const title = (opts.title || 'Olo Studio Show').slice(0, 60)
572
+ const category = (opts.category || 'Show 9:16').slice(0, 32)
573
+ const vibe = opts.vibe || 'drama'
574
+ const scenario = normalizeScenario(opts.scenario)
575
+ const cameraOverride = OLO_CAMERA_MODES[opts.cameraOverride] ? opts.cameraOverride : 'auto'
576
+ const stateOverride = opts.stateOverride && OLO_STATES[opts.stateOverride] ? opts.stateOverride : null
577
+ const duelConfig = (opts.duelConfig && typeof opts.duelConfig === 'object') ? Object.assign({}, opts.duelConfig) : null
578
+ const voiceFx = opts.voiceFx
579
+ ? (typeof opts.voiceFx === 'object' ? Object.assign({}, opts.voiceFx) : String(opts.voiceFx))
580
+ : 'none'
581
+ const drillBeat = opts.drillBeat
582
+ ? (typeof opts.drillBeat === 'object' ? Object.assign({}, opts.drillBeat) : opts.drillBeat)
583
+ : null
584
+ const customOutro = (opts.customOutro && typeof opts.customOutro === 'object') ? Object.assign({}, opts.customOutro) : null
585
+ const pipMeta = (opts.pipMeta && typeof opts.pipMeta === 'object') ? Object.assign({}, opts.pipMeta) : null
586
+
587
+ const beats = []
588
+ let tCursor = 0.45
589
+
590
+ rawLines.forEach((raw, idx) => {
591
+ let speaker = 'Olo'
592
+ let text = raw
593
+ const colonIdx = raw.indexOf(':')
594
+ if (colonIdx > 0 && colonIdx < 18) {
595
+ speaker = raw.slice(0, colonIdx).trim()
596
+ text = raw.slice(colonIdx + 1).trim()
597
+ }
598
+ const wordCount = text.split(/\s+/).filter(Boolean).length
599
+ const duration = Math.max(2.1, Math.min(4.4, 0.75 + wordCount * 0.27))
600
+ const t0 = Number(tCursor.toFixed(3))
601
+ const t1 = Number((tCursor + duration).toFixed(3))
602
+ const meta = classifyBeat(text, speaker, idx, rawLines.length)
603
+ const words = buildWordTimings(text, t0, t1)
604
+
605
+ beats.push({
606
+ index: idx,
607
+ speaker,
608
+ isOlo: speaker.toLowerCase().includes('olo'),
609
+ text,
610
+ t0,
611
+ t1,
612
+ duration: Number(duration.toFixed(3)),
613
+ state: stateOverride || meta.state,
614
+ cam: cameraOverride !== 'auto' ? cameraOverride : meta.cam,
615
+ sfx: meta.sfx,
616
+ highlightColor: meta.highlightColor,
617
+ voicePitch: meta.voicePitch,
618
+ turntableYaw: meta.turntableYaw,
619
+ prosody: meta.prosody,
620
+ motionEvent: meta.motionEvent,
621
+ words,
622
+ })
623
+ tCursor = t1 + 0.12
624
+ })
625
+
626
+ const outroDuration = 1.35
627
+ const totalDuration = Number((tCursor + outroDuration).toFixed(3))
628
+ const canonicalPayload = JSON.stringify({
629
+ title,
630
+ category,
631
+ wear,
632
+ tint,
633
+ finish,
634
+ scenario,
635
+ cameraOverride,
636
+ stateOverride,
637
+ beats: beats.map((b) => [b.speaker, b.text, b.state, b.cam])
638
+ })
639
+ const sceneHash = fnv1aHex(canonicalPayload)
640
+
641
+ const sceneSpec = {
642
+ schema: 'olomni.studio.scene.v1',
643
+ sceneHash,
644
+ title,
645
+ category,
646
+ wear: wear === 'none' ? null : wear,
647
+ tint,
648
+ finish,
649
+ vibe,
650
+ scenario,
651
+ cameraOverride,
652
+ stateOverride,
653
+ duelConfig,
654
+ voiceFx,
655
+ drillBeat,
656
+ customOutro,
657
+ pipMeta,
658
+ duration: totalDuration,
659
+ outroStart: Number(tCursor.toFixed(3)),
660
+ beats,
661
+ }
662
+ if (typeof opts.watermark === 'boolean') {
663
+ sceneSpec.watermark = opts.watermark
664
+ }
665
+ return sceneSpec
666
+ }
667
+
668
+ /**
669
+ * Wrapper canónico para compilación desde texto libre (`script` o `scriptText`).
670
+ */
671
+ function compileSceneFromScript(opts = {}) {
672
+ const safeOpts = opts && typeof opts === 'object' ? opts : {}
673
+ return compileScene(Object.assign({}, safeOpts, {
674
+ script: safeOpts.script || safeOpts.scriptText,
675
+ }))
676
+ }
677
+
678
+ /**
679
+ * Evalúa la curva de anticipación canónica `POP` de `character.js` (duración 340ms al cambiar de estado).
680
+ */
681
+ function evalCanonicalPop(stateName, elapsedSec) {
682
+ const p = STATE_POP[stateName]
683
+ if (!p || elapsedSec < 0 || elapsedSec >= 0.34) return [1, 1]
684
+ const u = elapsedSec / 0.34
685
+ const [sx0, sy0] = p
686
+ const sx1 = 2 - sx0
687
+ const sy1 = 2 - sy0
688
+ if (u < 0.35) {
689
+ const k = u / 0.35
690
+ return [1 + (sx0 - 1) * k, 1 + (sy0 - 1) * k]
691
+ } else if (u < 0.70) {
692
+ const k = (u - 0.35) / 0.35
693
+ return [sx0 + (sx1 - sx0) * k, sy0 + (sy1 - sy0) * k]
694
+ } else {
695
+ const k = (u - 0.70) / 0.30
696
+ return [sx1 + (1 - sx1) * k, sy1 + (1 - sy1) * k]
697
+ }
698
+ }
699
+
700
+ function computeBodyKinematicsAt(scene, tau) {
701
+ let activeBeat = null
702
+ let prevBeat = null
703
+ for (let i = 0; i < scene.beats.length; i++) {
704
+ const b = scene.beats[i]
705
+ if (tau >= b.t0 && tau <= b.t1 + 0.12) {
706
+ activeBeat = b
707
+ prevBeat = i > 0 ? scene.beats[i - 1] : null
708
+ break
709
+ }
710
+ }
711
+ if (!activeBeat && scene.beats.length && tau < scene.outroStart) {
712
+ activeBeat = scene.beats[0]
713
+ }
714
+
715
+ let activeWord = null
716
+ let wordIndex = -1
717
+ if (activeBeat) {
718
+ for (let i = 0; i < activeBeat.words.length; i++) {
719
+ const w = activeBeat.words[i]
720
+ if (tau >= w.t0 && tau <= w.t1) {
721
+ activeWord = w
722
+ wordIndex = i
723
+ break
724
+ }
725
+ }
726
+ }
727
+
728
+ const stateName = tau >= scene.outroStart ? 'proud' : (activeBeat ? activeBeat.state : 'idle')
729
+ const beatElapsed = activeBeat ? Math.max(0, tau - activeBeat.t0) : tau
730
+
731
+ // 1. Curva `POP` canónica de `character.js` al entrar a un nuevo estado
732
+ const [popX, popY] = evalCanonicalPop(stateName, beatElapsed)
733
+ let squashX = popX
734
+ let stretchY = 1 / squashX
735
+ let headTiltDeg = 0
736
+ let bodyOffsetY = 0
737
+
738
+ // 2. Pulso silábico sutil y cohesivo en todo el cuerpo 3D (anclado en los pies `FEET = 0.955`)
739
+ let mouthOpen = 0
740
+ let visemeId = 'REST'
741
+ let visemeShape = OLO_VISEMES.REST
742
+
743
+ if (activeWord && activeBeat) {
744
+ const wordDur = Math.max(0.05, activeWord.t1 - activeWord.t0)
745
+ const wordProgress = Math.max(0, Math.min(1, (tau - activeWord.t0) / wordDur))
746
+
747
+ const segs = activeWord.visemes || [{ viseme: 'A_AH', p0: 0, p1: 1 }]
748
+ let chosenSeg = segs[0]
749
+ for (let s = 0; s < segs.length; s++) {
750
+ if (wordProgress >= segs[s].p0 && wordProgress <= segs[s].p1) {
751
+ chosenSeg = segs[s]
752
+ break
753
+ }
754
+ }
755
+ visemeId = chosenSeg.viseme
756
+ const targetV = OLO_VISEMES[visemeId] || OLO_VISEMES.A_AH
757
+
758
+ const segSpan = Math.max(0.08, chosenSeg.p1 - chosenSeg.p0)
759
+ const localP = Math.max(0, Math.min(1, (wordProgress - chosenSeg.p0) / segSpan))
760
+ const segEnv = Math.sin(localP * Math.PI)
761
+ const emphasisBoost = activeWord.emphasis ? 1.2 : 1.0
762
+
763
+ mouthOpen = Math.min(1, (0.35 + 0.65 * segEnv) * targetV.open * emphasisBoost)
764
+ visemeShape = {
765
+ id: visemeId,
766
+ open: Number(mouthOpen.toFixed(3)),
767
+ width: Number((1.0 + (targetV.width - 1.0) * (0.4 + 0.6 * segEnv)).toFixed(3)),
768
+ round: Number((targetV.round * (0.45 + 0.55 * segEnv)).toFixed(3)),
769
+ teeth: Number((targetV.teeth * segEnv).toFixed(3)),
770
+ f1: targetV.f1,
771
+ f2: targetV.f2,
772
+ label: targetV.label,
773
+ }
774
+
775
+ // El pulso de voz comprime/estira suavemente a Olo conservando el área (squashX * stretchY = 1.0)
776
+ const roleFactor = activeBeat.isOlo ? 1.0 : 0.45
777
+ const vocalSquash = 1 + mouthOpen * 0.034 * roleFactor
778
+ squashX *= vocalSquash
779
+ stretchY = 1 / squashX
780
+ headTiltDeg = Math.sin(tau * 6.8 + wordIndex * 1.1) * (activeWord.emphasis ? 2.4 : 1.1) * roleFactor
781
+ bodyOffsetY = -mouthOpen * 5.0 * roleFactor
782
+ }
783
+
784
+ return {
785
+ activeBeat,
786
+ prevBeat,
787
+ activeWord,
788
+ wordIndex,
789
+ stateName,
790
+ beatElapsed,
791
+ mouthOpen,
792
+ visemeId,
793
+ visemeShape,
794
+ squashX,
795
+ stretchY,
796
+ headTiltDeg,
797
+ bodyOffsetY,
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Evalúa el estado completo de la escena en el instante `t` (segundos).
803
+ * Incluye transiciones `FADE = 170ms` entre estados (como `character.js`), telemetría de `motion.js` y cámara DOP.
804
+ */
805
+ function sampleSceneAt(scene, t) {
806
+ const clampedT = Math.max(0, Math.min(scene.duration, Number(t) || 0))
807
+ const isIntro = clampedT < (scene.beats[0] ? scene.beats[0].t0 : 0.45)
808
+ const isOutro = clampedT >= scene.outroStart
809
+
810
+ const nowKin = computeBodyKinematicsAt(scene, clampedT)
811
+ const lagKin = computeBodyKinematicsAt(scene, Math.max(0, clampedT - 0.068))
812
+
813
+ const activeBeat = nowKin.activeBeat
814
+ const activeWord = nowKin.activeWord
815
+ const wordIndex = nowKin.wordIndex
816
+ const stateName = nowKin.stateName
817
+ const stateDef = OLO_STATES[stateName] || OLO_STATES.idle
818
+ const beatElapsed = nowKin.beatElapsed
819
+ const rawFrame = Math.floor(beatElapsed * stateDef.fps)
820
+ const frameIndex = stateDef.loop ? (rawFrame % stateDef.frames) : Math.min(stateDef.frames - 1, rawFrame)
821
+
822
+ // Transición suave de 170ms (`FADE = 0.17s` de `character.js`) con el estado del beat anterior
823
+ let prevStateDef = null
824
+ let prevFrameIndex = 0
825
+ let fadeAlpha = 0
826
+ if (activeBeat && beatElapsed < 0.17 && nowKin.prevBeat && nowKin.prevBeat.state !== stateName) {
827
+ prevStateDef = OLO_STATES[nowKin.prevBeat.state] || OLO_STATES.idle
828
+ const prevDur = nowKin.prevBeat.duration || 2.0
829
+ const pRaw = Math.floor(prevDur * prevStateDef.fps)
830
+ prevFrameIndex = prevStateDef.loop ? (pRaw % prevStateDef.frames) : (prevStateDef.frames - 1)
831
+ const k = 1 - beatElapsed / 0.17
832
+ fadeAlpha = Number((k * k).toFixed(3))
833
+ }
834
+
835
+ const velY = nowKin.bodyOffsetY - lagKin.bodyOffsetY
836
+ const velTilt = nowKin.headTiltDeg - lagKin.headTiltDeg
837
+ const wearSpring = {
838
+ offsetX: Number((velTilt * -1.2).toFixed(2)),
839
+ offsetY: Number((-velY * 1.1).toFixed(2)),
840
+ tiltDeg: Number((-velTilt * 0.9).toFixed(2)),
841
+ scaleY: Number((1.0 + (lagKin.stretchY - nowKin.stretchY) * 0.5).toFixed(4)),
842
+ }
843
+
844
+ const blinkCycle = (clampedT + Math.abs(fractalNoise1D(clampedT * 0.25, 41)) * 0.9) % 3.4
845
+ const blinkAmount = blinkCycle < 0.13 ? Math.sin((blinkCycle / 0.13) * Math.PI) : 0
846
+ const microExpressions = {
847
+ blinkAmount: Number(blinkAmount.toFixed(3)),
848
+ saccadeX: Number((fractalNoise1D(clampedT * 0.9, 17) * 3.5).toFixed(2)),
849
+ saccadeY: Number((fractalNoise1D(clampedT * 0.8, 31) * 2.5).toFixed(2)),
850
+ breathOffsetY: Number(nowKin.bodyOffsetY.toFixed(2)),
851
+ }
852
+
853
+ const handheldDriftX = fractalNoise1D(clampedT * 0.95, 101) * 5.5
854
+ const handheldDriftY = fractalNoise1D(clampedT * 0.88, 202) * 4.2
855
+ const handheldRot = fractalNoise1D(clampedT * 0.72, 303) * 0.42
856
+
857
+ let camZoom = 1.0
858
+ let camOffsetX = handheldDriftX
859
+ let camOffsetY = handheldDriftY
860
+ let camRotateDeg = handheldRot
861
+ let turntableYawDeg = 0
862
+ let chromaticAberration = 0
863
+
864
+ if (activeBeat) {
865
+ const bp = Math.max(0, Math.min(1, (clampedT - activeBeat.t0) / Math.max(0.1, activeBeat.duration)))
866
+ const dtBeat = Math.max(0, clampedT - activeBeat.t0)
867
+ const entryPop = Math.exp(-bp * 8) * Math.sin(bp * 24)
868
+ const targetYaw = activeBeat.turntableYaw || 0
869
+ turntableYawDeg = targetYaw * Math.min(1, dtBeat * 4.5)
870
+
871
+ switch (activeBeat.cam) {
872
+ case 'crash_zoom':
873
+ camZoom = 1.0 + Math.min(1, bp * 3.2) * 0.18 + entryPop * 0.03
874
+ camOffsetY = -22 + handheldDriftY * 0.5
875
+ chromaticAberration = Math.exp(-dtBeat * 5.5) * 0.75
876
+ break
877
+ case 'closeup':
878
+ camZoom = 1.12 + Math.sin(bp * Math.PI) * 0.035
879
+ camOffsetY = -14 + handheldDriftY * 0.5
880
+ break
881
+ case 'shake':
882
+ camZoom = 1.10
883
+ camOffsetX = fractalNoise1D(clampedT * 26, 11) * 18 * Math.exp(-bp * 2.4)
884
+ camOffsetY = fractalNoise1D(clampedT * 24, 22) * 14 * Math.exp(-bp * 2.4)
885
+ camRotateDeg = fractalNoise1D(clampedT * 20, 33) * 2.2 * Math.exp(-bp * 2.4)
886
+ chromaticAberration = Math.exp(-dtBeat * 3.8) * 0.7
887
+ break
888
+ case 'dutch_right':
889
+ camZoom = 1.07
890
+ camRotateDeg = 2.0 + handheldRot
891
+ camOffsetX = 10 + handheldDriftX
892
+ break
893
+ case 'hero_low':
894
+ camZoom = 1.15
895
+ camOffsetY = -28 + handheldDriftY * 0.5
896
+ chromaticAberration = Math.exp(-dtBeat * 6.0) * 0.35
897
+ break
898
+ case 'three_quarter_left':
899
+ camZoom = 1.08
900
+ turntableYawDeg = -14
901
+ camOffsetX = -10 + handheldDriftX
902
+ break
903
+ case 'three_quarter_right':
904
+ camZoom = 1.08
905
+ turntableYawDeg = 14
906
+ camOffsetX = 10 + handheldDriftX
907
+ break
908
+ case 'orbit_turntable':
909
+ camZoom = 1.07
910
+ turntableYawDeg = Math.sin(clampedT * 1.25) * 18
911
+ break
912
+ case 'snorricam':
913
+ camZoom = 1.14
914
+ camOffsetX = -nowKin.headTiltDeg * 2.8
915
+ camOffsetY = -18 - nowKin.bodyOffsetY * 0.85
916
+ camRotateDeg = -nowKin.headTiltDeg * 0.55
917
+ chromaticAberration = 0.18 + nowKin.mouthOpen * 0.25
918
+ break
919
+ case 'handheld_doc':
920
+ camZoom = 1.05 + fractalNoise1D(clampedT * 1.4, 77) * 0.035
921
+ camOffsetX = handheldDriftX * 2.1
922
+ camOffsetY = handheldDriftY * 2.1
923
+ camRotateDeg = handheldRot * 2.0
924
+ turntableYawDeg = fractalNoise1D(clampedT * 0.8, 88) * 12
925
+ break
926
+ default:
927
+ camZoom = 1.0 + bp * 0.04
928
+ }
929
+ } else if (isOutro) {
930
+ camZoom = 1.08
931
+ turntableYawDeg = Math.sin(clampedT * 1.4) * 6
932
+ }
933
+
934
+ return {
935
+ t: clampedT,
936
+ isIntro,
937
+ isOutro,
938
+ state: stateName,
939
+ stateDef,
940
+ frameIndex,
941
+ prevStateDef,
942
+ prevFrameIndex,
943
+ fadeAlpha,
944
+ activeBeat,
945
+ activeWord,
946
+ wordIndex,
947
+ mouthOpen: Number(nowKin.mouthOpen.toFixed(3)),
948
+ viseme: nowKin.visemeId,
949
+ visemeShape: nowKin.visemeShape,
950
+ squashX: Number(nowKin.squashX.toFixed(4)),
951
+ stretchY: Number(nowKin.stretchY.toFixed(4)),
952
+ headTiltDeg: Number(nowKin.headTiltDeg.toFixed(2)),
953
+ wearSpring,
954
+ microExpressions,
955
+ postFx: {
956
+ chromaticAberration: Number(chromaticAberration.toFixed(3)),
957
+ rimIntensity: Number((0.42 + nowKin.mouthOpen * 0.52).toFixed(3)),
958
+ turntableYawDeg: Number(turntableYawDeg.toFixed(2)),
959
+ },
960
+ camera: {
961
+ zoom: Number(camZoom.toFixed(4)),
962
+ x: Number(camOffsetX.toFixed(2)),
963
+ y: Number(camOffsetY.toFixed(2)),
964
+ rotateDeg: Number(camRotateDeg.toFixed(2)),
965
+ turntableYawDeg: Number(turntableYawDeg.toFixed(2)),
966
+ }
967
+ }
968
+ }
969
+
970
+ function evaluateSparringTurn(missionId, transcriptText, elapsedSec = 15) {
971
+ const mission = SPARRING_MISSIONS[missionId] || SPARRING_MISSIONS.sueldo_25
972
+ const clean = (transcriptText || '').trim()
973
+ const words = clean.split(/\s+/).filter(Boolean)
974
+ const low = clean.toLowerCase()
975
+
976
+ const matchedPower = mission.powerKeywords.filter((kw) => low.includes(kw))
977
+ const matchedWeak = mission.weakKeywords.filter((kw) => low.includes(kw))
978
+ const matchedFillers = []
979
+ mission.fillers.forEach((f) => {
980
+ const re = new RegExp(`\\b${f}\\b`, 'gi')
981
+ const m = low.match(re)
982
+ if (m) matchedFillers.push(...m)
983
+ })
984
+
985
+ const hasNumbers = /\d+%?|\b(veinte|treinta|cincuenta|cien|doble|mitad|mil|millones)\b/i.test(clean)
986
+ const wpm = elapsedSec > 0 ? Math.round((words.length / elapsedSec) * 60) : 110
987
+
988
+ let rawScore = 28
989
+ rawScore += matchedPower.length * 14
990
+ if (hasNumbers) rawScore += 18
991
+ if (words.length >= 12) rawScore += 12
992
+ if (wpm >= 95 && wpm <= 175) rawScore += 10
993
+ rawScore -= matchedWeak.length * 11
994
+ rawScore -= matchedFillers.length * 6
995
+
996
+ const score = Math.max(5, Math.min(99, Math.round(rawScore)))
997
+ let tier = 'En Lona'
998
+ let oloState = 'error'
999
+ let oloReply = mission.rebuttals[0].text
1000
+
1001
+ if (score >= 80) {
1002
+ tier = 'Tiburón Implacable'
1003
+ oloState = 'done'
1004
+ oloReply = mission.rebuttals[2].text
1005
+ } else if (score >= 55) {
1006
+ tier = 'Contendiente Sólido'
1007
+ oloState = 'thinking'
1008
+ oloReply = mission.rebuttals[1].text
1009
+ } else {
1010
+ tier = 'Congelado Bajo Presión'
1011
+ oloState = 'needs_approval'
1012
+ oloReply = mission.rebuttals[0].text
1013
+ }
1014
+
1015
+ const replayScene = compileScene({
1016
+ title: `${mission.title} · ${score}/100`,
1017
+ category: `Ring Olo · ${tier}`,
1018
+ wear: mission.wear,
1019
+ tint: mission.tint,
1020
+ vibe: score >= 80 ? 'victory' : 'drama',
1021
+ script: [
1022
+ `Olo: ${mission.openingLine}`,
1023
+ `Yo: ${clean || 'Eh... este... pues quería ver si se podía revisar.'}`,
1024
+ `Olo: ${oloReply}`,
1025
+ `Olo: Puntaje de presión: ${score} de 100. Rango: ${tier}.`
1026
+ ].join('\n')
1027
+ })
1028
+
1029
+ return {
1030
+ missionId: mission.id,
1031
+ score,
1032
+ tier,
1033
+ oloState,
1034
+ oloReply,
1035
+ metrics: {
1036
+ wordCount: words.length,
1037
+ wpm,
1038
+ hasNumbers,
1039
+ powerHits: matchedPower,
1040
+ weakHits: matchedWeak,
1041
+ fillerCount: matchedFillers.length,
1042
+ },
1043
+ replayScene,
1044
+ }
1045
+ }
1046
+
1047
+ function buildMirrorRoastScene(profile = {}) {
1048
+ const archetype = profile.archetype || 'Arquitecto Nocturno de Prompts'
1049
+ const topTopic = profile.topTopic || 'Negocio y Código'
1050
+ const peakHour = profile.peakHour != null ? `${profile.peakHour}:00` : '02:00 AM'
1051
+ const politeness = profile.politeness || 'Ultra educado con las máquinas'
1052
+ const score = profile.score || 88
1053
+
1054
+ return compileScene({
1055
+ title: `Mi Espejo IA: ${archetype}`,
1056
+ category: 'Roast Espejo IA',
1057
+ wear: 'mago',
1058
+ tint: 'lilac',
1059
+ vibe: 'roast',
1060
+ script: [
1061
+ `Olo: Escaneé con lupa tus chats de inteligencia artificial.`,
1062
+ `Olo: Leyendo tu historial: eres "${archetype}" con hora pico a las ${peakHour}.`,
1063
+ `Yo: Te juro que solo uso la IA para temas serios de ${topTopic}.`,
1064
+ `Olo: ¡Claro que sí! Y tu nivel de cortesía es "${politeness}".`,
1065
+ `Olo: ¡Nivel legendario! Índice de intensidad: ${score} de 100.`
1066
+ ].join('\n')
1067
+ })
1068
+ }
1069
+
1070
+ function evalStatePop(stateName, elapsedMs) {
1071
+ const [sx, sy] = evalCanonicalPop(stateName, (Number(elapsedMs) || 0) / 1000)
1072
+ return { sx, sy }
1073
+ }
1074
+
1075
+ return {
1076
+ OLO_STATES,
1077
+ STATE_POP,
1078
+ evalStatePop,
1079
+ OLO_TINTS,
1080
+ OLO_WEARS,
1081
+ OLO_VISEMES,
1082
+ OLO_CAMERA_MODES,
1083
+ VALID_SCENARIOS,
1084
+ normalizeScenario,
1085
+ PRESET_SHOWS,
1086
+ SPARRING_MISSIONS,
1087
+ fnv1aHex,
1088
+ fractalNoise1D,
1089
+ computeVisemesForWord,
1090
+ buildWordTimings,
1091
+ classifyBeat,
1092
+ compileScene,
1093
+ compileSceneFromScript,
1094
+ sampleSceneAt,
1095
+ evaluateSparringTurn,
1096
+ buildMirrorRoastScene,
1097
+ }
1098
+ })