primocode 10.16.0 → 10.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) 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 +28 -8
  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 +36 -8
  198. package/lib/catalogo.js +4 -0
  199. package/lib/chamada-torta.js +81 -0
  200. package/lib/editor-pro.js +786 -0
  201. package/lib/entrega.js +58 -0
  202. package/lib/estudio/conferencia-visual.js +76 -0
  203. package/lib/estudio/diretor.js +6 -1
  204. package/lib/estudio/exportar.js +3 -1
  205. package/lib/estudio/gosto.js +45 -4
  206. package/lib/estudio/index.js +8 -5
  207. package/lib/estudio/kit-ae/base.jsx +119 -13
  208. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  209. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  210. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  211. package/lib/estudio/maquina-diretor.js +61 -7
  212. package/lib/estudio/maquina-foto.js +177 -0
  213. package/lib/estudio/maquina-guarda.js +207 -0
  214. package/lib/estudio/maquina-layout.js +22 -1
  215. package/lib/estudio/maquina.js +60 -11
  216. package/lib/estudio/referencias.js +1 -1
  217. package/lib/estudio/visao/visao.swift +97 -0
  218. package/lib/estudio/visao.js +60 -0
  219. package/lib/local/cerebro.json +192 -6
  220. package/lib/local/motor.js +5 -2
  221. package/lib/mcp.js +105 -7
  222. package/lib/nuvem.js +11 -1
  223. package/lib/outra-pasta.js +1 -1
  224. package/lib/passagem.js +50 -0
  225. package/lib/perigo.js +29 -12
  226. package/lib/protecao.js +134 -0
  227. package/lib/sandbox.js +11 -4
  228. package/lib/titulo-conversa.js +46 -0
  229. package/lib/tools.js +3 -0
  230. package/lib/ui.js +1 -1
  231. package/package.json +3 -2
  232. package/skills/editar-meu-video/SKILL.md +6 -2
  233. package/skills/editar-video/SKILL.md +113 -0
  234. package/studio/pessoa.py +52 -0
  235. package/voz/primo_voz/app.py +43 -4
  236. package/voz/primo_voz/chat.html +17 -6
  237. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,734 @@
