primocode 10.13.0 → 10.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +66 -12
  2. package/bin/primocode.js +148 -8
  3. package/lib/act.js +48 -1
  4. package/lib/catalogo.js +12 -1
  5. package/lib/config.js +1 -0
  6. package/lib/estudio/cota.js +150 -0
  7. package/lib/estudio/dados/projetos/clube-ad5.json +105 -0
  8. package/lib/estudio/dados/projetos/clube-ae-sem-memoria.json +107 -0
  9. package/lib/estudio/dados/projetos/clube-agent.json +120 -0
  10. package/lib/estudio/dados/projetos/clube-antes-e-agora.json +86 -0
  11. package/lib/estudio/dados/projetos/clube-apresentando-clube.json +110 -0
  12. package/lib/estudio/dados/projetos/clube-bluevisa.json +106 -0
  13. package/lib/estudio/dados/projetos/clube-caught-in-the-fire.json +127 -0
  14. package/lib/estudio/dados/projetos/clube-chat-cliente-satisfeito.json +98 -0
  15. package/lib/estudio/dados/projetos/clube-chatgpt-anuncios.json +120 -0
  16. package/lib/estudio/dados/projetos/clube-claude-dj.json +140 -0
  17. package/lib/estudio/dados/projetos/clube-claude-mcp.json +142 -0
  18. package/lib/estudio/dados/projetos/clube-cleanui.json +106 -0
  19. package/lib/estudio/dados/projetos/clube-clinica-take-unico.json +128 -0
  20. package/lib/estudio/dados/projetos/clube-clinica.json +104 -0
  21. package/lib/estudio/dados/projetos/clube-clube-opener.json +125 -0
  22. package/lib/estudio/dados/projetos/clube-clube.json +108 -0
  23. package/lib/estudio/dados/projetos/clube-codigo.json +101 -0
  24. package/lib/estudio/dados/projetos/clube-distilbook.json +104 -0
  25. package/lib/estudio/dados/projetos/clube-drive-upload.json +115 -0
  26. package/lib/estudio/dados/projetos/clube-dub.json +119 -0
  27. package/lib/estudio/dados/projetos/clube-enxovaly.json +105 -0
  28. package/lib/estudio/dados/projetos/clube-fecha7.json +118 -0
  29. package/lib/estudio/dados/projetos/clube-filatov.json +128 -0
  30. package/lib/estudio/dados/projetos/clube-frame.json +108 -0
  31. package/lib/estudio/dados/projetos/clube-glass.json +118 -0
  32. package/lib/estudio/dados/projetos/clube-letra-em-apps.json +84 -0
  33. package/lib/estudio/dados/projetos/clube-letra-globo-3d.json +111 -0
  34. package/lib/estudio/dados/projetos/clube-letra-nos-apps.json +146 -0
  35. package/lib/estudio/dados/projetos/clube-letra-seis-mundos.json +100 -0
  36. package/lib/estudio/dados/projetos/clube-lula-3.json +146 -0
  37. package/lib/estudio/dados/projetos/clube-morph.json +119 -0
  38. package/lib/estudio/dados/projetos/clube-motion-designer.json +115 -0
  39. package/lib/estudio/dados/projetos/clube-motion-pacote.json +112 -0
  40. package/lib/estudio/dados/projetos/clube-movimento.json +103 -0
  41. package/lib/estudio/dados/projetos/clube-not-like-us.json +117 -0
  42. package/lib/estudio/dados/projetos/clube-pinterest-inspiracao.json +86 -0
  43. package/lib/estudio/dados/projetos/clube-pixel-e-tinta.json +108 -0
  44. package/lib/estudio/dados/projetos/clube-ponte.json +118 -0
  45. package/lib/estudio/dados/projetos/clube-processo-edit-tonal.json +126 -0
  46. package/lib/estudio/dados/projetos/clube-processo-motion-100-ia.json +109 -0
  47. package/lib/estudio/dados/projetos/clube-promo.json +114 -0
  48. package/lib/estudio/dados/projetos/clube-proposito.json +115 -0
  49. package/lib/estudio/dados/projetos/clube-rankhog.json +128 -0
  50. package/lib/estudio/dados/projetos/clube-recursao.json +116 -0
  51. package/lib/estudio/dados/projetos/clube-revelar.json +118 -0
  52. package/lib/estudio/dados/projetos/clube-robo.json +125 -0
  53. package/lib/estudio/dados/projetos/clube-shelby-ep2.json +144 -0
  54. package/lib/estudio/dados/projetos/clube-shelby.json +97 -0
  55. package/lib/estudio/dados/projetos/clube-solar-eclipse.json +96 -0
  56. package/lib/estudio/dados/projetos/clube-soldier.json +128 -0
  57. package/lib/estudio/dados/projetos/clube-som-de-cena.json +108 -0
  58. package/lib/estudio/dados/projetos/clube-spotify-candidatura.json +112 -0
  59. package/lib/estudio/dados/projetos/clube-tda-app.json +118 -0
  60. package/lib/estudio/dados/projetos/clube-tda-ia.json +75 -0
  61. package/lib/estudio/dados/projetos/clube-telegram-chat.json +118 -0
  62. package/lib/estudio/dados/projetos/clube-telegram-ilha.json +106 -0
  63. package/lib/estudio/dados/projetos/clube-tennessee-nike.json +132 -0
  64. package/lib/estudio/dados/projetos/clube-tudo-se-move.json +112 -0
  65. package/lib/estudio/dados/projetos/clube-uiad.json +107 -0
  66. package/lib/estudio/dados/projetos/clube-viralgrow.json +108 -0
  67. package/lib/estudio/dados/projetos/clube-whose-side.json +140 -0
  68. package/lib/estudio/dados/projetos/clube-youtube-inscrever.json +108 -0
  69. package/lib/estudio/dados/projetos/elementos-do-site-em-movimento.json +49 -0
  70. package/lib/estudio/dados/projetos/explainer-narrado.json +51 -0
  71. package/lib/estudio/dados/projetos/lancamento-de-produto.json +51 -0
  72. package/lib/estudio/dados/projetos/motion-so-com-sons.json +50 -0
  73. package/lib/estudio/dados/projetos/titulo-em-after-effects.json +51 -0
  74. package/lib/estudio/dados/projetos/tour-de-saas.json +50 -0
  75. package/lib/estudio/dados/projetos-ae/antes-e-agora/ae/cenas.jsx +733 -0
  76. package/lib/estudio/dados/projetos-ae/antes-e-agora/assets/fazer_assets.py +88 -0
  77. package/lib/estudio/dados/projetos-ae/letra-em-apps/ae/cenas.jsx +402 -0
  78. package/lib/estudio/dados/projetos-ae/letra-em-apps/assets/preparar_assets.py +17 -0
  79. package/lib/estudio/dados/projetos-ae/letra-em-apps/assets/svg_para_ae.py +25 -0
  80. package/lib/estudio/dados/projetos-ae/letra-nos-apps/BRIEF-AGENTES.md +31 -0
  81. package/lib/estudio/dados/projetos-ae/letra-nos-apps/README.md +53 -0
  82. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ROTEIRO-CENAS.md +69 -0
  83. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/A-agenda.jsx +148 -0
  84. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/B-docs.jsx +174 -0
  85. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/C-alttab.jsx +113 -0
  86. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/D-spotify.jsx +118 -0
  87. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/E-notas.jsx +146 -0
  88. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/F-jornal1.jsx +144 -0
  89. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/G-google.jsx +153 -0
  90. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/H-mapa.jsx +176 -0
  91. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/I-imagens.jsx +132 -0
  92. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/J-jornal2.jsx +191 -0
  93. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/K-maps.jsx +184 -0
  94. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/camera-movimentos.jsx +48 -0
  95. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/camera.jsx +57 -0
  96. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/card-spotify.jsx +98 -0
  97. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/digitar.jsx +38 -0
  98. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/jornal-comum.jsx +196 -0
  99. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/letra.jsx +23 -0
  100. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/letra_automatica.py +83 -0
  101. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/montagem.jsx +74 -0
  102. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/w-agenda/render_html.py +13 -0
  103. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/w-agenda/util.jsx +162 -0
  104. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/w-midia-comum.jsx +114 -0
  105. package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/wg-util.jsx +360 -0
  106. package/lib/estudio/dados/projetos-ae/letra-nos-apps/assets/F-jornal1/render_html.py +13 -0
  107. package/lib/estudio/dados/projetos-ae/letra-nos-apps/assets/render_w-midia.py +26 -0
  108. package/lib/estudio/dados/projetos-ae/letra-nos-apps/assets/w-midia-ferramentas/inclin.py +30 -0
  109. package/lib/estudio/dados/projetos-ae/letra-nos-apps/assets/w-midia-ferramentas/medir_d.py +36 -0
  110. package/lib/estudio/dados/projetos-ae/letra-nos-apps/assets/w-midia-ferramentas/medir_e.py +27 -0
  111. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/ajustar_movimentos.py +61 -0
  112. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/ancorar.py +74 -0
  113. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/assar.py +47 -0
  114. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/camera3d_jornal.py +172 -0
  115. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/comparar.py +32 -0
  116. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/dados_ae_jornal.py +46 -0
  117. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/folha_contato.py +28 -0
  118. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/homografia_jornal.py +74 -0
  119. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/integrar.py +44 -0
  120. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/medir_quadro_a_quadro.py +32 -0
  121. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/mesa_2d.py +64 -0
  122. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/plot_cenas.py +11 -0
  123. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/retificar_quadros.py +38 -0
  124. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/w-agenda/assar_ab.py +52 -0
  125. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/w-agenda/medir_ab.py +59 -0
  126. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/wg_ajustar_h.py +111 -0
  127. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/wg_corrige_cam.py +17 -0
  128. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/wg_ecc_cadeia.py +80 -0
  129. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/wg_finalizar_cam.py +65 -0
  130. package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/wg_mosaico.py +61 -0
  131. package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces/01-gramatica-e-roteiro.md +55 -0
  132. package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces/02-camera.md +66 -0
  133. package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces/03-montagem-no-ae.md +50 -0
  134. package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces/04-render-e-conferencia.md +43 -0
  135. package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces/05-abertura-celular-mergulho.md +43 -0
  136. package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces.md +42 -0
  137. package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/atraso_imagem.py +31 -0
  138. package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/fundo.py +56 -0
  139. package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/juntar.py +45 -0
  140. package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/montar.py +177 -0
  141. package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/rastreio.py +74 -0
  142. package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/sincronia.py +18 -0
  143. package/lib/estudio/dados/projetos-ae/pinterest-inspiracao/ae/cenas.jsx +530 -0
  144. package/lib/estudio/dados/projetos-ae/pinterest-inspiracao/assets/fazer_assets.py +107 -0
  145. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/assets/pessoas/figurinha.py +46 -0
  146. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/BRIEF-A.md +83 -0
  147. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/audio.py +179 -0
  148. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/confere.py +20 -0
  149. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/dados/tempo-parte1.js +1 -0
  150. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/dados/tempo-parte2.js +1 -0
  151. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/gerar_emojis.py +11 -0
  152. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/img/filmes/_indice.js +122 -0
  153. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/base.js +380 -0
  154. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/main.js +86 -0
  155. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/p1a.js +169 -0
  156. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/p1b.js +455 -0
  157. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/p1c.js +389 -0
  158. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/p2.js +379 -0
  159. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/video.py +62 -0
  160. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/take-unico/README.md +65 -0
  161. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/take-unico/oraculo_take.py +137 -0
  162. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/take-unico/render_take.py +388 -0
  163. package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/take-unico/take.js +235 -0
  164. package/lib/estudio/editor.js +285 -0
  165. package/lib/estudio/effectcraft.js +152 -0
  166. package/lib/estudio/exportar.js +181 -0
  167. package/lib/estudio/index.js +90 -2
  168. package/lib/estudio/kit-ae/base.jsx +1146 -0
  169. package/lib/estudio/kit-ae/cenas.jsx +395 -0
  170. package/lib/estudio/kit-ae/primitivas.jsx +911 -0
  171. package/lib/estudio/maquina-diretor.js +393 -0
  172. package/lib/estudio/maquina-elementos.js +134 -0
  173. package/lib/estudio/maquina-layout.js +198 -0
  174. package/lib/estudio/maquina-som.js +478 -0
  175. package/lib/estudio/maquina-trilha.js +160 -0
  176. package/lib/estudio/maquina-voz.js +429 -0
  177. package/lib/estudio/maquina.js +516 -0
  178. package/lib/estudio/projetos.js +60 -0
  179. package/lib/headless.js +71 -0
  180. package/lib/hooks.js +72 -0
  181. package/lib/local/cerebro.json +398 -12
  182. package/lib/local/entrada.js +46 -2
  183. package/lib/marca-kit.js +309 -0
  184. package/lib/mcp.js +286 -6
  185. package/lib/perigo.js +31 -3
  186. package/lib/planos.js +47 -19
  187. package/lib/preparar.js +111 -0
  188. package/lib/retomar.js +55 -0
  189. package/lib/sandbox.js +197 -0
  190. package/lib/skills.js +3 -1
  191. package/lib/tools.js +13 -4
  192. package/lib/ui.js +1 -1
  193. package/package.json +4 -2
  194. package/skills/after-effects/SKILL.md +52 -0
  195. package/skills/maquina-de-motion/SKILL.md +84 -0
