primocode 10.15.2 → 10.17.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 (245) hide show
  1. package/LICENSES/edit-ia-pro.txt +32 -0
  2. package/LICENSES/fontes-OFL.txt +476 -0
  3. package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
  4. package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
  5. package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
  6. package/LICENSES/three-MIT.txt +23 -0
  7. package/LICENSES/video-use-MIT.txt +24 -0
  8. package/LICENSES/woven-sfx-CC0.txt +16 -0
  9. package/LICENSES/yunet-MIT.txt +21 -0
  10. package/README.md +2 -2
  11. package/bin/primocode.js +74 -9
  12. package/editor/LICENSE.md +11 -0
  13. package/editor/README.md +321 -0
  14. package/editor/SKILL.md +307 -0
  15. package/editor/THIRD_PARTY_NOTICES.md +228 -0
  16. package/editor/assets/baixar.json +591 -0
  17. package/editor/assets/fonts/anton/OFL.txt +93 -0
  18. package/editor/assets/fonts/caveat/OFL.txt +93 -0
  19. package/editor/assets/fonts/figtree/OFL.txt +93 -0
  20. package/editor/assets/fonts/fonts.json +10 -0
  21. package/editor/assets/fonts/montserrat/OFL.txt +93 -0
  22. package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
  23. package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
  24. package/editor/assets/models/YUNET_LICENSE.txt +21 -0
  25. package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
  26. package/editor/assets/sfx/licencas/cash.txt +76 -0
  27. package/editor/assets/sfx/licencas/charge.txt +31 -0
  28. package/editor/assets/sfx/licencas/click.txt +46 -0
  29. package/editor/assets/sfx/licencas/coin.txt +50 -0
  30. package/editor/assets/sfx/licencas/count.txt +47 -0
  31. package/editor/assets/sfx/licencas/data.txt +31 -0
  32. package/editor/assets/sfx/licencas/error.txt +32 -0
  33. package/editor/assets/sfx/licencas/flash.txt +31 -0
  34. package/editor/assets/sfx/licencas/gears.txt +82 -0
  35. package/editor/assets/sfx/licencas/keyboard.txt +48 -0
  36. package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
  37. package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
  38. package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
  39. package/editor/assets/sfx/licencas/notification.txt +54 -0
  40. package/editor/assets/sfx/licencas/pop.txt +38 -0
  41. package/editor/assets/sfx/licencas/rewind.txt +32 -0
  42. package/editor/assets/sfx/licencas/rise.txt +46 -0
  43. package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
  44. package/editor/assets/sfx/licencas/snap.txt +50 -0
  45. package/editor/assets/sfx/licencas/success.txt +40 -0
  46. package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
  47. package/editor/assets/sfx/licencas/tick.txt +14 -0
  48. package/editor/assets/sfx/licencas/whoosh.txt +47 -0
  49. package/editor/assets/sfx/licencas/wind.txt +31 -0
  50. package/editor/assets/sfx/sfx.json +832 -0
  51. package/editor/assets/sfx/sfx_sinteticos.json +317 -0
  52. package/editor/assets/sfx/sfx_woven.json +387 -0
  53. package/editor/assets/sfx/woven/LICENCA.md +16 -0
  54. package/editor/fichas/briefing-do-video.md +45 -0
  55. package/editor/fichas/conferencia-da-amostra.md +56 -0
  56. package/editor/fichas/matriz-de-ganchos.csv +3 -0
  57. package/editor/fichas/padrao.json +16 -0
  58. package/editor/fichas/planilha-de-cenas.csv +4 -0
  59. package/editor/fichas/planilha-de-versoes.csv +4 -0
  60. package/editor/fichas/plano-em-blocos.md +32 -0
  61. package/editor/fichas/registro-de-materiais.json +27 -0
  62. package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
  63. package/editor/helpers/bordas_precisas.py +272 -0
  64. package/editor/helpers/camera_viva.py +426 -0
  65. package/editor/helpers/captions.py +601 -0
  66. package/editor/helpers/common.py +308 -0
  67. package/editor/helpers/compose_final.py +3555 -0
  68. package/editor/helpers/conferir_corte.py +234 -0
  69. package/editor/helpers/conferir_dados.py +405 -0
  70. package/editor/helpers/contact_sheet.py +282 -0
  71. package/editor/helpers/corte_por_onda.py +433 -0
  72. package/editor/helpers/corte_seco.py +595 -0
  73. package/editor/helpers/denoise.py +971 -0
  74. package/editor/helpers/efeitos.py +408 -0
  75. package/editor/helpers/efeitos_gerar.py +159 -0
  76. package/editor/helpers/elevenlabs_api.py +442 -0
  77. package/editor/helpers/face_layout.py +570 -0
  78. package/editor/helpers/fetch_images.py +423 -0
  79. package/editor/helpers/ganho_por_trecho.py +214 -0
  80. package/editor/helpers/gen_image.py +312 -0
  81. package/editor/helpers/gen_music.py +316 -0
  82. package/editor/helpers/gen_sfx.py +148 -0
  83. package/editor/helpers/grade.py +307 -0
  84. package/editor/helpers/icloud.py +119 -0
  85. package/editor/helpers/make_sfx.py +940 -0
  86. package/editor/helpers/matte.py +1342 -0
  87. package/editor/helpers/medir_referencia.py +2232 -0
  88. package/editor/helpers/mixar_trilha.py +354 -0
  89. package/editor/helpers/montar_corte.py +295 -0
  90. package/editor/helpers/motion/__init__.py +13 -0
  91. package/editor/helpers/motion/engine.py +1915 -0
  92. package/editor/helpers/motion/registry.py +510 -0
  93. package/editor/helpers/motion/render.py +665 -0
  94. package/editor/helpers/motion/templates/barra_progresso.py +370 -0
  95. package/editor/helpers/motion/templates/carimbo.py +132 -0
  96. package/editor/helpers/motion/templates/checklist.py +271 -0
  97. package/editor/helpers/motion/templates/comentario_cta.py +380 -0
  98. package/editor/helpers/motion/templates/contador.py +222 -0
  99. package/editor/helpers/motion/templates/destaque.py +263 -0
  100. package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
  101. package/editor/helpers/motion/templates/grafico_barras.py +510 -0
  102. package/editor/helpers/motion/templates/grafico_linha.py +462 -0
  103. package/editor/helpers/motion/templates/notificacao.py +266 -0
  104. package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
  105. package/editor/helpers/motion/templates/passos.py +321 -0
  106. package/editor/helpers/motion/templates/riscado.py +298 -0
  107. package/editor/helpers/motion/templates/selo_garantia.py +150 -0
  108. package/editor/helpers/motion_render.py +241 -0
  109. package/editor/helpers/motion_sugerir.py +961 -0
  110. package/editor/helpers/noise_check.py +134 -0
  111. package/editor/helpers/pack_transcripts.py +223 -0
  112. package/editor/helpers/palavras_na_onda.py +176 -0
  113. package/editor/helpers/plano_sfx.py +839 -0
  114. package/editor/helpers/portao.py +427 -0
  115. package/editor/helpers/preview_server.py +1177 -0
  116. package/editor/helpers/rastrear_rosto.py +298 -0
  117. package/editor/helpers/render_cut.py +1256 -0
  118. package/editor/helpers/respiros.py +89 -0
  119. package/editor/helpers/sincronizar_cameras.py +571 -0
  120. package/editor/helpers/speech_regions.py +423 -0
  121. package/editor/helpers/style_catalog.json +231 -0
  122. package/editor/helpers/style_samples.py +246 -0
  123. package/editor/helpers/tela_verde.py +2113 -0
  124. package/editor/helpers/text_render.py +2059 -0
  125. package/editor/helpers/texto_na_tela.py +366 -0
  126. package/editor/helpers/timeline_view.py +292 -0
  127. package/editor/helpers/tira_de_quadros.py +113 -0
  128. package/editor/helpers/transcribe.py +734 -0
  129. package/editor/helpers/transcribe_batch.py +129 -0
  130. package/editor/helpers/trilhas.py +1146 -0
  131. package/editor/helpers/trilhas_humores.json +66 -0
  132. package/editor/helpers/valores.py +187 -0
  133. package/editor/helpers/verify_cut.py +820 -0
  134. package/editor/helpers/voice_levels.py +233 -0
  135. package/editor/helpers/voz_check.py +386 -0
  136. package/editor/helpers/voz_limpa.py +81 -0
  137. package/editor/helpers/watch_preview.py +372 -0
  138. package/editor/modelo-projeto/LEIA.md +402 -0
  139. package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
  140. package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
  141. package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
  142. package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
  143. package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
  144. package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
  145. package/editor/modelo-projeto/assets/three/LICENSE +21 -0
  146. package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
  147. package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
  148. package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
  149. package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
  150. package/editor/modelo-projeto/build.py +4828 -0
  151. package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
  152. package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
  153. package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
  154. package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
  155. package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
  156. package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
  157. package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
  158. package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
  159. package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
  160. package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
  161. package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
  162. package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
  163. package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
  164. package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
  165. package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
  166. package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
  167. package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
  168. package/editor/modelo-projeto/hyperframes.json +12 -0
  169. package/editor/modelo-projeto/package.json +11 -0
  170. package/editor/modelo-projeto/projeto.json +16 -0
  171. package/editor/preview/app.css +1009 -0
  172. package/editor/preview/app.js +2122 -0
  173. package/editor/preview/index.html +307 -0
  174. package/editor/pyproject.toml +23 -0
  175. package/editor/references/3d.md +236 -0
  176. package/editor/references/animacao-catalogo.md +672 -0
  177. package/editor/references/animacao.md +498 -0
  178. package/editor/references/comandos.md +92 -0
  179. package/editor/references/corte-seco-como-funciona.md +50 -0
  180. package/editor/references/corte-seco.md +88 -0
  181. package/editor/references/corte.md +290 -0
  182. package/editor/references/direcao-por-blocos.md +218 -0
  183. package/editor/references/enquadramento.md +654 -0
  184. package/editor/references/estilos-prontos.md +355 -0
  185. package/editor/references/estudio.md +100 -0
  186. package/editor/references/exemplo-eventos.json +67 -0
  187. package/editor/references/lettering.md +252 -0
  188. package/editor/references/modelos.md +494 -0
  189. package/editor/references/phase2.md +1007 -0
  190. package/editor/references/referencia-aplicar.md +203 -0
  191. package/editor/references/referencia-medir.md +375 -0
  192. package/editor/references/revisao.md +335 -0
  193. package/editor/references/som-e-trilha.md +146 -0
  194. package/editor/references/tabela-de-sons.md +130 -0
  195. package/editor/references/variacoes.md +234 -0
  196. package/editor/uv.lock +1141 -0
  197. package/lib/act.js +38 -8
  198. package/lib/app-chat.js +97 -0
  199. package/lib/catalogo.js +7 -1
  200. package/lib/chamada-torta.js +81 -0
  201. package/lib/editor-pro.js +786 -0
  202. package/lib/entrega.js +58 -0
  203. package/lib/estudio/conferencia-visual.js +76 -0
  204. package/lib/estudio/diretor.js +6 -1
  205. package/lib/estudio/exportar.js +3 -1
  206. package/lib/estudio/gosto/tela.html +1349 -0
  207. package/lib/estudio/gosto.js +305 -0
  208. package/lib/estudio/index.js +12 -1
  209. package/lib/estudio/kit-ae/base.jsx +119 -13
  210. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  211. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  212. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  213. package/lib/estudio/maquina-diretor.js +70 -12
  214. package/lib/estudio/maquina-foto.js +177 -0
  215. package/lib/estudio/maquina-guarda.js +207 -0
  216. package/lib/estudio/maquina-layout.js +22 -1
  217. package/lib/estudio/maquina.js +64 -11
  218. package/lib/estudio/referencias.js +1 -1
  219. package/lib/estudio/visao/visao.swift +97 -0
  220. package/lib/estudio/visao.js +60 -0
  221. package/lib/headless.js +15 -3
  222. package/lib/local/cerebro.json +285 -7
  223. package/lib/local/motor.js +5 -2
  224. package/lib/memoria-usuario.js +164 -0
  225. package/lib/nuvem.js +33 -2
  226. package/lib/passagem.js +50 -0
  227. package/lib/perigo.js +19 -11
  228. package/lib/protecao.js +134 -0
  229. package/lib/repeticao.js +1 -1
  230. package/lib/sandbox.js +57 -9
  231. package/lib/titulo-conversa.js +46 -0
  232. package/lib/tools.js +23 -4
  233. package/lib/ui.js +2 -2
  234. package/package.json +3 -2
  235. package/skills/editar-meu-video/SKILL.md +6 -2
  236. package/skills/editar-video/SKILL.md +113 -0
  237. package/studio/pessoa.py +52 -0
  238. package/voz/primo_voz/__main__.py +55 -40
  239. package/voz/primo_voz/app.py +500 -4
  240. package/voz/primo_voz/chat.html +1773 -0
  241. package/voz/primo_voz/conversa.py +0 -26
  242. package/voz/primo_voz/hud.html +8 -2
  243. package/voz/primo_voz/hud.py +129 -5
  244. package/voz/primo_voz/identidade.py +1 -1
  245. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,961 @@