1
+ # Adapted from browser-use/video-use (MIT, (c) 2026 Browser Use). See THIRD_PARTY_NOTICES.md.
2
+ """Word-level local transcription of one video (Contract 1).
3
+
4
+ Pipeline:
5
+ 1. Decode the audio to 16 kHz mono (a mute or phase-cancelled channel is left out).
6
+ 2. Measure silences with the same auto threshold as speech_regions.py.
7
+ 3. Long silences (>= 2 s) are shortened to 1 s before Whisper sees the audio, so it
8
+ never gets a window of pure room noise (that is where it invents text). Times are
9
+ mapped back to the original file.
10
+ 4. Whisper in chunks of <= 28 s cut at pauses (spec cap: 300 s), each with the prompt:
11
+ mlx-whisper on Apple Silicon, faster-whisper (int8, CPU) elsewhere,
12
+ condition_on_previous_text=False and a short PT-BR prompt with fillers.
13
+ 5. Hallucination guards: drop words that lie entirely inside a measured silence >= 1 s,
14
+ repeated n-gram loops, and known subtitle-credit phrases sitting on silence.
15
+ 6. Word edges that fall inside a measured silence are moved to the speech edge
16
+ (Whisper tends to start the first word after a pause too early).
17
+ 7. Too few words for the measured voice (under POUCA_FALA_WPS words per second of speech) is
18
+ reported: on a clip copied out of a longer video that starts mid-sentence, Whisper returned the
19
+ fillers of the prompt ("E aí, olha só, sabe?") instead of 15 s of speech (29/09/2026).
20
+
21
+ A file that is only in iCloud (macOS placeholder) is downloaded first or refused with a message:
22
+ ffmpeg hangs on it without any error (helpers/icloud.py).
23
+
24
+ Output: <edit_dir>/transcripts/<stem>.json, cached by source signature. Also writes
25
+ <edit_dir>/regions/<stem>.json (auto threshold, 0.25 s) when it is missing or stale.
26
+
27
+ Usage:
28
+ uv run python helpers/transcribe.py <video> [--edit-dir DIR] [--language pt]
29
+ [--model large-v3-turbo] [--force] [--prompt "Kiwify, Hubla"] [--no-prompt]
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import bisect
36
+ import os
37
+ import platform
38
+ import re
39
+ import sys
40
+ import time
41
+ import unicodedata
42
+ from pathlib import Path
43
+
44
+ import numpy as np
45
+
46
+ from common import file_signature, load_json, save_json
47
+ from icloud import SoNaNuvem, garantir_local
48
+ from speech_regions import (
49
+ SAMPLE_RATE,
50
+ AudioError,
51
+ complement,
52
+ detect_silences,
53
+ estimate_noise,
54
+ load_mono,
55
+ probe_media,
56
+ regions_path,
57
+ window_levels,
58
+ )
59
+
60
+ DEFAULT_MODEL = "large-v3-turbo"
61
+ # Nudges Whisper toward verbatim PT-BR with punctuation. Tested on the sample clips:
62
+ # identical words with and without it, better punctuation, no leak into silence.
63
+ DEFAULT_PROMPT = "Ééé, hum, tipo, né, então... Aí, olha só, sabe?"
64
+ MIN_SILENCE_S = 0.25 # same default as speech_regions.py
65
+ LONG_GAP_S = 2.0 # silences at least this long get shortened before Whisper
66
+ KEEP_GAP_S = 0.5 # silence kept on each side of a shortened gap
67
+ CHUNK_TARGET_S = 28.0 # one Whisper window per chunk (spec cap is 300 s)
68
+ CHUNK_MIN_S = 12.0
69
+ GUARD_SILENCE_S = 1.0 # words entirely inside a silence this long are dropped
70
+ EDGE_EPS = 0.02
71
+ POUCA_FALA_WPS = 1.0 # fewer words than this per second of measured voice: Whisper likely returned the prompt
72
+ POUCA_FALA_MIN_S = 4.0 # ... only judged with at least this much voice
73
+
74
+ MLX_REPOS = {
75
+ "large-v3-turbo": "mlx-community/whisper-large-v3-turbo",
76
+ "turbo": "mlx-community/whisper-large-v3-turbo",
77
+ "large-v3": "mlx-community/whisper-large-v3-mlx",
78
+ "medium": "mlx-community/whisper-medium-mlx",
79
+ "small": "mlx-community/whisper-small-mlx",
80
+ "base": "mlx-community/whisper-base-mlx",
81
+ "tiny": "mlx-community/whisper-tiny-mlx",
82
+ }
83
+
84
+ # Whisper's classic inventions on silence (subtitle credits, YouTube outros).
85
+ KNOWN_HALLUCINATIONS = (
86
+ "legendas pela comunidade amara org",
87
+ "legendas pela comunidade amaraorg",
88
+ "legenda adriana zanotto",
89
+ "legendas adriana zanotto",
90
+ "transcricao e legendas",
91
+ "obrigado por assistir",
92
+ "obrigada por assistir",
93
+ "inscreva se no canal",
94
+ "se inscreva no canal",
95
+ "ate o proximo video",
96
+ "ate a proxima",
97
+ "tchau tchau",
98
+ "e ai",
99
+ "subtitles by the amara org community",
100
+ "thanks for watching",
101
+ )
102
+
103
+ VIDEO_EXTS = (".mov", ".mp4", ".m4v", ".mkv", ".webm")
104
+
105
+
106
+ class EngineError(RuntimeError):
107
+ pass
108
+
109
+
110
+ class NameClashError(RuntimeError):
111
+ """Two different videos would share transcripts/<stem>.json (video.mov + video.mp4)."""
112
+
113
+
114
+ def same_video(a: Path, b: Path) -> bool:
115
+ """Same file, or a copy of it (a symlink, or a copied folder: same size)."""
116
+ try:
117
+ return os.path.samefile(a, b) or os.stat(a).st_size == os.stat(b).st_size
118
+ except OSError:
119
+ return False
120
+
121
+
122
+ def check_name_clash(out_path: Path, video: Path) -> None:
123
+ """Refuse to overwrite the transcript of ANOTHER video that has the same name without extension."""
124
+ data = load_json(out_path) if out_path.exists() else None
125
+ if not isinstance(data, dict) or not data.get("source"):
126
+ return
127
+ other = Path(data["source"])
128
+ if other == video or not other.exists() or same_video(other, video):
129
+ return
130
+ raise NameClashError(
131
+ f"{video.name} e {other.name} têm o mesmo nome sem a extensão e a transcrição de um apagaria a do outro "
132
+ f"({out_path.name}). Renomeie um deles (ex.: {video.stem}_2{video.suffix}) e rode de novo.")
133
+
134
+
135
+ # ---------------------------------------------------------------- engines
136
+
137
+ def default_engine() -> str:
138
+ return "mlx" if sys.platform == "darwin" and platform.machine() == "arm64" else "faster-whisper"
139
+
140
+
141
+ def engine_label(engine: str, model: str) -> str:
142
+ name = Path(model).name if ("/" in model or os.sep in model) else model
143
+ return f"{'mlx-whisper' if engine == 'mlx' else 'faster-whisper'}/{name}"
144
+
145
+
146
+ def _mlx_model_path(model: str) -> str:
147
+ if Path(model).exists():
148
+ return str(model)
149
+ repo = MLX_REPOS.get(model, model)
150
+ try:
151
+ from huggingface_hub import snapshot_download
152
+
153
+ return snapshot_download(repo, local_files_only=True)
154
+ except Exception:
155
+ print(f"baixando o modelo {repo} (só na primeira vez)...", file=sys.stderr, flush=True)
156
+ return repo
157
+
158
+
159
+ def _run_mlx(audio: np.ndarray, model: str, language: str | None, prompt: str | None) -> tuple[list[dict], str | None]:
160
+ try:
161
+ import mlx_whisper
162
+ except ImportError as exc:
163
+ raise EngineError("mlx-whisper não está instalado. Rode: uv sync") from exc
164
+ result = mlx_whisper.transcribe(
165
+ audio,
166
+ path_or_hf_repo=_mlx_model_path(model),
167
+ language=language,
168
+ word_timestamps=True,
169
+ condition_on_previous_text=False,
170
+ initial_prompt=prompt,
171
+ verbose=None,
172
+ )
173
+ segments = []
174
+ for seg in result.get("segments", []):
175
+ words = [{"word": w.get("word", ""), "start": float(w["start"]), "end": float(w["end"]),
176
+ "probability": float(w.get("probability", 0.0))} for w in seg.get("words", [])]
177
+ segments.append({"start": float(seg["start"]), "end": float(seg["end"]),
178
+ "text": seg.get("text", ""), "words": words})
179
+ return segments, result.get("language")
180
+
181
+
182
+ _FASTER_MODELS: dict[str, object] = {}
183
+
184
+
185
+ def _run_faster(audio: np.ndarray, model: str, language: str | None, prompt: str | None) -> tuple[list[dict], str | None]:
186
+ try:
187
+ from faster_whisper import WhisperModel
188
+ except ImportError as exc:
189
+ raise EngineError("faster-whisper não está instalado. Rode: uv sync") from exc
190
+ if model not in _FASTER_MODELS:
191
+ _FASTER_MODELS[model] = WhisperModel(model, device="cpu", compute_type="int8",
192
+ cpu_threads=os.cpu_count() or 4)
193
+ fw = _FASTER_MODELS[model]
194
+ seg_iter, info = fw.transcribe(
195
+ audio,
196
+ language=language,
197
+ word_timestamps=True,
198
+ condition_on_previous_text=False,
199
+ initial_prompt=prompt,
200
+ beam_size=5,
201
+ vad_filter=False,
202
+ )
203
+ segments = []
204
+ for seg in seg_iter: # lazy generator: the work happens while iterating
205
+ words = [{"word": w.word, "start": float(w.start), "end": float(w.end),
206
+ "probability": float(w.probability)} for w in (seg.words or [])]
207
+ segments.append({"start": float(seg.start), "end": float(seg.end), "text": seg.text, "words": words})
208
+ return segments, getattr(info, "language", None)
209
+
210
+
211
+ def run_whisper(audio: np.ndarray, engine: str, model: str, language: str | None,
212
+ prompt: str | None) -> tuple[list[dict], str | None]:
213
+ if engine == "mlx":
214
+ return _run_mlx(audio, model, language, prompt)
215
+ return _run_faster(audio, model, language, prompt)
216
+
217
+
218
+ # ---------------------------------------------------------------- long-gap compaction
219
+
220
+ class TimeMap:
221
+ """Kept pieces of the original timeline, laid end to end in the compacted audio."""
222
+
223
+ def __init__(self, pieces: list[tuple[float, float]]):
224
+ self.orig = pieces
225
+ self.comp_starts: list[float] = []
226
+ t = 0.0
227
+ for s, e in pieces:
228
+ self.comp_starts.append(t)
229
+ t += e - s
230
+ self.length = t
231
+
232
+ def to_orig(self, t: float, side: str = "start") -> float:
233
+ if not self.orig:
234
+ return t
235
+ if side == "start":
236
+ i = bisect.bisect_right(self.comp_starts, t) - 1
237
+ else:
238
+ i = bisect.bisect_left(self.comp_starts, t) - 1
239
+ i = min(max(i, 0), len(self.orig) - 1)
240
+ s, e = self.orig[i]
241
+ return min(max(s + (t - self.comp_starts[i]), s), e)
242
+
243
+ def span_to_orig(self, cs: float, ce: float) -> tuple[float, float]:
244
+ """Map a word. A word that straddles a join (Whisper pulled its start back across a
245
+ removed gap) is kept inside the piece that holds most of it, ties going to the later one."""
246
+ if not self.orig:
247
+ return cs, ce
248
+ i = min(max(bisect.bisect_right(self.comp_starts, cs) - 1, 0), len(self.orig) - 1)
249
+ j = min(max(bisect.bisect_left(self.comp_starts, ce) - 1, 0), len(self.orig) - 1)
250
+ if j <= i:
251
+ return self.to_orig(cs, "start"), self.to_orig(ce, "end")
252
+ best, best_len = i, -1.0
253
+ for k in range(i, j + 1):
254
+ k0 = self.comp_starts[k]
255
+ k1 = k0 + (self.orig[k][1] - self.orig[k][0])
256
+ part = min(ce, k1) - max(cs, k0)
257
+ if part >= best_len:
258
+ best, best_len = k, part
259
+ k0 = self.comp_starts[best]
260
+ s, e = self.orig[best]
261
+ a = s + max(cs - k0, 0.0)
262
+ b = s + min(ce - k0, e - s)
263
+ return min(a, e), max(min(b, e), min(a, e))
264
+
265
+
266
+ def plan_pieces(silences: list[list[float]], duration: float) -> list[tuple[float, float]]:
267
+ removed = []
268
+ for s, e in silences:
269
+ if e - s < LONG_GAP_S:
270
+ continue
271
+ rs = 0.0 if s <= EDGE_EPS else s + KEEP_GAP_S
272
+ re_ = duration if e >= duration - EDGE_EPS else e - KEEP_GAP_S
273
+ if re_ - rs > 0.05:
274
+ removed.append([rs, re_])
275
+ return [(s, e) for s, e in complement(removed, 0.0, duration) if e - s > 0.01]
276
+
277
+
278
+ def plan_chunks(audio: np.ndarray, sr: int = SAMPLE_RATE, max_s: float = CHUNK_TARGET_S,
279
+ min_s: float = CHUNK_MIN_S) -> list[tuple[int, int]]:
280
+ """Sample ranges of <= max_s, each cut at a quiet point (a pause) after at least min_s.
281
+
282
+ Chunks fit one Whisper window, so the prompt applies to all of them: with
283
+ condition_on_previous_text=False Whisper only uses the prompt on the first window
284
+ of each call, and later windows came out without punctuation.
285
+ """
286
+ total = len(audio)
287
+ max_n = int(max_s * sr)
288
+ if total <= max_n:
289
+ return [(0, total)]
290
+ win = int(0.05 * sr)
291
+ rms_db, _ = window_levels(audio, sr)
292
+ smooth = np.convolve(rms_db, np.ones(5) / 5, mode="same") if len(rms_db) >= 5 else rms_db
293
+ bounds, start = [], 0
294
+ while total - start > max_n:
295
+ lo_w = (start + int(min_s * sr)) // win
296
+ hi_w = min((start + max_n) // win, len(smooth))
297
+ seg = smooth[lo_w:hi_w]
298
+ if len(seg) == 0:
299
+ cut = start + max_n
300
+ else:
301
+ quiet = np.where(seg <= seg.min() + 1.5)[0]
302
+ cut = (lo_w + int(quiet[-1])) * win + win // 2
303
+ cut = min(max(cut, start + win), total)
304
+ bounds.append((start, cut))
305
+ start = cut
306
+ if total > start:
307
+ if bounds and total - start < 3 * sr and total - bounds[-1][0] <= 30 * sr:
308
+ bounds[-1] = (bounds[-1][0], total)
309
+ else:
310
+ bounds.append((start, total))
311
+ return bounds
312
+
313
+
314
+ # ---------------------------------------------------------------- word cleanup
315
+
316
+ def _norm(text: str) -> str:
317
+ t = unicodedata.normalize("NFKD", text.lower())
318
+ t = "".join(c for c in t if not unicodedata.combining(c))
319
+ return re.sub(r"[^\w]+", "", t)
320
+
321
+
322
+ def _has_letters(text: str) -> bool:
323
+ return any(c.isalnum() for c in text)
324
+
325
+
326
+ def collect_words(segments: list[dict], offset: float, tmap: TimeMap, seg_base: int) -> list[dict]:
327
+ words: list[dict] = []
328
+ for si, seg in enumerate(segments):
329
+ first_in_seg = True
330
+ for w in seg["words"]:
331
+ raw = w.get("word") or ""
332
+ text = raw.strip()
333
+ if not text:
334
+ continue
335
+ s, e = tmap.span_to_orig(offset + w["start"], offset + max(w["end"], w["start"]))
336
+ same_seg = bool(words) and words[-1]["seg"] == seg_base + si
337
+ # Punctuation alone, or a piece with no leading space ("20" + ".8."), belongs to the previous word.
338
+ if same_seg and (not _has_letters(text) or (not first_in_seg and not raw[:1].isspace())):
339
+ prev = words[-1]
340
+ prev["text"] += text
341
+ prev["end"] = max(prev["end"], e)
342
+ prev["prob"] = min(prev["prob"], w["probability"])
343
+ continue
344
+ first_in_seg = False
345
+ if not _has_letters(text):
346
+ continue
347
+ words.append({"text": text, "start": s, "end": max(e, s), "prob": w["probability"], "seg": seg_base + si})
348
+ return words
349
+
350
+
351
+ def _inside(w: dict, s: float, e: float) -> bool:
352
+ return w["start"] >= s - EDGE_EPS and w["end"] <= e + EDGE_EPS
353
+
354
+
355
+ def silence_guard(words: list[dict], silences: list[list[float]]) -> tuple[list[dict], int]:
356
+ long_sil = [x for x in silences if x[1] - x[0] >= GUARD_SILENCE_S]
357
+ if not long_sil:
358
+ return words, 0
359
+ starts = [x[0] for x in long_sil]
360
+ kept = []
361
+ for w in words:
362
+ i = bisect.bisect_right(starts, w["start"] + EDGE_EPS) - 1
363
+ if i >= 0 and _inside(w, *long_sil[i]):
364
+ continue
365
+ kept.append(w)
366
+ return kept, len(words) - len(kept)
367
+
368
+
369
+ def _speech_fraction(s: float, e: float, silences: list[list[float]]) -> float:
370
+ if e - s <= 0:
371
+ mid = s
372
+ return 0.0 if any(a <= mid <= b for a, b in silences) else 1.0
373
+ sil = sum(max(0.0, min(e, b) - max(s, a)) for a, b in silences)
374
+ return max(0.0, 1.0 - sil / (e - s))
375
+
376
+
377
+ def loop_guard(words: list[dict], silences: list[list[float]]) -> tuple[list[dict], int]:
378
+ """Drop repeated n-gram loops (Whisper stuck repeating itself).
379
+
380
+ A run needs 5+ copies of one word, 4+ copies of 2-3 words or 3+ copies of longer
381
+ n-grams. Copies after the first are dropped only when they look invented: low
382
+ confidence, squeezed timing, or sitting mostly on silence. A real retake said three
383
+ times keeps all its copies.
384
+ """
385
+ toks = [_norm(w["text"]) for w in words]
386
+ drop = [False] * len(words)
387
+ # The same word twice on top of itself in time (a segment emitted twice) is never real speech.
388
+ for i in range(1, len(words)):
389
+ for j in range(max(0, i - 8), i):
390
+ if not drop[j] and toks[i] and toks[i] == toks[j] and words[i]["start"] < words[j]["end"] - 0.05:
391
+ drop[i] = True
392
+ break
393
+ words = [w for w, d in zip(words, drop) if not d]
394
+ n_overlap = sum(drop)
395
+ toks = [_norm(w["text"]) for w in words]
396
+ drop = [False] * len(words)
397
+ i, n_words = 0, len(words)
398
+ while i < n_words:
399
+ found = None
400
+ for n in range(1, 9):
401
+ if i + 2 * n > n_words:
402
+ break
403
+ pat = toks[i:i + n]
404
+ if not all(pat):
405
+ continue
406
+ k = 1
407
+ while toks[i + k * n:i + (k + 1) * n] == pat:
408
+ k += 1
409
+ need = 5 if n == 1 else 4 if n <= 3 else 3
410
+ if k >= need:
411
+ found = (n, k)
412
+ break
413
+ if not found:
414
+ i += 1
415
+ continue
416
+ n, k = found
417
+ for c in range(1, k):
418
+ copy = words[i + c * n:i + (c + 1) * n]
419
+ mean_prob = sum(w["prob"] for w in copy) / n
420
+ mean_dur = sum(w["end"] - w["start"] for w in copy) / n
421
+ speech = _speech_fraction(copy[0]["start"], copy[-1]["end"], silences)
422
+ if mean_prob < 0.6 or mean_dur < 0.08 or speech < 0.5:
423
+ for j in range(i + c * n, i + (c + 1) * n):
424
+ drop[j] = True
425
+ i += k * n
426
+ kept = [w for w, d in zip(words, drop) if not d]
427
+ return kept, len(words) - len(kept) + n_overlap
428
+
429
+
430
+ def known_phrase_guard(words: list[dict], silences: list[list[float]]) -> tuple[list[dict], int]:
431
+ """Drop whole segments that are a known subtitle-credit phrase sitting mostly on silence.
432
+
433
+ A creator can really say "obrigado por assistir", so the text alone is never enough.
434
+ """
435
+ by_seg: dict[int, list[int]] = {}
436
+ for idx, w in enumerate(words):
437
+ by_seg.setdefault(w["seg"], []).append(idx)
438
+ drop: set[int] = set()
439
+ for idxs in by_seg.values():
440
+ seg_words = [words[i] for i in idxs]
441
+ text = " ".join(_norm(w["text"]) for w in seg_words).strip()
442
+ if text not in KNOWN_HALLUCINATIONS:
443
+ continue
444
+ if _speech_fraction(seg_words[0]["start"], seg_words[-1]["end"], silences) < 0.5:
445
+ drop.update(idxs)
446
+ kept = [w for i, w in enumerate(words) if i not in drop]
447
+ return kept, len(words) - len(kept)
448
+
449
+
450
+ _FINAL_PUNCT = re.compile(r"[.!?…]$") # sentence end or ellipsis (not commas)
451
+ EDGE_TAIL_S = 0.25 # a speech island this short next to a sentence end is a neighbour's tail
452
+
453
+
454
+ def refine_edges(words: list[dict], silences: list[list[float]]) -> int:
455
+ """Move word edges that fall inside a measured silence onto speech. In place.
456
+
457
+ The part of the word outside the pauses is split into speech islands:
458
+ - one island (the word starts or ends inside a pause): the word becomes that island;
459
+ - several islands (a pause inside the word): the longest island wins, after dropping a
460
+ short first island when the previous word ends a sentence (it is that word's tail) and
461
+ a short last island when this word ends a sentence or has "..." (the next word's head);
462
+ - no island (entirely inside a short pause): snap after the pause, or before it when the
463
+ word ends a sentence and the previous one does not.
464
+ """
465
+ if not silences or not words:
466
+ return 0
467
+ starts = [s for s, _ in silences]
468
+ changed = 0
469
+ for idx, w in enumerate(words):
470
+ ws, we = w["start"], w["end"]
471
+ j = bisect.bisect_right(starts, we) - 1
472
+ hits = []
473
+ while j >= 0 and silences[j][1] > ws:
474
+ if silences[j][0] < we:
475
+ hits.append(silences[j])
476
+ j -= 1
477
+ if not hits:
478
+ continue
479
+ hits.sort()
480
+ dur = we - ws
481
+ own_final = bool(_FINAL_PUNCT.search(w["text"]))
482
+ prev_final = idx > 0 and bool(_FINAL_PUNCT.search(words[idx - 1]["text"]))
483
+ islands = [i for i in complement([[max(a, ws), min(b, we)] for a, b in hits], ws, we) if i[1] - i[0] > 1e-3]
484
+ if not islands:
485
+ ss, se = hits[0]
486
+ if own_final and not prev_final:
487
+ ns, ne = max(ss - min(dur, 0.3), 0.0), ss
488
+ else:
489
+ ns, ne = se, se + min(dur, 0.3)
490
+ else:
491
+ cands = islands
492
+ if len(cands) > 1 and prev_final and cands[0][1] - cands[0][0] <= EDGE_TAIL_S:
493
+ cands = cands[1:]
494
+ if len(cands) > 1 and own_final and cands[-1][1] - cands[-1][0] <= EDGE_TAIL_S:
495
+ cands = cands[:-1]
496
+ ns, ne = max(cands, key=lambda i: (round(i[1] - i[0], 3), i[0]))
497
+ if ne - ns < 0.02:
498
+ ne = ns + min(max(dur, 0.02), 0.08)
499
+ if abs(ns - ws) > 1e-6 or abs(ne - we) > 1e-6:
500
+ w["start"], w["end"] = ns, ne
501
+ changed += 1
502
+ words.sort(key=lambda x: x["start"])
503
+ for a, b in zip(words, words[1:]):
504
+ if b["start"] < a["end"]:
505
+ a["end"] = max(a["start"], b["start"])
506
+ return changed
507
+
508
+
509
+ def build_segments(words: list[dict]) -> list[dict]:
510
+ groups: dict[int, list[dict]] = {}
511
+ for w in words:
512
+ groups.setdefault(w["seg"], []).append(w)
513
+ segs = []
514
+ for ws in groups.values():
515
+ text = " ".join(w["text"] for w in ws)
516
+ segs.append({"start": round(ws[0]["start"], 3), "end": round(max(w["end"] for w in ws), 3), "text": text})
517
+ segs.sort(key=lambda s: s["start"])
518
+ return segs
519
+
520
+
521
+ def aviso_pouca_fala(words: list[dict], speech_s: float, prompt: str | None) -> str | None:
522
+ """Warning when the transcript has far fewer words than the measured voice asks for.
523
+
524
+ Normal speech is 2 to 3 words per second. Under POUCA_FALA_WPS, Whisper most likely returned the prompt
525
+ (its fillers) or skipped a stretch: seen on a clip that started in the middle of a sentence.
526
+ """
527
+ if speech_s < POUCA_FALA_MIN_S:
528
+ return None
529
+ n = len(words)
530
+ wps = n / speech_s
531
+ if wps >= POUCA_FALA_WPS:
532
+ return None
533
+ vocab = {_norm(x) for x in re.findall(r"\w+", f"{DEFAULT_PROMPT} {prompt or ''}")} - {""}
534
+ eco = [w["text"] for w in words if _norm(w["text"]) in vocab]
535
+ msg = (f"só {n} palavra(s) para {speech_s:.0f} s de voz medida ({wps:.1f} por segundo; fala normal dá 2 a 3). ")
536
+ if n and len(eco) * 2 >= n:
537
+ msg += f"Quase tudo é o texto do prompt (\"{' '.join(eco[:8])}\"): o Whisper devolveu o prompt, não a fala. "
538
+ msg += ("Se o arquivo é um trecho copiado de um vídeo maior, copie de novo começando numa pausa (antes da "
539
+ "primeira palavra de uma frase) e transcreva de novo; senão rode com --no-prompt --force. Se o "
540
+ "trecho só tem música ou ruído, pode ignorar.")
541
+ return msg
542
+
543
+
544
+ # ---------------------------------------------------------------- main entry
545
+
546
+ def transcript_path(edit_dir: Path | str, video: Path | str) -> Path:
547
+ return Path(edit_dir) / "transcripts" / f"{Path(video).stem}.json"
548
+
549
+
550
+ def build_prompt(extra: str | None, use_default: bool) -> str | None:
551
+ parts = []
552
+ if use_default:
553
+ parts.append(DEFAULT_PROMPT)
554
+ if extra and extra.strip():
555
+ parts.append(extra.strip())
556
+ return " ".join(parts) if parts else None
557
+
558
+
559
+ def is_cached(out_path: Path, video: Path, engine: str, model: str, language: str | None, prompt: str | None) -> bool:
560
+ data = load_json(out_path)
561
+ if not isinstance(data, dict):
562
+ return False
563
+ if data.get("source_sig") != file_signature(video):
564
+ return False
565
+ if data.get("engine") != engine_label(engine, model):
566
+ return False
567
+ if language and data.get("language") != language:
568
+ return False
569
+ return data.get("prompt", DEFAULT_PROMPT) == prompt
570
+
571
+
572
+ def transcribe_one(video: Path | str, edit_dir: Path | str | None = None, language: str | None = "pt",
573
+ model: str = DEFAULT_MODEL, force: bool = False, prompt: str | None = DEFAULT_PROMPT,
574
+ engine: str | None = None, verbose: bool = True) -> Path:
575
+ """Transcribe one video. Returns the transcript path (cached when the source did not change)."""
576
+ video = Path(video).absolute()
577
+ if not video.exists():
578
+ raise FileNotFoundError(f"vídeo não encontrado: {video}")
579
+ edit_dir = Path(edit_dir).absolute() if edit_dir else video.parent / "edit"
580
+ engine = engine or default_engine()
581
+ language = None if (language or "").lower() in ("", "auto") else language
582
+ out_path = transcript_path(edit_dir, video)
583
+ check_name_clash(out_path, video)
584
+
585
+ if not force and is_cached(out_path, video, engine, model, language, prompt):
586
+ if verbose:
587
+ data = load_json(out_path)
588
+ print(f"{video.stem}: em cache ({len(data.get('words', []))} palavras) · {out_path}")
589
+ if data.get("aviso"):
590
+ print(f"{video.stem}: AVISO: {data['aviso']}", flush=True)
591
+ return out_path
592
+
593
+ garantir_local([video], avisar=(print if verbose else (lambda *_: None)))
594
+ t0 = time.time()
595
+ info = probe_media(video)
596
+ duration = float(info.get("duration") or 0.0)
597
+ mono, audio_desc = load_mono(video)
598
+ if duration <= 0:
599
+ duration = len(mono) / SAMPLE_RATE
600
+ if verbose and audio_desc.get("reason"):
601
+ side = {"left": "esquerdo", "right": "direito"}.get(audio_desc["used"], audio_desc["used"])
602
+ print(f"{video.stem}: usando só o canal {side} ({audio_desc['reason']})", flush=True)
603
+
604
+ est = estimate_noise(mono)
605
+ audio_len = len(mono) / SAMPLE_RATE
606
+ silences = [[min(a, duration), min(b, duration)] for a, b in detect_silences(mono, est["threshold_db"], MIN_SILENCE_S)]
607
+ silences = [x for x in silences if x[1] > x[0]]
608
+ if silences and silences[-1][1] >= min(audio_len, duration) - EDGE_EPS:
609
+ silences[-1][1] = duration
610
+ t_audio = time.time() - t0
611
+
612
+ pieces = plan_pieces(silences, audio_len)
613
+ tmap = TimeMap(pieces)
614
+ comp = np.concatenate([mono[int(s * SAMPLE_RATE):int(e * SAMPLE_RATE)] for s, e in pieces]) if pieces else np.zeros(0, np.float32)
615
+ comp = np.ascontiguousarray(comp, dtype=np.float32)
616
+
617
+ t1 = time.time()
618
+ raw_words: list[dict] = []
619
+ detected_lang = language
620
+ seg_base = 0
621
+ speech_total = sum(e - s for s, e in complement(silences, 0.0, audio_len))
622
+ if len(comp) >= int(0.2 * SAMPLE_RATE) and speech_total >= 0.1:
623
+ for a, b in plan_chunks(comp):
624
+ chunk = comp[a:b]
625
+ if len(chunk) < int(0.2 * SAMPLE_RATE):
626
+ continue
627
+ segments, lang = run_whisper(chunk, engine, model, language, prompt)
628
+ detected_lang = detected_lang or lang
629
+ raw_words.extend(collect_words(segments, a / SAMPLE_RATE, tmap, seg_base))
630
+ seg_base += len(segments)
631
+ t_whisper = time.time() - t1
632
+
633
+ words = sorted(raw_words, key=lambda w: w["start"])
634
+ words, n_sil = silence_guard(words, silences)
635
+ words, n_loop = loop_guard(words, silences)
636
+ words, n_known = known_phrase_guard(words, silences)
637
+ n_refined = refine_edges(words, silences)
638
+
639
+ for w in words:
640
+ w["start"] = round(min(max(w["start"], 0.0), duration), 3)
641
+ w["end"] = round(min(max(w["end"], w["start"]), duration), 3)
642
+ segments = build_segments(words)
643
+ out_words = [{"text": w["text"], "start": w["start"], "end": w["end"], "prob": round(w["prob"], 3)} for w in words]
644
+ aviso = aviso_pouca_fala(out_words, speech_total, prompt)
645
+
646
+ elapsed = time.time() - t0
647
+ doc = {
648
+ "source": str(video),
649
+ "source_sig": file_signature(video),
650
+ "stem": video.stem,
651
+ "duration": round(duration, 3),
652
+ "language": detected_lang or language or "",
653
+ "engine": engine_label(engine, model),
654
+ "words": out_words,
655
+ "segments": segments,
656
+ "prompt": prompt,
657
+ "audio": {**audio_desc, "noise_db": est["threshold_db"], "shortened_gaps": len(pieces) - 1 if pieces else 0},
658
+ "guard": {"silence": n_sil, "loop": n_loop, "known_phrase": n_known, "refined_edges": n_refined},
659
+ "timing": {"audio_s": round(t_audio, 2), "whisper_s": round(t_whisper, 2), "total_s": round(elapsed, 2)},
660
+ "voz_s": round(speech_total, 2),
661
+ }
662
+ if aviso:
663
+ doc["aviso"] = aviso
664
+ save_json(out_path, doc)
665
+ _write_regions_if_stale(edit_dir, video, duration, est, silences, audio_desc)
666
+
667
+ if verbose:
668
+ per_min = elapsed / (duration / 60.0) if duration > 0 else 0.0
669
+ removed = n_sil + n_loop + n_known
670
+ extra = f" · {removed} palavras inventadas removidas" if removed else ""
671
+ print(f"{video.stem}: {len(out_words)} palavras, {duration:.1f}s de áudio, {elapsed:.1f}s "
672
+ f"({per_min:.1f}s por minuto){extra} · {out_path}", flush=True)
673
+ if aviso:
674
+ print(f"{video.stem}: AVISO: {aviso}", flush=True)
675
+ return out_path
676
+
677
+
678
+ def _write_regions_if_stale(edit_dir: Path, video: Path, duration: float, est: dict,
679
+ silences: list[list[float]], audio_desc: dict) -> None:
680
+ path = regions_path(edit_dir, video.stem)
681
+ cached = load_json(path)
682
+ if cached and cached.get("source_sig") == file_signature(video):
683
+ return
684
+ r = lambda x: round(float(x), 3) # noqa: E731
685
+ save_json(path, {
686
+ "source": str(video),
687
+ "source_sig": file_signature(video),
688
+ "stem": video.stem,
689
+ "duration": r(duration),
690
+ "noise_db": est["threshold_db"],
691
+ "noise_mode": "auto",
692
+ "min_silence": MIN_SILENCE_S,
693
+ "speech": [[r(s), r(e)] for s, e in complement(silences, 0.0, duration)],
694
+ "silences": [[r(s), r(e)] for s, e in silences],
695
+ "audio": audio_desc,
696
+ "noise_estimate": est,
697
+ })
698
+
699
+
700
+ def main() -> None:
701
+ ap = argparse.ArgumentParser(description="Transcreve um vídeo palavra por palavra, localmente (Whisper).")
702
+ ap.add_argument("video", type=Path, help="arquivo de vídeo")
703
+ ap.add_argument("--edit-dir", type=Path, default=None, help="pasta edit (padrão: <pasta do vídeo>/edit)")
704
+ ap.add_argument("--language", default="pt", help="idioma falado (padrão pt; 'auto' detecta)")
705
+ ap.add_argument("--model", default=DEFAULT_MODEL, help="modelo Whisper (padrão large-v3-turbo)")
706
+ ap.add_argument("--force", action="store_true", help="transcreve de novo mesmo com cache")
707
+ ap.add_argument("--prompt", default=None,
708
+ help="palavras que aparecem no vídeo, para o Whisper acertar a grafia (ex.: \"Kiwify, Hubla\")")
709
+ ap.add_argument("--no-prompt", action="store_true", help="não usa o prompt padrão com vícios de fala")
710
+ ap.add_argument("--engine", choices=("auto", "mlx", "faster-whisper"), default="auto",
711
+ help="motor (padrão: mlx no Mac Apple Silicon, faster-whisper nos outros)")
712
+ args = ap.parse_args()
713
+
714
+ video = args.video.absolute()
715
+ if not video.exists():
716
+ sys.exit(f"vídeo não encontrado: {video}")
717
+ if video.is_dir():
718
+ sys.exit(f"isso é uma pasta, não um vídeo: {video}\nPara a pasta inteira use transcribe_batch.py")
719
+ try:
720
+ transcribe_one(
721
+ video,
722
+ edit_dir=args.edit_dir,
723
+ language=args.language,
724
+ model=args.model,
725
+ force=args.force,
726
+ prompt=build_prompt(args.prompt, not args.no_prompt),
727
+ engine=None if args.engine == "auto" else args.engine,
728
+ )
729
+ except (AudioError, EngineError, FileNotFoundError, NameClashError, SoNaNuvem) as exc:
730
+ sys.exit(str(exc))
731
+
732
+
733
+ if __name__ == "__main__":
734
+ main()