@@ -0,0 +1,429 @@
1
+ /**
2
+ * maquina-voz.js — a narração que MANDA no tempo do motion.
3
+ *
4
+ * A voz é gerada de uma vez (uma fala por cena, junta), o whisper-cli mede o instante de CADA palavra
5
+ * e o roteiro é alinhado a esses instantes. Daí sai a regra "nada aparece antes de ser dito": o kit
6
+ * pergunta `tempoDaPalavra` e põe o elemento na hora da fala. A legenda vem por palavra, com a
7
+ * palavra-chave (a que veio entre *asteriscos*) marcada.
8
+ *
9
+ * falarEMedir({falas, pasta, voz, narrar, whisper}) → {ok, mp3, dur, palavras, cenas, motor}
10
+ * legendaChunks(palavras, {max}) → [{t, t2, palavras}]
11
+ * tempoDaPalavra(palavras, trecho, {cena, depoisDe}) → segundos | null
12
+ *
13
+ * A cadeia de voz (quando `narrar` não é injetado): VoiceStudio local → narrar.py (ElevenLabs/Edge)
14
+ * → Kokoro → `say` do macOS. A primeira que der certo ganha; o `motor` diz qual foi.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const { spawnSync, spawn } = require('child_process');
23
+
24
+ const WHISPER_BIN = process.env.PRIMOCODE_WHISPER_BIN || '/opt/homebrew/bin/whisper-cli';
25
+ const WHISPER_MODELO = () => process.env.PRIMOCODE_WHISPER_MODELO || path.join(os.homedir(), '.primocode', 'models', 'ggml-small.bin');
26
+ const NARRAR_PY = path.join(__dirname, '..', '..', 'studio', 'narrar.py');
27
+ const r3 = (n) => Math.round(n * 1000) / 1000;
28
+
29
+ const norm = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '');
30
+
31
+ // ── o roteiro em palavras ────────────────────────────────────────────────
32
+
33
+ /** "Veja *elas* de volta." → [{w:'Veja'}, {w:'elas', chave:true}, ...] (pontuação fica em `w`, `fim` marca pausa). */
34
+ function palavrasDoRoteiro(falas) {
35
+ const out = [];
36
+ falas.forEach((fala, cena) => {
37
+ const brutas = String(fala || '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
38
+ brutas.forEach((b) => {
39
+ const chave = /\*/.test(b);
40
+ const w = b.replace(/\*/g, '');
41
+ if (!norm(w)) return;
42
+ out.push({ w, n: norm(w), cena, chave, pausa: /[.!?;:]$/.test(w) });
43
+ });
44
+ });
45
+ return out;
46
+ }
47
+
48
+ /** Texto único da narração: sem asteriscos, uma frase por cena (ponto no fim se faltar, para a voz respirar). */
49
+ function textoDaNarracao(falas) {
50
+ return falas.map((f) => String(f || '').replace(/\*/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean)
51
+ .map((f) => (/[.!?…]$/.test(f) ? f : f + '.')).join(' ');
52
+ }
53
+
54
+ // ── o whisper ────────────────────────────────────────────────────────────
55
+
56
+ /** As palavras do whisper: [{w, n, t, t2}] (-ojf com -ml 1 -sow: uma palavra por segmento). */
57
+ function lerJsonDoWhisper(json) {
58
+ const out = [];
59
+ for (const s of (json && json.transcription) || []) {
60
+ const texto = String(s.text || '').trim();
61
+ if (!texto || /^\[.*\]$/.test(texto) || !norm(texto)) continue;
62
+ const o = s.offsets || {};
63
+ out.push({ w: texto, n: norm(texto), t: r3((o.from || 0) / 1000), t2: r3((o.to || 0) / 1000) });
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function medirComWhisper(mp3, { bin = WHISPER_BIN, modelo = WHISPER_MODELO(), lingua = 'pt' } = {}) {
69
+ if (!fs.existsSync(bin) || !fs.existsSync(modelo)) return { ok: false, error: 'whisper-cli ou o modelo não estão instalados' };
70
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mq-voz-'));
71
+ try {
72
+ const wav = path.join(tmp, 'v.wav');
73
+ const f = spawnSync('ffmpeg', ['-y', '-v', 'error', '-i', mp3, '-ar', '16000', '-ac', '1', wav], { encoding: 'utf8' });
74
+ if (f.status !== 0) return { ok: false, error: 'ffmpeg não converteu a voz: ' + String(f.stderr).slice(-200) };
75
+ const base = path.join(tmp, 'w');
76
+ const w = spawnSync(bin, ['-m', modelo, '-f', wav, '-l', lingua, '-ojf', '-ml', '1', '-sow', '-of', base], { encoding: 'utf8', timeout: 5 * 60000 });
77
+ if (w.status !== 0 || !fs.existsSync(base + '.json')) return { ok: false, error: 'whisper falhou: ' + String(w.stderr).slice(-200) };
78
+ return { ok: true, palavras: lerJsonDoWhisper(JSON.parse(fs.readFileSync(base + '.json', 'utf8'))) };
79
+ } catch (e) {
80
+ return { ok: false, error: e.message };
81
+ } finally {
82
+ fs.rmSync(tmp, { recursive: true, force: true });
83
+ }
84
+ }
85
+
86
+ // ── o alinhamento ────────────────────────────────────────────────────────
87
+
88
+ function lev(a, b) {
89
+ const m = a.length, n = b.length;
90
+ if (Math.abs(m - n) > 2) return 9;
91
+ let ant = Array.from({ length: n + 1 }, (_, j) => j);
92
+ for (let i = 1; i <= m; i++) {
93
+ const cur = [i];
94
+ for (let j = 1; j <= n; j++) cur[j] = Math.min(ant[j] + 1, cur[j - 1] + 1, ant[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
95
+ ant = cur;
96
+ }
97
+ return ant[n];
98
+ }
99
+ /** 0 = diferente, 1 = parecida, 2 = igual. */
100
+ function parecidas(a, b) {
101
+ if (!a || !b) return 0;
102
+ if (a === b) return 2;
103
+ if (Math.min(a.length, b.length) >= 4 && (lev(a, b) <= 1 || a.startsWith(b) || b.startsWith(a))) return 1;
104
+ return 0;
105
+ }
106
+
107
+ /** Casa as palavras do roteiro com as do whisper (programação dinâmica) e interpola as que ficaram sem par. */
108
+ function alinhar(roteiro, ouvidas, dur) {
109
+ const R = roteiro.length, O = ouvidas.length;
110
+ const tempos = new Array(R).fill(null);
111
+ if (R && O) {
112
+ const D = Array.from({ length: R + 1 }, () => new Array(O + 1).fill(0));
113
+ for (let i = 1; i <= R; i++) for (let j = 1; j <= O; j++) {
114
+ const s = parecidas(roteiro[i - 1].n, ouvidas[j - 1].n);
115
+ D[i][j] = Math.max(D[i - 1][j], D[i][j - 1], s ? D[i - 1][j - 1] + s : 0);
116
+ }
117
+ let i = R, j = O;
118
+ while (i > 0 && j > 0) {
119
+ const s = parecidas(roteiro[i - 1].n, ouvidas[j - 1].n);
120
+ if (s && D[i][j] === D[i - 1][j - 1] + s) { tempos[i - 1] = ouvidas[j - 1]; i--; j--; }
121
+ else if (D[i - 1][j] >= D[i][j - 1]) i--;
122
+ else j--;
123
+ }
124
+ }
125
+ const casadas = tempos.filter(Boolean).length;
126
+ // interpola as sem par entre vizinhas casadas (ou pelas pontas: 0 e a duração)
127
+ const out = roteiro.map((p, i) => ({ ...p, t: tempos[i] ? tempos[i].t : null, t2: tempos[i] ? tempos[i].t2 : null, casada: !!tempos[i] }));
128
+ let i = 0;
129
+ while (i < R) {
130
+ if (out[i].t != null) { i++; continue; }
131
+ let j = i; while (j < R && out[j].t == null) j++;
132
+ const a = i > 0 ? out[i - 1].t2 : 0.15, b = j < R ? out[j].t : Math.max(a, dur - 0.1);
133
+ const passo = (b - a) / (j - i);
134
+ for (let k = i; k < j; k++) { out[k].t = r3(a + passo * (k - i)); out[k].t2 = r3(a + passo * (k - i + 1)); }
135
+ i = j;
136
+ }
137
+ return { palavras: out, casadas, total: R };
138
+ }
139
+
140
+ /** Só com a duração: reparte o tempo pelas letras (a reserva se o whisper falhar). */
141
+ function alinharProporcional(roteiro, dur) {
142
+ const pesos = roteiro.map((p) => p.n.length + 2 + (p.pausa ? 4 : 0));
143
+ const soma = pesos.reduce((a, b) => a + b, 0) || 1;
144
+ let t = 0.15;
145
+ const util = Math.max(0.5, dur - 0.3);
146
+ return roteiro.map((p, i) => { const d = util * pesos[i] / soma; const o = { ...p, t: r3(t), t2: r3(t + d * 0.85), casada: false }; t += d; return o; });
147
+ }
148
+
149
+ function cenasDasPalavras(palavras, nCenas, dur) {
150
+ const cenas = [];
151
+ for (let c = 0; c < nCenas; c++) {
152
+ const idx = []; palavras.forEach((p, i) => { if (p.cena === c) idx.push(i); });
153
+ cenas.push({ idx });
154
+ }
155
+ // cena sem fala herda o instante da anterior (dura até a próxima)
156
+ const inicios = cenas.map((c, k) => (c.idx.length ? Math.max(0, palavras[c.idx[0]].t - 0.2) : null));
157
+ let ult = 0; for (let k = 0; k < inicios.length; k++) { if (inicios[k] == null) inicios[k] = ult; else ult = inicios[k]; }
158
+ inicios[0] = 0;
159
+ return cenas.map((c, k) => ({
160
+ inicio: r3(inicios[k]),
161
+ fim: r3(k < cenas.length - 1 ? inicios[k + 1] : dur + 0.5),
162
+ palavras: c.idx,
163
+ }));
164
+ }
165
+
166
+ // ── a voz ────────────────────────────────────────────────────────────────
167
+
168
+ function sondar(arq) {
169
+ const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', arq], { encoding: 'utf8' });
170
+ const d = parseFloat(String(r.stdout || '').trim());
171
+ return isNaN(d) ? null : d;
172
+ }
173
+
174
+ function pyNarrar(texto, mp3, voz) {
175
+ return new Promise((resolve) => {
176
+ const p = spawn('python3', [NARRAR_PY, 'falar', '--texto', texto, '--saida', mp3, '--voz', String(voz || 'francisca')]);
177
+ let saida = '', erro = '';
178
+ p.stdout.on('data', (d) => { saida += d; }); p.stderr.on('data', (d) => { erro += d; });
179
+ const rel = setTimeout(() => { try { p.kill(); } catch { /* já foi */ } }, 5 * 60000);
180
+ p.on('close', () => { clearTimeout(rel); try { const r = JSON.parse(saida.trim().split('\n').pop()); resolve({ ...r, motor: 'narrar.py/' + (r.motor || '?') }); } catch { resolve({ ok: false, error: (erro || saida).slice(-300) }); } });
181
+ p.on('error', (e) => { clearTimeout(rel); resolve({ ok: false, error: e.message }); });
182
+ });
183
+ }
184
+
185
+ /** A cadeia local: VoiceStudio → narrar.py → Kokoro → say. Devolve {ok, motor}. */
186
+ async function cadeiaLocal(texto, mp3, voz) {
187
+ fs.mkdirSync(path.dirname(mp3), { recursive: true });
188
+ try {
189
+ const vs = require('./voicestudio.js');
190
+ if (!process.env.PRIMOCODE_SEM_VOICESTUDIO && await vs.disponivel()) {
191
+ const r = await vs.falar(texto, { voz: voz && voz !== 'francisca' ? voz : undefined });
192
+ if (r.ok) { fs.writeFileSync(mp3, r.audio); return { ok: true, motor: 'voicestudio' }; }
193
+ }
194
+ } catch { /* segue a cadeia */ }
195
+ if (fs.existsSync(NARRAR_PY)) {
196
+ const r = await pyNarrar(texto, mp3, voz);
197
+ if (r.ok && fs.existsSync(mp3)) return r;
198
+ }
199
+ try {
200
+ const k = require('./kokoro.js');
201
+ const uv = ['/Users/' + os.userInfo().username + '/.local/bin/uv', '/opt/homebrew/bin/uv'].find((x) => fs.existsSync(x));
202
+ if (uv) { const r = await k.narrar([{ texto }], mp3, { voz, uv }); if (r && r.ok && fs.existsSync(mp3)) return { ok: true, motor: 'kokoro' }; }
203
+ } catch { /* segue */ }
204
+ if (process.platform === 'darwin') {
205
+ const aiff = mp3.replace(/\.mp3$/, '.aiff');
206
+ const s = spawnSync('say', ['-v', 'Luciana', '-o', aiff, texto]);
207
+ if (s.status === 0 && spawnSync('ffmpeg', ['-y', '-v', 'error', '-i', aiff, mp3]).status === 0) { fs.rmSync(aiff, { force: true }); return { ok: true, motor: 'say' }; }
208
+ }
209
+ return { ok: false, error: 'nenhuma voz disponível (VoiceStudio, narrar.py, Kokoro, say)' };
210
+ }
211
+
212
+ /**
213
+ * A narração do vídeo todo + o instante de cada palavra.
214
+ * `narrar(texto, mp3, voz)` e `whisper(mp3)` são injetáveis (testes). `whisper` devolve {ok, palavras:[{w,n,t,t2}]}.
215
+ */
216
+ async function falarEMedirUnico({ falas, pasta, voz, narrar, whisper } = {}) {
217
+ if (!Array.isArray(falas) || !falas.some((f) => String(f || '').trim())) return { ok: false, error: 'não há fala para narrar.' };
218
+ if (!pasta) return { ok: false, error: 'faltou a pasta do projeto.' };
219
+ const mp3 = path.join(pasta, 'midia', 'voz-motion.mp3');
220
+ fs.mkdirSync(path.dirname(mp3), { recursive: true });
221
+ const texto = textoDaNarracao(falas);
222
+ let motor = 'injetada';
223
+ // mesma fala, mesma voz: reaproveita o áudio (a voz premium custa crédito a cada chamada)
224
+ const marca = mp3.replace(/\.mp3$/, '.fala.json'), assinatura = JSON.stringify({ texto, voz: voz || null });
225
+ let r;
226
+ try { if (!narrar && fs.existsSync(mp3) && fs.readFileSync(marca, 'utf8') === assinatura) r = { ok: true, motor: 'cache' }; } catch { /* sem marca: narra */ }
227
+ if (!r) {
228
+ r = narrar ? await narrar(texto, mp3, voz) : await cadeiaLocal(texto, mp3, voz);
229
+ if (r && r.ok !== false && fs.existsSync(mp3)) { try { fs.writeFileSync(marca, assinatura); } catch { /* sem cache */ } }
230
+ }
231
+ if (!r || r.ok === false || !fs.existsSync(mp3)) return { ok: false, error: 'a narração falhou: ' + ((r && r.error) || 'sem áudio') };
232
+ if (r.motor) motor = r.motor;
233
+ const dur = sondar(mp3);
234
+ if (!dur) return { ok: false, error: 'não consegui medir a duração da voz.' };
235
+
236
+ const roteiro = palavrasDoRoteiro(falas);
237
+ const w = whisper ? await whisper(mp3) : medirComWhisper(mp3);
238
+ let palavras, casadas = 0, medido = false;
239
+ if (w && w.ok && w.palavras && w.palavras.length) {
240
+ const a = alinhar(roteiro, w.palavras, dur);
241
+ palavras = a.palavras; casadas = a.casadas; medido = true;
242
+ // whisper que ouviu bem menos da metade: o alinhamento é chute, melhor a divisão proporcional
243
+ if (a.casadas < roteiro.length * 0.4) { palavras = alinharProporcional(roteiro, dur); medido = false; casadas = 0; }
244
+ } else palavras = alinharProporcional(roteiro, dur);
245
+ // o tempo nunca anda para trás
246
+ for (let i = 1; i < palavras.length; i++) if (palavras[i].t < palavras[i - 1].t) { palavras[i].t = palavras[i - 1].t; if (palavras[i].t2 < palavras[i].t) palavras[i].t2 = palavras[i].t; }
247
+ const limpas = palavras.map(({ w: pw, n, t, t2, cena, chave, pausa, casada }) => ({ w: pw, n, t, t2, cena, chave, pausa, casada }));
248
+ return { ok: true, mp3, dur: r3(dur), palavras: limpas, cenas: cenasDasPalavras(limpas, falas.length, dur), motor, medido, casadas, total: roteiro.length };
249
+ }
250
+
251
+
252
+ // ── a narração por FRASES (Joel, 18/10: "a voz está rápida/emendada") ─────────
253
+
254
+ const UNIDADES = ['zero', 'um', 'dois', 'três', 'quatro', 'cinco', 'seis', 'sete', 'oito', 'nove', 'dez', 'onze', 'doze', 'treze', 'catorze', 'quinze', 'dezesseis', 'dezessete', 'dezoito', 'dezenove'];
255
+ const DEZENAS = ['', '', 'vinte', 'trinta', 'quarenta', 'cinquenta', 'sessenta', 'setenta', 'oitenta', 'noventa'];
256
+ const CENTENAS = ['', 'cento', 'duzentos', 'trezentos', 'quatrocentos', 'quinhentos', 'seiscentos', 'setecentos', 'oitocentos', 'novecentos'];
257
+ /** O número por extenso em português (0 a 999.999), para a voz não tropeçar em "1.840". */
258
+ function porExtenso(n) {
259
+ n = Math.floor(n);
260
+ if (n < 20) return UNIDADES[n];
261
+ if (n < 100) return DEZENAS[Math.floor(n / 10)] + (n % 10 ? ' e ' + UNIDADES[n % 10] : '');
262
+ if (n === 100) return 'cem';
263
+ if (n < 1000) return CENTENAS[Math.floor(n / 100)] + (n % 100 ? ' e ' + porExtenso(n % 100) : '');
264
+ if (n < 1000000) {
265
+ const m = Math.floor(n / 1000), r = n % 1000;
266
+ return (m === 1 ? 'mil' : porExtenso(m) + ' mil') + (r ? (r < 100 || r % 100 === 0 ? ' e ' : ' ') + porExtenso(r) : '');
267
+ }
268
+ return String(n);
269
+ }
270
+ /** A grafia de pronúncia por tentativa: 1 = o roteiro; 2 = números, %, R$ e horas por extenso; 3 = também sem pontuação rara. */
271
+ function ajustarGrafia(texto, tentativa) {
272
+ let t = String(texto);
273
+ if (tentativa >= 2) {
274
+ t = t.replace(/R\$\s*([\d.]+)(?:,(\d+))?/g, (_, a) => porExtenso(parseInt(a.replace(/\./g, ''), 10)) + ' reais')
275
+ .replace(/(\d+)\s*%/g, (_, a) => porExtenso(parseInt(a, 10)) + ' por cento')
276
+ .replace(/(\d{1,2})[:h](\d{2})/g, (_, h, m) => porExtenso(parseInt(h, 10)) + (parseInt(m, 10) ? ' e ' + porExtenso(parseInt(m, 10)) : ' horas'))
277
+ .replace(/\b(\d{1,3}(?:\.\d{3})+|\d+)\b/g, (_, a) => porExtenso(parseInt(a.replace(/\./g, ''), 10)));
278
+ }
279
+ if (tentativa >= 3) t = t.replace(/[—–"“”«»()]/g, ',').replace(/\s*[:;]\s*/g, ', ').replace(/\s+/g, ' ');
280
+ return t;
281
+ }
282
+ /** As frases de uma fala (uma frase por arquivo). */
283
+ function frasesDe(fala) {
284
+ return String(fala || '').replace(/\s+/g, ' ').trim().split(/(?<=[.!?…])\s+/).map((x) => x.trim()).filter((x) => norm(x));
285
+ }
286
+ /** Quanto do roteiro o whisper reconheceu: razão de palavras casadas (LCS aproximada pelo alinhamento) 0–1. */
287
+ function similaridadeFala(roteiroTexto, ouvidas) {
288
+ const R = String(roteiroTexto).replace(/\*/g, '').split(/\s+/).map(norm).filter(Boolean);
289
+ const O = (ouvidas || []).map((x) => x.n || norm(x.w)).filter(Boolean);
290
+ if (!R.length) return 1;
291
+ const D = Array.from({ length: R.length + 1 }, () => new Array(O.length + 1).fill(0));
292
+ for (let i = 1; i <= R.length; i++) for (let j = 1; j <= O.length; j++) D[i][j] = Math.max(D[i - 1][j], D[i][j - 1], parecidas(R[i - 1], O[j - 1]) ? D[i - 1][j - 1] + 1 : 0);
293
+ return D[R.length][O.length] / Math.max(R.length, 1);
294
+ }
295
+ const hashCurto = (s) => { let h = 5381; for (const c of String(s)) h = ((h * 33) ^ c.charCodeAt(0)) >>> 0; return h.toString(36); };
296
+
297
+ /**
298
+ * A narração por frases: cada frase vira um arquivo (cache por texto+voz), conferida com o whisper (reconhecido ≥ 90% do roteiro, senão
299
+ * regenera com outra grafia, até 3 tentativas), desacelerada para 0,95×, e montada com 0,35 s de pausa entre frases e 0,6 s entre partes
300
+ * (cenas). O tempo de cada palavra vem do whisper de CADA frase, somado ao início dela na montagem.
301
+ * `narrar(texto, mp3, voz)` e `whisper(mp3)` são injetáveis. Devolve o mesmo formato do falarEMedirUnico + `relatorio`.
302
+ */
303
+ async function falarEMedir({ falas, pasta, voz, narrar, whisper, velocidade = 0.95, pausaFrase = 0.35, pausaParte = 0.6, minimoReconhecido = 0.9 } = {}) {
304
+ if (!Array.isArray(falas) || !falas.some((f) => String(f || '').trim())) return { ok: false, error: 'não há fala para narrar.' };
305
+ if (!pasta) return { ok: false, error: 'faltou a pasta do projeto.' };
306
+ const dir = path.join(pasta, 'midia', 'voz'); fs.mkdirSync(dir, { recursive: true });
307
+ const frases = [];
308
+ falas.forEach((f, cena) => frasesDe(f).forEach((texto, k, arr) => frases.push({ cena, texto, ultima: k === arr.length - 1 })));
309
+ const relatorio = [];
310
+ const segs = [];
311
+ let motor = 'injetada';
312
+ for (const fr of frases) {
313
+ const limpo = fr.texto.replace(/\*/g, '');
314
+ const chave = hashCurto(JSON.stringify([limpo, voz || null]));
315
+ const arq = path.join(dir, `f-${chave}.mp3`), ficha = arq.replace(/\.mp3$/, '.json');
316
+ let melhor = null;
317
+ try { if (!narrar && fs.existsSync(arq)) melhor = JSON.parse(fs.readFileSync(ficha, 'utf8')); } catch { melhor = null; }
318
+ if (melhor && !fs.existsSync(arq)) melhor = null;
319
+ if (!melhor) {
320
+ for (let tent = 1; tent <= 3; tent++) {
321
+ const texto = ajustarGrafia(limpo, tent);
322
+ const tmp = arq.replace(/\.mp3$/, `.t${tent}.mp3`);
323
+ const r = narrar ? await narrar(texto, tmp, voz) : await cadeiaLocal(texto, tmp, voz);
324
+ if (!r || r.ok === false || !fs.existsSync(tmp)) continue;
325
+ if (r.motor) motor = r.motor;
326
+ const w = whisper ? await whisper(tmp) : medirComWhisper(tmp);
327
+ const ouvidas = w && w.ok ? w.palavras : null;
328
+ const sim = ouvidas ? similaridadeFala(limpo, ouvidas) : 1; // sem whisper não dá para conferir: aceita
329
+ const cand = { arquivo: tmp, tentativa: tent, similaridade: Math.round(sim * 100) / 100, ouvidas, dur: sondar(tmp) };
330
+ if (!melhor || cand.similaridade > melhor.similaridade) melhor = cand;
331
+ if (sim >= minimoReconhecido) break;
332
+ }
333
+ if (!melhor) return { ok: false, error: 'a narração falhou na frase: ' + limpo.slice(0, 60) };
334
+ fs.copyFileSync(melhor.arquivo, arq);
335
+ melhor = { ...melhor, arquivo: arq };
336
+ for (const f of fs.readdirSync(dir)) if (/\.t\d\.mp3$/.test(f) && f.startsWith(path.basename(arq, '.mp3'))) fs.rmSync(path.join(dir, f), { force: true });
337
+ if (!narrar) { try { fs.writeFileSync(ficha, JSON.stringify({ ...melhor, ouvidas: melhor.ouvidas })); } catch { /* sem cache */ } }
338
+ }
339
+ relatorio.push({ cena: fr.cena, texto: limpo, tentativa: melhor.tentativa, similaridade: melhor.similaridade, ok: melhor.similaridade >= minimoReconhecido });
340
+ segs.push({ ...fr, arq, ouvidas: melhor.ouvidas, dur: melhor.dur || sondar(arq) });
341
+ }
342
+ // a montagem: atempo, cabeça de 0,04 s e rabo de 0,06 s (nenhuma sílaba cortada) + as pausas
343
+ const SR = 48000, trab = path.join(dir, 'mont'); fs.mkdirSync(trab, { recursive: true });
344
+ const partes = []; let t = 0.15; const inicios = [];
345
+ const silencio = (seg, nome) => { const f = path.join(trab, nome + '.wav'); spawnSync('ffmpeg', ['-y', '-v', 'error', '-f', 'lavfi', '-i', `anullsrc=r=${SR}:cl=mono`, '-t', String(seg), f]); return f; };
346
+ partes.push(silencio(0.15, 'ini'));
347
+ segs.forEach((s, i) => {
348
+ const w = path.join(trab, `s${i}.wav`);
349
+ spawnSync('ffmpeg', ['-y', '-v', 'error', '-i', s.arq, '-ac', '1', '-ar', String(SR), '-af', `atempo=${velocidade},adelay=40|40,apad=pad_dur=0.06`, w]);
350
+ const d = sondar(w) || (s.dur / velocidade + 0.1);
351
+ inicios.push(t); partes.push(w); t += d;
352
+ const pausa = s.ultima ? pausaParte : pausaFrase;
353
+ if (i < segs.length - 1) { partes.push(silencio(pausa, `p${i}`)); t += pausa; }
354
+ });
355
+ const lista = path.join(trab, 'lista.txt');
356
+ fs.writeFileSync(lista, partes.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join('\n'));
357
+ const mp3 = path.join(pasta, 'midia', 'voz-motion.mp3');
358
+ const cc = spawnSync('ffmpeg', ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', lista, '-ar', String(SR), '-ac', '1', '-b:a', '192k', mp3]);
359
+ fs.rmSync(trab, { recursive: true, force: true });
360
+ if (cc.status !== 0 || !fs.existsSync(mp3)) return { ok: false, error: 'não consegui montar a narração.' };
361
+ const dur = sondar(mp3);
362
+ // o tempo de cada palavra: o whisper da frase (÷ velocidade) + o início da frase na montagem
363
+ const roteiroTodo = palavrasDoRoteiro(falas);
364
+ const palavras = []; let cursor = 0, medidos = 0;
365
+ segs.forEach((s, i) => {
366
+ const rot = roteiroTodo.slice(cursor, cursor + palavrasDoRoteiro([s.texto]).length);
367
+ cursor += rot.length;
368
+ rot.forEach((p) => { p.cena = s.cena; });
369
+ const ouvidas = (s.ouvidas || []).map((o) => ({ ...o, t: r3(o.t / velocidade + 0.04), t2: r3(o.t2 / velocidade + 0.04) }));
370
+ const durSeg = s.dur / velocidade + 0.1;
371
+ let al;
372
+ if (ouvidas.length) { const a = alinhar(rot, ouvidas, durSeg); al = a.palavras; if (a.casadas >= rot.length * 0.4) medidos++; else al = alinharProporcional(rot, durSeg); }
373
+ else al = alinharProporcional(rot, durSeg);
374
+ al.forEach((p) => palavras.push({ ...p, t: r3(p.t + inicios[i]), t2: r3(p.t2 + inicios[i]), cena: s.cena }));
375
+ });
376
+ for (let i = 1; i < palavras.length; i++) if (palavras[i].t < palavras[i - 1].t) { palavras[i].t = palavras[i - 1].t; if (palavras[i].t2 < palavras[i].t) palavras[i].t2 = palavras[i].t; }
377
+ const limpas = palavras.map(({ w: pw, n, t: tt, t2, cena, chave, pausa, casada }) => ({ w: pw, n, t: tt, t2, cena, chave, pausa, casada }));
378
+ return { ok: true, mp3, dur: r3(dur), palavras: limpas, cenas: cenasDasPalavras(limpas, falas.length, dur), motor, medido: medidos > 0, casadas: limpas.filter((p) => p.casada).length, total: limpas.length, relatorio, velocidade, pausas: { frase: pausaFrase, parte: pausaParte } };
379
+ }
380
+
381
+ // ── legenda e consulta ───────────────────────────────────────────────────
382
+
383
+ /** Agrupa as palavras para a legenda numa FRASE falada de até `maxCar` caracteres (nunca uma palavra solta): quebra em pontuação final,
384
+ * em pausa de mais de 0,45 s, ou quando passaria do limite. `max` (palavras) fica como teto de segurança. */
385
+ function legendaChunks(palavras, { max = 12, maxCar = 64 } = {}) {
386
+ const out = [];
387
+ let atual = null, car = 0;
388
+ palavras.forEach((p, i) => {
389
+ const prev = palavras[i - 1];
390
+ const novoCar = car + (atual ? 1 : 0) + p.w.length;
391
+ const quebra = !atual || atual.palavras.length >= max || novoCar > maxCar || (prev && (/[.!?;:]$/.test(prev.w) || p.t - prev.t2 > 0.45 || p.cena !== prev.cena));
392
+ if (quebra) { atual = { t: p.t, t2: p.t2, cena: p.cena, palavras: [] }; out.push(atual); car = 0; }
393
+ car += (atual.palavras.length ? 1 : 0) + p.w.length;
394
+ atual.palavras.push({ w: p.w, t: p.t, t2: p.t2, chave: !!p.chave });
395
+ atual.t2 = p.t2;
396
+ });
397
+ // frases de uma palavra só se juntam à vizinha da mesma cena (legenda nunca solta uma palavra)
398
+ for (let i = 0; i < out.length; i++) {
399
+ if (out[i].palavras.length === 1) {
400
+ const v = out[i + 1] && out[i + 1].cena === out[i].cena ? i + 1 : (out[i - 1] && out[i - 1].cena === out[i].cena ? i - 1 : -1);
401
+ if (v >= 0 && out[v].palavras.map((x) => x.w).join(' ').length + out[i].palavras[0].w.length < maxCar + 8) {
402
+ if (v > i) { out[v].palavras.unshift(out[i].palavras[0]); out[v].t = out[i].t; } else { out[v].palavras.push(out[i].palavras[0]); out[v].t2 = out[i].t2; }
403
+ out.splice(i, 1); i--;
404
+ }
405
+ }
406
+ }
407
+ return out;
408
+ }
409
+
410
+ /**
411
+ * O instante (s) em que `trecho` (1 ou mais palavras) é dito, ou null. `cena` limita a busca à cena;
412
+ * `depoisDe` (s) ignora o que foi dito antes.
413
+ */
414
+ function tempoDaPalavra(palavras, trecho, { cena = null, depoisDe = -1 } = {}) {
415
+ const alvo = String(trecho || '').replace(/\*/g, '').split(/\s+/).map(norm).filter(Boolean);
416
+ if (!alvo.length) return null;
417
+ const lista = palavras.filter((p) => (cena == null || p.cena === cena) && p.t >= depoisDe);
418
+ for (let i = 0; i + alvo.length <= lista.length; i++) {
419
+ if (alvo.every((a, k) => parecidas(a, lista[i + k].n))) return lista[i].t;
420
+ }
421
+ // o trecho todo não bateu: a primeira palavra que tem peso (mais de 3 letras)
422
+ for (const a of alvo.filter((x) => x.length > 3)) {
423
+ const p = lista.find((q) => parecidas(a, q.n));
424
+ if (p) return p.t;
425
+ }
426
+ return null;
427
+ }
428
+
429
+ module.exports = { falarEMedirUnico, porExtenso, ajustarGrafia, frasesDe, similaridadeFala, falarEMedir, legendaChunks, tempoDaPalavra, medirComWhisper, alinhar, alinharProporcional, palavrasDoRoteiro, textoDaNarracao, lerJsonDoWhisper, cenasDasPalavras, cadeiaLocal, norm };