1
+ """Suggests animations for edit_data.json "motion" from what the person says in the cut (SPEC Contract 12).
2
+
3
+ Reads <edit>/transcripts/cut.json (the words of cut.mp4 with their times) and, when they exist,
4
+ cut_timeline.json and edit_data.json (hook and CTA times). Finds in the speech:
5
+ - a comment call ("comenta IDEIA") -> comentario_cta with that keyword
6
+ - a price ("por apenas 37 reais", "de 97 por 37") -> etiqueta_preco (old price when spoken)
7
+ - "você não precisa de X, Y, nem Z" -> riscado (each item crossed when it is said)
8
+ - what the person gets, or "sem X, sem Y" -> checklist (each item checked when it is said)
9
+ - a short sequence ("primeiro..., depois..."; "Compra. Conecta. Usa.") -> passos
10
+ - a percentage (progress words: barra_progresso), a count with a unit ("1.200 alunos") or money that is not
11
+ a price ("faturei 50 mil reais por mês") -> contador
12
+ and prints them as "motion" items with times from the speech. SUGGESTIONS ONLY: nothing is written to
13
+ edit_data.json. The agent reads, edits, asks the person and copies what she wants.
14
+
15
+ Rules the suggestions follow: at most one animation every ~6 s, never during the hook, never over the CTA
16
+ (except the comment call, with a note), region "auto" (the compose keeps it off the face). Numbers and values
17
+ come only from what the person says: nothing is made up.
18
+
19
+ Usage:
20
+ uv run python helpers/motion_sugerir.py --edit-dir EDIT [--json] [--intervalo 6] [-o sugestoes.json]
21
+
22
+ The last stdout line is a JSON object: {"motion": [...], "skipped": [...], ...}. Each item has a "note"
23
+ (the phrase and why), which compose_final.py accepts and ignores.
24
+ Exit 2 for things to fix (missing transcript, bad folder).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import re
32
+ import sys
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+
36
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
37
+ import valores as vl # noqa: E402
38
+ from common import file_signature, fmt_time, load_json # noqa: E402
39
+ from motion import engine as ME # noqa: E402
40
+ from motion import registry as Rg # noqa: E402
41
+ from motion.engine import MotionError # noqa: E402
42
+
43
+ MIN_GAP = 6.0 # s between two suggestions (starts)
44
+ HOOK_DEFAULT = 3.0 # s kept free at the start when edit_data has no hook yet
45
+ CTA_DEFAULT = 2.5 # s at the end taken by the CTA card when edit_data does not say
46
+ LEAD = 0.25 # s the animation starts before the first word it shows
47
+ READ_AFTER = 1.2 # s kept on screen after the last word it shows
48
+ MIN_LENGTH = 1.5 # s: a shorter suggestion is dropped
49
+ SENTENCE_GAP = 0.7 # s of silence that ends a sentence
50
+ PRIORITY = {"comentario_cta": 100, "etiqueta_preco": 90, "riscado": 80, "checklist": 70, "passos": 65,
51
+ "barra_progresso": 50, "contador": 40}
52
+
53
+ FUNCTION_WORDS = {"de", "do", "da", "dos", "das", "o", "a", "os", "as", "um", "uma", "uns", "umas", "nem", "e",
54
+ "ou", "que", "pra", "para", "com", "no", "na", "em", "tambem", "mais", "nenhum", "nenhuma",
55
+ "isso", "so", "ai", "aqui", "tipo", "ne", "entao", "voce"}
56
+ KEYWORD_STOP = FUNCTION_WORDS | {"embaixo", "comentario", "comentarios", "mensagem", "direct", "dm", "eu", "la",
57
+ "palavra", "seu", "sua", "ele", "ela", "gente", "agora", "ja", "se", "me", "te"}
58
+ UNITS = ("alunos", "alunas", "aluno", "pessoas", "clientes", "seguidores", "dias", "horas", "minutos", "aulas",
59
+ "modulos", "videos", "produtos", "fornecedores", "lojas", "vezes", "anos", "meses", "semanas",
60
+ "vagas", "passos", "etapas", "cursos", "ferramentas", "bonus", "mentorias", "encontros", "lives")
61
+ UNIT_LABEL = {"alunas": "alunas", "aluno": "aluno", "modulos": "módulos", "videos": "vídeos", "bonus": "bônus"}
62
+ PROGRESS_WORDS = re.compile(r"\b(conclu\w*|complet\w*|pront\w*|preench\w*|vagas?|do curso|das aulas|meta|"
63
+ r"progresso|falta\w*|lotad\w*|esgotad\w*|acabando)\b")
64
+ PRICE_CONTEXT = re.compile(r"\b(por\s+(apenas|so)|apenas|custa\w*|investimento|valor|preco|paga\w*|sai\s+por|"
65
+ r"parcela\w*|\d+\s*x\s+de|a\s+vista|promocao|desconto|oferta|por\s+menos\s+de)\b")
66
+ # money said as what someone made (not what something costs): "faturei 50 mil", "30 mil de faturamento"
67
+ GAIN_BEFORE = re.compile(r"\b(?:fatur\w*|ganh(?:ei|ou|amos|aram|ava|avam|ando|e|ar)|lucr\w*|fiz|fez|fizemos|fizeram|"
68
+ r"fazendo|tirei|tirou|tiramos|tiro|tirando|caiu|cairam|recebi|recebeu|recebemos|vendi|vendeu|"
69
+ r"vendemos|renda|salario|comiss\w*|saldo)\b")
70
+ GAIN_AFTER = re.compile(r"\s*(?:de|em|no|na)?\s*(?:faturamento|lucro|renda|comiss\w*|vendas|salario)\b")
71
+ PERIOD_AFTER = re.compile(r"\s*(?:/\s*|por\s+|ao\s+|no\s+|na\s+|em\s+(?:um|uma)\s+)(?:mes|dia|semana|ano|hora)\b")
72
+
73
+
74
+ @dataclass
75
+ class Word:
76
+ text: str # as transcribed
77
+ norm: str # lower case, no accents, no punctuation
78
+ start: float
79
+ end: float
80
+
81
+
82
+ @dataclass
83
+ class Sentence:
84
+ words: list[Word]
85
+ text: str = ""
86
+ ntext: str = ""
87
+ spans: list[tuple[int, int]] = field(default_factory=list) # char span of each word in ntext
88
+
89
+ def __post_init__(self) -> None:
90
+ parts, pos = [], 0
91
+ for w in self.words:
92
+ t = vl.norm(w.text)
93
+ parts.append(t)
94
+ self.spans.append((pos, pos + len(t)))
95
+ pos += len(t) + 1
96
+ self.ntext = " ".join(parts)
97
+ self.text = " ".join(w.text for w in self.words)
98
+
99
+ def word_at(self, char: int) -> int:
100
+ for i, (a, b) in enumerate(self.spans):
101
+ if char < b:
102
+ return i
103
+ return len(self.words) - 1
104
+
105
+ def words_in(self, a: int, b: int) -> list[Word]:
106
+ return [w for w, (x, y) in zip(self.words, self.spans) if x < b and a < y]
107
+
108
+
109
+ @dataclass
110
+ class Candidate:
111
+ template: str
112
+ start: float # first word it shows
113
+ last: float # last word it shows (end of that word)
114
+ params: dict
115
+ quote: str
116
+ note: str = ""
117
+ times: list[float] = field(default_factory=list) # absolute seconds of each item, when it has items
118
+ score: int | None = None # a moment's own weight (None = the template's PRIORITY)
119
+ marked: bool = False # asked for at this point in the preview: always kept
120
+
121
+ @property
122
+ def priority(self) -> int:
123
+ if self.marked:
124
+ return 1000
125
+ return PRIORITY[self.template] if self.score is None else self.score
126
+
127
+
128
+ class SuggestError(Exception):
129
+ pass
130
+
131
+
132
+ # ------------------------------------------------------------------ reading
133
+
134
+ def clean_word(text: str) -> str:
135
+ return re.sub(r"[^\w%$]+", "", vl.norm(text))
136
+
137
+
138
+ def load_words(edit_dir: Path) -> tuple[list[Word], list[str]]:
139
+ path = edit_dir / "transcripts" / "cut.json"
140
+ notes: list[str] = []
141
+ if not path.is_file():
142
+ raise SuggestError(f"não achei {path}. Transcreva o corte antes: "
143
+ f"transcribe.py '{edit_dir / 'cut.mp4'}' --edit-dir '{edit_dir}'")
144
+ try:
145
+ data = load_json(path)
146
+ except ValueError as e:
147
+ raise SuggestError(f"{path.name} inválido ({e})") from None
148
+ raw = (data or {}).get("words") if isinstance(data, dict) else None
149
+ if not isinstance(raw, list) or not raw:
150
+ raise SuggestError(f"{path.name} não tem palavras com tempo (transcreva o corte de novo)")
151
+ cut = edit_dir / "cut.mp4"
152
+ if cut.is_file() and data.get("source_sig") and data.get("source_sig") != file_signature(cut):
153
+ notes.append("transcripts/cut.json é de outra versão do corte: os tempos podem estar errados. "
154
+ "Transcreva o cut.mp4 de novo")
155
+ words = []
156
+ for w in raw:
157
+ try:
158
+ text, a, b = str(w["text"]).strip(), float(w["start"]), float(w["end"])
159
+ except (KeyError, TypeError, ValueError):
160
+ continue
161
+ if text:
162
+ words.append(Word(text, clean_word(text), a, max(a, b)))
163
+ words.sort(key=lambda w: w.start)
164
+ return words, notes
165
+
166
+
167
+ def sentences_of(words: list[Word]) -> list[Sentence]:
168
+ out: list[Sentence] = []
169
+ cur: list[Word] = []
170
+ for i, w in enumerate(words):
171
+ cur.append(w)
172
+ nxt = words[i + 1] if i + 1 < len(words) else None
173
+ if nxt is None or re.search(r"[.!?]$", w.text) or nxt.start - w.end > SENTENCE_GAP:
174
+ out.append(Sentence(cur))
175
+ cur = []
176
+ return out
177
+
178
+
179
+ def edit_windows(edit_dir: Path, duration: float) -> tuple[float, float | None, dict]:
180
+ """(hook end, CTA start or None, facts) from edit_data.json, with defaults for an edit not written yet."""
181
+ data = None
182
+ path = edit_dir / "edit_data.json"
183
+ if path.is_file():
184
+ try:
185
+ data = load_json(path)
186
+ except ValueError:
187
+ data = None
188
+ if not isinstance(data, dict):
189
+ return HOOK_DEFAULT, max(0.0, duration - CTA_DEFAULT), {"edit_data": False}
190
+
191
+ def on(key: str) -> dict | None:
192
+ sec = data.get(key)
193
+ return sec if isinstance(sec, dict) and sec and sec.get("enabled", True) is not False else None
194
+
195
+ hook_end = 0.3
196
+ hook = on("hook")
197
+ if hook:
198
+ try:
199
+ hook_end = float(hook.get("end") if hook.get("end") is not None else 3.0)
200
+ except (TypeError, ValueError):
201
+ hook_end = 3.0
202
+ cta_start = None
203
+ cta = on("cta")
204
+ if cta:
205
+ try:
206
+ dur = float(cta.get("duration", 2.5) or 2.5)
207
+ cta_start = float(cta["start"]) if cta.get("start") is not None else max(0.0, duration - dur)
208
+ except (TypeError, ValueError):
209
+ cta_start = max(0.0, duration - CTA_DEFAULT)
210
+ return hook_end, cta_start, {"edit_data": True}
211
+
212
+
213
+ # ------------------------------------------------------------------ small text helpers
214
+
215
+ def title_case(text: str) -> str:
216
+ text = text.strip(" ,.;:!?-")
217
+ return text[:1].upper() + text[1:] if text else text
218
+
219
+
220
+ def original_span(s: Sentence, a: int, b: int) -> str:
221
+ """The transcribed words for a char range of the normalized sentence, without trailing punctuation."""
222
+ return " ".join(w.text for w in s.words_in(a, b)).strip(" ,.;:!?")
223
+
224
+
225
+ def short_item(s: Sentence, a: int, b: int, max_words: int, max_len: int) -> tuple[str, float] | None:
226
+ """A list item from a char range: leading function words dropped, at most max_words words and max_len letters.
227
+ Returns (text, start of its first word)."""
228
+ ws = s.words_in(a, b)
229
+ while ws and ws[0].norm in FUNCTION_WORDS:
230
+ ws = ws[1:]
231
+ ws = ws[:max_words]
232
+ while ws and ws[-1].norm in FUNCTION_WORDS:
233
+ ws = ws[:-1]
234
+ if not ws:
235
+ return None
236
+ text = title_case(" ".join(w.text for w in ws))
237
+ while len(text) > max_len and len(ws) > 1:
238
+ ws = ws[:-1]
239
+ text = title_case(" ".join(w.text for w in ws))
240
+ if len(text) > max_len or not re.search(r"\w", text):
241
+ return None
242
+ return text, ws[0].start
243
+
244
+
245
+ def split_list(s: Sentence, a: int, b: int) -> list[tuple[int, int]]:
246
+ """Char ranges of the pieces of an enumeration ("x, y e z", "x, nem y")."""
247
+ seg = s.ntext[a:b]
248
+ cuts = [0]
249
+ for m in re.finditer(r",|\s(?:e|nem|ou)\s(?!sim\b)", seg):
250
+ if m.group(0) != ",":
251
+ said = s.words[s.word_at(a + m.start() + 1)].text.lower().strip(",.;:!?")
252
+ if said not in ("e", "nem", "ou"):
253
+ continue # "é" (the verb) loses its accent in the normalized text
254
+ cuts.append(m.start())
255
+ cuts.append(m.end())
256
+ cuts.append(len(seg))
257
+ out = []
258
+ for x, y in zip(cuts[::2], cuts[1::2]):
259
+ if y > x and seg[x:y].strip(" ,"):
260
+ out.append((a + x, a + y))
261
+ return out
262
+
263
+
264
+ def is_gain(s: Sentence, m: vl.Money) -> bool:
265
+ """The money is what someone made ("faturei 50 mil", "30 mil de faturamento"), not a price."""
266
+ return bool(GAIN_BEFORE.search(s.ntext[max(0, m.start - 45):m.start]) or GAIN_AFTER.match(s.ntext, m.end))
267
+
268
+
269
+ # ------------------------------------------------------------------ detectors
270
+
271
+ COMMENT_RE = re.compile(r"\b(?:comenta|comente|comentem|comentar|digita|digite|escreve|escreva|"
272
+ r"(?:manda|mande)(?=\s+(?:a|essa)\s+palavra))\b((?:\s+(?:a|essa|esta|aqui|ai|embaixo|nos|"
273
+ r"comentarios|palavra|so|apenas))*)\s+[\"']?([a-z0-9]{2,20})\b")
274
+
275
+
276
+ def find_comment(sents: list[Sentence]) -> list[Candidate]:
277
+ out = []
278
+ for s in sents:
279
+ for m in COMMENT_RE.finditer(s.ntext):
280
+ key_i = s.word_at(m.start(2))
281
+ word = s.words[key_i]
282
+ keyword = re.sub(r"[^\w]", "", word.text).upper()
283
+ if word.norm == "eu" and key_i + 1 < len(s.words) and s.words[key_i + 1].norm not in KEYWORD_STOP:
284
+ word = s.words[key_i + 1] # "comenta EU QUERO"
285
+ keyword = keyword + " " + re.sub(r"[^\w]", "", word.text).upper()
286
+ elif word.norm in KEYWORD_STOP:
287
+ continue
288
+ keyword = keyword[:20].strip()
289
+ if not keyword:
290
+ continue
291
+ trigger = s.words[s.word_at(m.start())]
292
+ params = {"keyword": keyword, "prompt_text": "Comente *{palavra}*",
293
+ "delay": round(min(5.0, max(0.3, word.start - (trigger.start - LEAD))), 2)}
294
+ out.append(Candidate("comentario_cta", trigger.start, word.end, params,
295
+ original_span(s, m.start(), m.end())))
296
+ return out
297
+
298
+
299
+ def price_value(expr: str) -> tuple[float | None, str]:
300
+ value = vl.parse_money(expr)
301
+ prefix = "US$" if re.search(r"us\$|u\$|dolar", vl.norm(expr)) else "€" if "€" in expr or "euro" in vl.norm(expr) \
302
+ else "R$"
303
+ return value, prefix
304
+
305
+
306
+ def find_price(sents: list[Sentence]) -> list[Candidate]:
307
+ out = []
308
+ for s in sents:
309
+ monies = vl.find_money(s.ntext)
310
+ if not monies:
311
+ continue
312
+ if not PRICE_CONTEXT.search(s.ntext):
313
+ continue
314
+ if any(is_gain(s, m) for m in monies):
315
+ continue # money someone made is not a price: find_numbers makes it a contador
316
+ used_old = None
317
+ # "de 97 por 37": the old price and the new one
318
+ for a, b in zip(monies, monies[1:]):
319
+ between = s.ntext[a.end:b.start]
320
+ before = s.ntext[max(0, a.start - 4):a.start]
321
+ if re.search(r"\bde\s*$", before) and re.fullmatch(r"\s+(?:por|para|pra)(?:\s+(?:apenas|so))?\s+", between):
322
+ used_old = (a, b)
323
+ break
324
+ if used_old:
325
+ old_m, new_m = used_old
326
+ else:
327
+ new_m, old_m = next((m for m in monies if not m.bare), monies[0]), None
328
+ if new_m.bare and not re.search(r"\b(?:por\s+(?:apenas|so)|apenas|custa\w*)\s+$",
329
+ s.ntext[max(0, new_m.start - 16):new_m.start]):
330
+ continue
331
+ price, prefix = price_value(s.ntext[new_m.start:new_m.end])
332
+ if price is None or price <= 0 or price > 1e7:
333
+ continue
334
+ w_new = s.words_in(new_m.start, new_m.end)
335
+ params: dict = {"price": round(price, 2), "prefix": prefix,
336
+ "label": "por apenas" if re.search(r"\b(apenas|so)\b", s.ntext[:new_m.start]) else ""}
337
+ first = w_new[0].start
338
+ if old_m is not None:
339
+ old, _ = price_value(s.ntext[old_m.start:old_m.end])
340
+ if old is not None and old > price:
341
+ w_old = s.words_in(old_m.start, old_m.end)
342
+ params["old_price"] = round(old, 2)
343
+ first = w_old[0].start
344
+ params["tag_delay"] = round(min(5.0, max(0.45, w_new[0].start - first)), 2)
345
+ cut = max(new_m.end, old_m.end if old_m else 0)
346
+ out.append(Candidate("etiqueta_preco", first, w_new[-1].end, params,
347
+ original_span(s, min(new_m.start, old_m.start if old_m else new_m.start), cut),
348
+ note="o preço antigo precisa ser o de verdade"))
349
+ return out
350
+
351
+
352
+ NOT_NEED_RE = re.compile(r"\b(?:nao\s+(?:vai\s+)?(?:precisa|precisar|e\s+preciso)(?:\s+mais)?|sem\s+precisar)"
353
+ r"(?:\s+(?:de|ter\s+que))?\b")
354
+
355
+
356
+ def find_not_needed(sents: list[Sentence]) -> list[Candidate]:
357
+ """'Você não precisa de X, Y, nem Z' (also one trigger per sentence, in sentences close together)."""
358
+ hits = [] # (sentence, trigger match, end of its clause)
359
+ for s in sents:
360
+ ms = list(NOT_NEED_RE.finditer(s.ntext))
361
+ for k, m in enumerate(ms):
362
+ end = ms[k + 1].start() if k + 1 < len(ms) else len(s.ntext)
363
+ hits.append((s, m, end))
364
+ groups: list[list] = []
365
+ for h in hits:
366
+ t = h[0].words[h[0].word_at(h[1].start())].start
367
+ if groups and t - groups[-1][-1][3] <= 4.0:
368
+ groups[-1].append((*h, t))
369
+ else:
370
+ groups.append([(*h, t)])
371
+ out = []
372
+ for g in groups:
373
+ items: list[tuple[str, float]] = []
374
+ last = 0.0
375
+ for s, m, end, _t in g:
376
+ pieces = split_list(s, m.end(), end)
377
+ for a, b in pieces:
378
+ it = short_item(s, a, b, 4, 32)
379
+ if it and it[0].lower() not in (x[0].lower() for x in items):
380
+ items.append(it)
381
+ last = max(last, s.words_in(a, b)[-1].end)
382
+ items = items[:6]
383
+ if len(items) < 2:
384
+ continue
385
+ s0, m0 = g[0][0], g[0][1]
386
+ start = s0.words[s0.word_at(m0.start())].start
387
+ quote = " ".join(original_span(s, m.start(), end) for s, m, end, _ in g)
388
+ out.append(Candidate("riscado", start, last,
389
+ {"items": [x[0] for x in items], "title": "Você *não* precisa de:"},
390
+ quote, times=[x[1] for x in items]))
391
+ return out
392
+
393
+
394
+ GET_RE = re.compile(r"\b(?:voce\s+)?(?:vai\s+)?(?:recebe|receber|ganha|ganhar|leva|levar|inclui|incluso|encontra|"
395
+ r"encontrar|"
396
+ r"tem\s+acesso\s+(?:a|ao|as|aos)|vai\s+ter|tem)\b(?:\s+(?:tambem|ainda|na\s+hora))?")
397
+ SEM_RE = re.compile(r"\bsem\s+\w+")
398
+
399
+
400
+ def find_checklist(sents: list[Sentence]) -> list[Candidate]:
401
+ out = []
402
+ for s in sents:
403
+ sems = list(SEM_RE.finditer(s.ntext))
404
+ if len(sems) >= 2:
405
+ items = []
406
+ for k, m in enumerate(sems):
407
+ end = sems[k + 1].start() if k + 1 < len(sems) else len(s.ntext)
408
+ piece = split_list(s, m.start(), end)
409
+ if not piece:
410
+ continue
411
+ ws = s.words_in(*piece[0])[:3]
412
+ if ws:
413
+ items.append((title_case(" ".join(w.text for w in ws))[:32], ws[0].start, ws[-1].end))
414
+ if len(items) >= 2:
415
+ out.append(Candidate("checklist", items[0][1], items[-1][2],
416
+ {"items": [x[0] for x in items[:6]]},
417
+ original_span(s, sems[0].start(), len(s.ntext)), times=[x[1] for x in items[:6]]))
418
+ continue
419
+ m = GET_RE.search(s.ntext)
420
+ if not m:
421
+ generic = find_enumeration(s)
422
+ if generic:
423
+ out.append(generic)
424
+ continue
425
+ pieces = split_list(s, m.end(), len(s.ntext))
426
+ if len(pieces) < 2:
427
+ continue
428
+ items = []
429
+ for a, b in pieces:
430
+ rel = re.search(r"\s(?:que|pra|para|onde)\s", s.ntext[a:b])
431
+ if rel and rel.start() > 0: # "um mecanismo ... que ajuda a ...": the item ends before the clause
432
+ b = a + rel.start()
433
+ if len(s.words_in(a, b)) > 9: # a long piece is shortened by short_item; longer is prose
434
+ break
435
+ it = short_item(s, a, b, 4, 32)
436
+ if it:
437
+ items.append((it[0], it[1], s.words_in(a, b)[-1].end))
438
+ if len(items) < 2:
439
+ continue
440
+ title = "O que você *recebe*:" if re.search(r"recebe|ganha|leva|inclui|acesso", m.group(0)) else ""
441
+ out.append(Candidate("checklist", items[0][1], items[-1][2],
442
+ {"items": [x[0] for x in items[:6]], "title": title},
443
+ original_span(s, m.start(), len(s.ntext)), times=[x[1] for x in items[:6]]))
444
+ return out
445
+
446
+
447
+ def find_enumeration(s: Sentence) -> Candidate | None:
448
+ """A plain list said in one sentence ("o tamanho, o acabamento, os detalhes e o material"): at least two
449
+ commas and three short pieces."""
450
+ if s.ntext.count(",") < 2:
451
+ return None
452
+ items = []
453
+ for a, b in split_list(s, 0, len(s.ntext)):
454
+ ws = s.words_in(a, b)
455
+ if len(ws) > 4:
456
+ return None
457
+ it = short_item(s, a, b, 4, 32)
458
+ if it:
459
+ items.append((it[0], it[1], ws[-1].end))
460
+ if len(items) < 3:
461
+ return None
462
+ items = items[:6]
463
+ return Candidate("checklist", items[0][1], items[-1][2], {"items": [x[0] for x in items]}, s.text.strip(),
464
+ note="lista falada: confira se cada item faz sentido sozinho", times=[x[1] for x in items])
465
+
466
+
467
+ STEP_RE = re.compile(r"\b(?:primeiro|primeira\s+coisa|passo\s+(?:um|1)|depois|em\s+seguida|segundo|passo\s+(?:dois|2)|"
468
+ r"terceiro|passo\s+(?:tres|3)|por\s+(?:fim|ultimo)|no\s+final|e\s+pronto)\b")
469
+
470
+
471
+ def find_steps(sents: list[Sentence]) -> list[Candidate]:
472
+ out = []
473
+ # 1. very short sentences in a row: "Compra. Conecta. Usa."
474
+ run: list[Sentence] = []
475
+
476
+ def flush() -> None:
477
+ if 2 <= len(run) <= 4:
478
+ items = [title_case(" ".join(w.text for w in x.words))[:20] for x in run]
479
+ if all(items):
480
+ out.append(Candidate("passos", run[0].words[0].start, run[-1].words[-1].end, {"items": items},
481
+ " ".join(x.text for x in run), times=[x.words[0].start for x in run]))
482
+
483
+ for s in sents:
484
+ short = len(s.words) <= 2 and all(w.norm not in FUNCTION_WORDS for w in s.words)
485
+ if short and (not run or s.words[0].start - run[-1].words[-1].end <= 1.2):
486
+ run.append(s)
487
+ continue
488
+ flush()
489
+ run = [s] if short else []
490
+ flush()
491
+ # 2. "primeiro ..., depois ..., por fim ..."
492
+ words = [w for s in sents for w in s.words]
493
+ whole = Sentence(words) if words else None
494
+ if whole is None:
495
+ return out
496
+ marks = list(STEP_RE.finditer(whole.ntext))
497
+ i = 0
498
+ while i < len(marks):
499
+ if not re.match(r"primeir|passo", marks[i].group(0)):
500
+ i += 1
501
+ continue
502
+ chain = [marks[i]]
503
+ j = i + 1
504
+ while j < len(marks) and len(chain) < 4:
505
+ t_prev = whole.words[whole.word_at(chain[-1].start())].start
506
+ t_here = whole.words[whole.word_at(marks[j].start())].start
507
+ if t_here - t_prev > 5.0:
508
+ break
509
+ chain.append(marks[j])
510
+ j += 1
511
+ if len(chain) >= 2:
512
+ items = []
513
+ for k, m in enumerate(chain):
514
+ end = chain[k + 1].start() if k + 1 < len(chain) else min(len(whole.ntext), m.end() + 40)
515
+ ws = [w for w in whole.words_in(m.end(), end) if w.norm not in FUNCTION_WORDS][:2]
516
+ if not ws:
517
+ break
518
+ items.append((title_case(" ".join(w.text for w in ws))[:20], ws[0].start, ws[-1].end))
519
+ if len(items) == len(chain):
520
+ out.append(Candidate("passos", whole.words[whole.word_at(chain[0].start())].start, items[-1][2],
521
+ {"items": [x[0] for x in items]},
522
+ original_span(whole, chain[0].start(), chain[-1].end() + 30),
523
+ times=[x[1] for x in items]))
524
+ i = j if len(chain) >= 2 else i + 1
525
+ return out
526
+
527
+
528
+ PERCENT_RE = re.compile(rf"(?<![\w,.])({vl.DIGITS_RE}|{vl.WORDNUM_RE})\s*(?:%|por\s*cento\b)")
529
+ COUNT_RE = re.compile(rf"(?<![\w,.$])(?:mais\s+de\s+)?({vl.DIGITS_RE}|{vl.WORDNUM_RE})(?:\s+({vl.MULT_RE}))?"
530
+ rf"\s+(?:de\s+)?({'|'.join(UNITS)})\b((?:\s+(?!e\b|que\b|com\b|pra\b|para\b)\w+){{0,3}})")
531
+
532
+
533
+ def find_numbers(sents: list[Sentence]) -> list[Candidate]:
534
+ """Percentages, counts with a unit and money that is not a price (what someone made, spent or has)."""
535
+ out = []
536
+ for s in sents:
537
+ found = []
538
+ for m in PERCENT_RE.finditer(s.ntext):
539
+ value = vl.parse_number(m.group(1))
540
+ if value is None or not 0 < value <= 1000:
541
+ continue
542
+ found.append(("pct", m, value))
543
+ for m in COUNT_RE.finditer(s.ntext):
544
+ value = vl.parse_number(m.group(1))
545
+ if value is None:
546
+ continue
547
+ if m.group(2):
548
+ value *= vl.MULTIPLIERS[m.group(2)]
549
+ if value < 2 or value > 1e9:
550
+ continue
551
+ found.append(("count", m, value))
552
+ monies = vl.find_money(s.ntext)
553
+ price_talk = PRICE_CONTEXT.search(s.ntext) and not any(is_gain(s, mo) for mo in monies)
554
+ for mo in monies if not price_talk else []:
555
+ if mo.value is None or not 0 < mo.value <= 1e12 or (mo.bare and not is_gain(s, mo)):
556
+ continue # a bare "10 mil" is money only when said as what someone made
557
+ found.append(("money", mo, mo.value))
558
+ if not found:
559
+ continue
560
+ kind, m, value = found[0]
561
+ if kind == "money":
562
+ ws = s.words_in(m.start, m.end)
563
+ _, prefix = price_value(s.ntext[m.start:m.end])
564
+ params = {"value": round(value, 2), "prefix": prefix}
565
+ period = PERIOD_AFTER.match(s.ntext, m.end)
566
+ if period:
567
+ params["label"] = original_span(s, m.end, period.end())[:40]
568
+ out.append(Candidate("contador", ws[0].start, ws[-1].end, params, original_span(s, m.start, m.end)))
569
+ continue
570
+ ws = s.words_in(m.start(), m.end())
571
+ if kind == "pct":
572
+ if value <= 100 and PROGRESS_WORDS.search(s.ntext):
573
+ label = ""
574
+ pm = PROGRESS_WORDS.search(s.ntext)
575
+ if pm:
576
+ label = title_case(original_span(s, pm.start(), pm.end()))[:30]
577
+ cand = Candidate("barra_progresso", ws[0].start, ws[-1].end,
578
+ {"value": value, "label": label or "Concluído"}, original_span(s, m.start(), m.end()))
579
+ else:
580
+ cand = Candidate("contador", ws[0].start, ws[-1].end, {"value": value, "suffix": "%"},
581
+ original_span(s, m.start(), m.end()))
582
+ else:
583
+ if m.group(3) == "anos" and re.search(r"\b(meus|minha\s+idade|aos|tinha|tenho|com)\s*$",
584
+ s.ntext[:m.start()].rstrip()):
585
+ continue # "antes dos meus 24 anos", "aos 19 anos": an age, not a result
586
+ unit = UNIT_LABEL.get(m.group(3), m.group(3))
587
+ label = original_span(s, m.end(3), m.end(4)).strip()[:40] if m.group(4).strip() else ""
588
+ params = {"value": value, "suffix": unit}
589
+ if label:
590
+ params["label"] = label
591
+ if re.match(r"mais\s+de", m.group(0)):
592
+ params["prefix"] = "+"
593
+ cand = Candidate("contador", ws[0].start, ws[-1].end, params, original_span(s, m.start(), m.end()))
594
+ out.append(cand)
595
+ return out
596
+
597
+
598
+ # ------------------------------------------------------------------ strong moments (no list, price or number)
599
+
600
+ # the turn of the story: what comes after these words is the point the viewer must keep
601
+ TURN_RE = re.compile(r"\b(?:foi\s+ai\s+que|ate\s+que|so\s+que|mas|porem|o\s+segredo\s+(?:e|foi)|a\s+verdade\s+e|"
602
+ r"o\s+caminho\s+que|(?:eu\s+)?descobri|a\s+virada|mudou\s+tudo|o\s+problema\s+e)\b")
603
+ # the offer: free, unlocked, limited time, bonus, guarantee
604
+ OFFER_RE = re.compile(r"\b(gratuit\w*|gratis|de\s+graca|liberad\w*|bonus|exclusiv\w*|"
605
+ r"(\d+|vinte\s+e\s+quatro)\s+horas|garantia\s+de\s+(\d+|sete|trinta)\s+dias|ultimas?\s+vagas?)\b")
606
+ EMPHASIS_RE = re.compile(r"\b(nunca|sempre|ninguem|zero|nada|todos\s+os\s+dias|de\s+verdade|simples|facil|"
607
+ r"rapido|sozinh\w*)\b")
608
+ NUMWORDS = {"sete": "7", "trinta": "30", "vinte e quatro": "24"}
609
+ MOMENT_MAX_WORDS = 3
610
+ OUTRANKS = 90 # only a moment this strong (a free offer) replaces what the patterns found there
611
+
612
+
613
+ def key_words(s: Sentence, from_word: int, limit: int = MOMENT_MAX_WORDS) -> tuple[list[Word], int] | None:
614
+ """Up to `limit` content words from word index `from_word` (function words skipped at the edges)."""
615
+ ws = [w for w in s.words[from_word:] if w.norm]
616
+ while ws and ws[0].norm in FUNCTION_WORDS | {"eu", "que", "mas", "foi", "ai", "descobri"}:
617
+ ws = ws[1:]
618
+ ws = ws[:limit]
619
+ while ws and ws[-1].norm in FUNCTION_WORDS:
620
+ ws = ws[:-1]
621
+ if not ws:
622
+ return None
623
+ return ws, s.words.index(ws[0])
624
+
625
+
626
+ def impact_text(ws: list[Word]) -> str:
627
+ text = " ".join(w.text for w in ws).strip(" ,.;:!?…").upper()
628
+ words = text.split()
629
+ while len(words) >= 2 and words[0] in ("O", "A", "OS", "AS", "UM", "UMA", "E"):
630
+ words = words[1:] # "O CAMINHO" -> "CAMINHO": a lone article on its own line reads as a symbol
631
+ if len(words) >= 2: # the last word (usually the noun) in the accent color
632
+ words[-1] = f"*{words[-1]}*"
633
+ elif words:
634
+ words[0] = f"*{words[0]}*"
635
+ return " ".join(words)[:32]
636
+
637
+
638
+ OFFER_NOUNS = {"aula": "AULA", "aulas": "AULAS", "acesso": "ACESSO", "curso": "CURSO", "mentoria": "MENTORIA",
639
+ "bonus": "BÔNUS", "treinamento": "TREINAMENTO", "material": "MATERIAL", "lista": "LISTA",
640
+ "live": "LIVE", "masterclass": "MASTERCLASS", "ebook": "E-BOOK"}
641
+ FREE_RE = re.compile(r"\b(gratuit\w*|gratis|de\s+graca)\b")
642
+ HOURS_RE = re.compile(r"\b(\d+|vinte\s+e\s+quatro)\s+horas\b")
643
+
644
+
645
+ def find_offer(s: Sentence) -> Candidate | None:
646
+ """The offer said in the sentence: guarantee -> selo_garantia; free / unlocked / for N hours -> carimbo with
647
+ what is offered ("AULA GRÁTIS", "por 24 horas" underneath). A free offer outranks a price."""
648
+ m = OFFER_RE.search(s.ntext)
649
+ if not m:
650
+ return None
651
+ g = re.search(r"garantia\s+de\s+(\d+|sete|trinta)\s+dias", s.ntext)
652
+ if g:
653
+ k = s.word_at(g.start())
654
+ num = NUMWORDS.get(g.group(1), g.group(1))
655
+ return Candidate("selo_garantia", s.words[k].start, s.words[min(len(s.words) - 1, k + 3)].end,
656
+ {"numero": num, "unidade": "dias", "titulo": "GARANTIA"}, s.text, score=78)
657
+ noun = next((OFFER_NOUNS[w.norm] for w in s.words if w.norm in OFFER_NOUNS), "")
658
+ free, hours = FREE_RE.search(s.ntext), HOURS_RE.search(s.ntext)
659
+ if free:
660
+ texto, key = (f"{noun} GRÁTIS" if noun else "GRÁTIS"), free
661
+ elif re.search(r"\bliberad\w*", s.ntext):
662
+ texto, key = (f"{noun} LIBERADA" if noun in ("AULA", "AULAS", "LISTA", "MENTORIA", "LIVE") else
663
+ f"{noun} LIBERADO" if noun else "LIBERADO"), re.search(r"\bliberad\w*", s.ntext)
664
+ elif re.search(r"\bexclusiv\w*|\bultimas?\s+vagas?", s.ntext):
665
+ key = re.search(r"\bexclusiv\w*|\bultimas?\s+vagas?", s.ntext)
666
+ texto = "EXCLUSIVO" if "exclusiv" in key.group(0) else "ÚLTIMAS VAGAS"
667
+ elif re.search(r"\bbonus\b", s.ntext):
668
+ texto, key = "BÔNUS", re.search(r"\bbonus\b", s.ntext)
669
+ else:
670
+ return None
671
+ sub = ""
672
+ if hours:
673
+ n = NUMWORDS.get(hours.group(1), hours.group(1))
674
+ sub = f"por {n} horas"
675
+ ends = [x.end() for x in (free, hours) if x] + [key.end()]
676
+ w0, w1 = s.words[s.word_at(key.start())], s.words[s.word_at(max(ends) - 1)]
677
+ return Candidate("carimbo", min(w0.start, w1.start), max(w0.end, w1.end),
678
+ {"texto": texto[:22], "subtexto": sub}, s.text, score=92 if free else 75,
679
+ note="oferta: confira se é isso mesmo que está sendo oferecido")
680
+
681
+
682
+ def find_moments(sents: list[Sentence]) -> list[Candidate]:
683
+ """The strong points of a talk that has no list, price or number: the offer (carimbo / selo_garantia),
684
+ the turn of the story (palavra_impacto with what comes after "mas", "descobri"...) and short emphatic
685
+ lines."""
686
+ out: list[Candidate] = []
687
+ for s in sents:
688
+ if len(s.words) < 2:
689
+ continue
690
+ offer = find_offer(s)
691
+ if offer:
692
+ out.append(offer)
693
+ continue
694
+ m = TURN_RE.search(s.ntext)
695
+ if m and re.match(r"(o|a)\s", m.group(0)): # "o caminho que", "o segredo é": the marker is the idea
696
+ ws = [w for w in s.words_in(m.start(), m.end()) if w.norm not in ("que", "e", "foi")]
697
+ if ws:
698
+ out.append(Candidate("palavra_impacto", ws[0].start, ws[-1].end, {"text": impact_text(ws)}, s.text,
699
+ score=60, note="virada da fala: reescreva o texto se a ideia pedir"))
700
+ continue
701
+ if m:
702
+ got = key_words(s, s.word_at(m.end() - 1) + 1)
703
+ if got and len(got[0]) >= 1:
704
+ ws = got[0]
705
+ out.append(Candidate("palavra_impacto", ws[0].start, ws[-1].end, {"text": impact_text(ws)}, s.text,
706
+ score=60, note="virada da fala: reescreva o texto se a ideia pedir"))
707
+ continue
708
+ if len(s.words) <= 8:
709
+ m = EMPHASIS_RE.search(s.ntext)
710
+ if m:
711
+ k = s.word_at(m.start())
712
+ a = max(0, k - 1)
713
+ ws = [w for w in s.words[a:a + MOMENT_MAX_WORDS] if w.norm]
714
+ while ws and ws[0].norm in FUNCTION_WORDS:
715
+ ws = ws[1:]
716
+ if ws:
717
+ out.append(Candidate("palavra_impacto", ws[0].start, ws[-1].end, {"text": impact_text(ws)},
718
+ s.text, score=52, note="frase curta e forte"))
719
+ return out
720
+
721
+
722
+ def find_marked(sents: list[Sentence], marks: list[tuple[float, str]], detectors) -> tuple[list[Candidate], list[str]]:
723
+ """One candidate per point marked in the preview: what the detectors find in that sentence, else the key
724
+ words of the sentence as palavra_impacto. Marked candidates are always kept."""
725
+ out, notes = [], []
726
+ for t, asked in marks:
727
+ s = next((x for x in sents if x.words[0].start - 0.4 <= t <= x.words[-1].end + 0.4), None)
728
+ if s is None:
729
+ s = min(sents, key=lambda x: min(abs(x.words[0].start - t), abs(x.words[-1].end - t)), default=None)
730
+ if s is None:
731
+ continue
732
+ found = [c for det in detectors for c in det([s])]
733
+ if found:
734
+ c = max(found, key=lambda c: c.priority)
735
+ else:
736
+ k = min(range(len(s.words)), key=lambda i: abs(s.words[i].start - t))
737
+ got = key_words(s, k) or key_words(s, 0)
738
+ if not got:
739
+ continue
740
+ ws = got[0]
741
+ c = Candidate("palavra_impacto", ws[0].start, ws[-1].end, {"text": impact_text(ws)}, s.text,
742
+ note="ponto marcado no preview: ajuste o texto e o modelo à ideia da frase")
743
+ c.marked = True
744
+ c.note = ("marcado no preview; " + c.note) if c.note else "marcado no preview"
745
+ if asked:
746
+ c.note += f". PEDIDO: \"{asked}\" (faça o que a pessoa pediu, mesmo que troque o modelo)"
747
+ out.append(c)
748
+ notes.append(f"ponto marcado em {fmt_time(t)} -> {c.template}")
749
+ return out, notes
750
+
751
+
752
+ def spread(items: list[dict], prio: list[int], duration: float, hook_end: float, n: int) -> list[int]:
753
+ """Indexes of the n strongest items plus every marked one (priority 1000), one automatic item per part of
754
+ the video when possible: the time after the hook is split in n equal parts."""
755
+ marked = [k for k, p in enumerate(prio) if p >= 1000]
756
+ auto = [k for k in range(len(items)) if k not in marked]
757
+ if n <= 0:
758
+ return marked
759
+ if len(auto) <= n:
760
+ return sorted(marked + auto)
761
+ span = max(1e-6, duration - hook_end)
762
+ part = lambda k: min(n - 1, max(0, int((items[k]["start"] - hook_end) / span * n))) # noqa: E731
763
+ keep: list[int] = []
764
+ used: set[int] = set()
765
+ for k in sorted(auto, key=lambda k: -prio[k]):
766
+ if len(keep) >= n:
767
+ break
768
+ if part(k) not in used:
769
+ keep.append(k)
770
+ used.add(part(k))
771
+ for k in sorted(auto, key=lambda k: -prio[k]): # parts without a candidate: fill by priority
772
+ if len(keep) >= n:
773
+ break
774
+ if k not in keep:
775
+ keep.append(k)
776
+ return sorted(marked + keep)
777
+
778
+
779
+ # ------------------------------------------------------------------ choosing and timing
780
+
781
+ def relative_params(c: Candidate, start: float) -> dict:
782
+ p = dict(c.params)
783
+ if c.times:
784
+ rel = [round(max(0.0, t - start), 2) for t in c.times]
785
+ for k in range(1, len(rel)):
786
+ rel[k] = max(rel[k], round(rel[k - 1] + 0.1, 2))
787
+ p["times"] = rel
788
+ if c.template == "riscado":
789
+ p["cross_times"] = [round(t + 0.45, 2) for t in rel]
790
+ return p
791
+
792
+
793
+ def choose(cands: list[Candidate], duration: float, hook_end: float, cta_start: float | None,
794
+ gap: float, skipped: list[dict]) -> list[dict]:
795
+ picked: list[tuple[Candidate, float, float, dict, str]] = []
796
+ for c in sorted(cands, key=lambda c: (-c.priority, c.start)):
797
+ start = round(max(0.0, c.start - LEAD), 2)
798
+ where = {"time": round(c.start, 2), "quote": c.quote, "template": c.template}
799
+ if start < hook_end:
800
+ skipped.append({**where, "why": f"fica no gancho (até {fmt_time(hook_end)})"})
801
+ continue
802
+ if any(abs(start - other[1]) < gap for other in picked):
803
+ skipped.append({**where, "why": f"perto demais de outra animação (no máximo uma a cada {gap:g} s)"})
804
+ continue
805
+ params = relative_params(c, start)
806
+ tpl = Rg.get(c.template)
807
+ try:
808
+ resolved = Rg.resolve_params(tpl, params)
809
+ except MotionError as e:
810
+ skipped.append({**where, "why": f"parâmetros recusados: {e}"})
811
+ continue
812
+ end = max(c.last + READ_AFTER, start + tpl.suggested_duration(resolved))
813
+ if c.times:
814
+ end = max(end, c.times[-1] + 1.6)
815
+ note = c.note
816
+ limit = duration
817
+ if cta_start is not None:
818
+ if c.template == "comentario_cta":
819
+ if end > cta_start and start < duration:
820
+ note = (note + "; " if note else "") + ("a chamada (cta) aparece junto no fim: termine antes dela "
821
+ "ou desligue o card de chamada")
822
+ else:
823
+ limit = min(limit, cta_start - 0.2)
824
+ end = round(min(end, limit), 2)
825
+ if end - start < MIN_LENGTH:
826
+ skipped.append({**where, "why": "não sobra tempo antes da chamada ou do fim do vídeo"})
827
+ continue
828
+ picked.append((c, start, end, params, note))
829
+ picked.sort(key=lambda x: x[1])
830
+ # an item never runs into the next one
831
+ out = []
832
+ for k, (c, start, end, params, note) in enumerate(picked):
833
+ if k + 1 < len(picked):
834
+ end = min(end, round(picked[k + 1][1] - 0.3, 2))
835
+ item = {"template": c.template, "start": start, "end": end, "region": "auto", "sfx": True,
836
+ "params": params,
837
+ "note": f"fala em {fmt_time(c.start)}: \"{c.quote}\"" + (f". {note}" if note else "")}
838
+ out.append((item, c.priority))
839
+ return out
840
+
841
+
842
+ def load_marks(edit_dir: Path) -> list[tuple[float, str]]:
843
+ """Points asked for in the preview with "Motion aqui" (marks with "kind": "motion" in
844
+ <edit>/preview_marks.json) and in <edit>/preview_motion.json: [(time, note)]."""
845
+ out: list[tuple[float, str]] = []
846
+ for name, only_motion in (("preview_marks.json", True), ("preview_motion.json", False)):
847
+ path = edit_dir / name
848
+ data = load_json(path) if path.is_file() else None
849
+ for m in (data or {}).get("marks") or [] if isinstance(data, dict) else []:
850
+ if isinstance(m, dict):
851
+ if only_motion and m.get("kind") != "motion":
852
+ continue
853
+ t = m.get("in", m.get("time"))
854
+ note = str(m.get("note") or "").strip()
855
+ else:
856
+ t, note = m, ""
857
+ try:
858
+ out.append((float(t), note))
859
+ except (TypeError, ValueError):
860
+ continue
861
+ return sorted(set(out))
862
+
863
+
864
+ def suggest(edit_dir: Path, gap: float = MIN_GAP, quantidade: int | None = None, tema: str | None = None) -> dict:
865
+ edit_dir = Path(edit_dir).expanduser().resolve()
866
+ if not edit_dir.is_dir():
867
+ raise SuggestError(f"pasta não encontrada: {edit_dir}")
868
+ words, notes = load_words(edit_dir)
869
+ timeline = load_json(edit_dir / "cut_timeline.json") if (edit_dir / "cut_timeline.json").is_file() else None
870
+ duration = float((timeline or {}).get("duration") or 0.0) or max(w.end for w in words)
871
+ hook_end, cta_start, facts = edit_windows(edit_dir, duration)
872
+ sents = sentences_of(words)
873
+ skipped: list[dict] = []
874
+ detectors = [find_comment, find_price, find_not_needed, find_checklist, find_steps, find_numbers, find_moments]
875
+ pattern = (find_comment(sents) + find_price(sents) + find_not_needed(sents) + find_checklist(sents)
876
+ + find_steps(sents) + find_numbers(sents))
877
+ # a strong moment only where the patterns found nothing in the same stretch, unless it outranks them
878
+ # (a free offer beats the price said right before it; a list with a guarantee stays a list)
879
+ cands = list(pattern)
880
+ for mo in find_moments(sents):
881
+ rivals = [p for p in cands if p.start - 0.5 <= mo.last and mo.start <= p.last + 0.5]
882
+ if not rivals or (mo.priority >= OUTRANKS and all(mo.priority > p.priority for p in rivals)):
883
+ cands = [p for p in cands if p not in rivals] + [mo]
884
+ marks = load_marks(edit_dir)
885
+ marked, mark_notes = find_marked(sents, marks, detectors)
886
+ notes += mark_notes
887
+ # a marked point replaces what the detectors found in the same sentence
888
+ cands = [c for c in cands if not any(abs(c.start - m.start) < 0.05 or m.start <= c.start <= m.last
889
+ for m in marked)] + marked
890
+ chosen = choose(cands, duration, hook_end, cta_start, gap, skipped)
891
+ if quantidade is not None:
892
+ keep = spread([it for it, _ in chosen], [p for _, p in chosen], duration, hook_end, quantidade)
893
+ for k, (it, _) in enumerate(chosen):
894
+ if k not in keep:
895
+ skipped.append({"time": it["start"], "quote": it["note"][:80], "template": it["template"],
896
+ "why": f"sobrou: você pediu {quantidade} animações e as outras são mais fortes"})
897
+ chosen = [chosen[k] for k in keep]
898
+ motion = [it for it, _ in chosen]
899
+ if tema:
900
+ for it in motion:
901
+ it["params"].setdefault("tema", tema)
902
+ return {"edit_dir": str(edit_dir), "duration": round(duration, 3), "hook_end": round(hook_end, 3),
903
+ "cta_start": round(cta_start, 3) if cta_start is not None else None, "edit_data": facts["edit_data"],
904
+ "marks": [{"time": t, "note": n} for t, n in marks], "motion": motion, "skipped": sorted(skipped, key=lambda x: x["time"]), "notes": notes}
905
+
906
+
907
+ def main(argv: list[str] | None = None) -> int:
908
+ for stream in (sys.stdout, sys.stderr):
909
+ try:
910
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
911
+ except (AttributeError, ValueError):
912
+ pass
913
+ ap = argparse.ArgumentParser(description="Sugere animações (motion) pelo que a pessoa fala no corte. Só sugere: "
914
+ "não muda o edit_data.json.")
915
+ ap.add_argument("--edit-dir", required=True, help="pasta edit (com transcripts/cut.json)")
916
+ ap.add_argument("--json", action="store_true", help="só o JSON, sem as linhas de leitura")
917
+ ap.add_argument("--intervalo", type=float, default=MIN_GAP, help=f"segundos entre duas animações (padrão {MIN_GAP:g})")
918
+ ap.add_argument("--quantidade", type=int, default=None,
919
+ help="no máximo N animações, as mais fortes, espalhadas pelo vídeo (os pontos marcados no "
920
+ "preview entram sempre). Padrão: o \"quantidade\" do motion no edit_data, senão todas")
921
+ ap.add_argument("--tema", choices=list(ME.THEME_NAMES), default=None,
922
+ help="visual das animações (padrão: o \"tema\" do motion no edit_data)")
923
+ ap.add_argument("-o", "--saida", help="também grava o JSON neste arquivo")
924
+ args = ap.parse_args(argv)
925
+ try:
926
+ if not 1.0 <= args.intervalo <= 60.0:
927
+ raise SuggestError("--intervalo precisa ficar entre 1 e 60 segundos")
928
+ ed = load_json(Path(args.edit_dir) / "edit_data.json") if (Path(args.edit_dir) / "edit_data.json").is_file() else None
929
+ mcfg = ed.get("motion") if isinstance(ed, dict) and isinstance(ed.get("motion"), dict) else {}
930
+ qtd = args.quantidade if args.quantidade is not None else mcfg.get("quantidade")
931
+ result = suggest(Path(args.edit_dir), args.intervalo, int(qtd) if qtd else None,
932
+ args.tema or mcfg.get("tema"))
933
+ except SuggestError as e:
934
+ print(f"erro: {e}", file=sys.stderr)
935
+ return 2
936
+ if not args.json:
937
+ for note in result["notes"]:
938
+ print(f"aviso: {note}")
939
+ if not result["motion"]:
940
+ print("nenhuma animação sugerida: a fala não tem lista, preço, passo a passo, número, oferta, virada ou "
941
+ "chamada para comentar")
942
+ for k, item in enumerate(result["motion"], start=1):
943
+ print(f"sugestão {k}: {item['template']} de {fmt_time(item['start'])} a {fmt_time(item['end'])} · "
944
+ f"{item['note']}")
945
+ for sk in result["skipped"]:
946
+ print(f"fora: {fmt_time(sk['time'])} \"{sk['quote'][:70]}\" · {sk['why']}")
947
+ print("São só sugestões: confira o texto de cada item, pergunte à pessoa e copie para \"motion\" no "
948
+ "edit_data.json o que ela quiser.")
949
+ text = json.dumps(result, ensure_ascii=False)
950
+ if args.saida:
951
+ out = Path(args.saida).expanduser()
952
+ out.parent.mkdir(parents=True, exist_ok=True)
953
+ out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
954
+ if not args.json:
955
+ print(f"ok: {out}")
956
+ print(text)
957
+ return 0
958
+
959
+
960
+ if __name__ == "__main__":
961
+ sys.exit(main())