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,354 @@
1
+ #!/usr/bin/env python3
2
+ """Mixa a trilha contínua por baixo da voz, com duck (sidechain), no vídeo JÁ pronto (references/som-e-trilha.md).
3
+
4
+ A trilha entra depois do corte seco, por cima de tudo, sem pular nos cortes. O script:
5
+ 1. mede a voz do vídeo (LUFS integrado);
6
+ 2. acerta o ganho da trilha pra ela ficar N dB abaixo da voz (medido na trilha como ela sai, já com o duck);
7
+ 3. abaixa a trilha quando a voz entra (sidechaincompress guiado pela voz) e devolve nas pausas longas;
8
+ 4. opcional: entra num segundo escolhido (--entrada-em) com um respiro mais alto no começo (--respiro);
9
+ 5. fecha com fade no fim e deixa o vídeo em -14 LUFS (loudnorm em 2 passadas, linear); o fade do fim nunca passa de
10
+ um terço do tempo em que a trilha toca (com --entrada-em perto do fim, 1,5 s de fade engoliria a trilha inteira:
11
+ ele diminui sozinho e avisa);
12
+ 6. confere: dB abaixo da voz, quanto o duck abaixou, duração, voz no lugar (desvio em ms), volume final.
13
+
14
+ Uso (<SKILL> é a pasta desta skill; o Python dela tem numpy):
15
+ uv run --directory "<SKILL>" python helpers/mixar_trilha.py --video final.mp4 --trilha musica.wav
16
+ [--abaixo-da-voz 22] [--entrada-em S] [--respiro [SEG]] [--respiro-db 10] [--duck 8] [--fade-fim 1.5]
17
+ [--inicio-trilha 0] [--saida saida.mp4]
18
+
19
+ Pegadinha do ffmpeg 8: o sidechaincompress corta a saída quando uma das entradas acaba antes. Por isso todas as
20
+ entradas passam por apad + atrim na duração exata do vídeo.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import math
27
+ import os
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import tempfile
32
+ from pathlib import Path
33
+
34
+ try:
35
+ import numpy as np
36
+ except ImportError: # pragma: no cover
37
+ sys.exit('Falta o numpy. Rode com o Python da skill: uv run --directory "<SKILL>" python helpers/mixar_trilha.py ...')
38
+
39
+ XFADE_LACO = 1.0 # segundos de crossfade quando a trilha é mais curta que o vídeo e precisa repetir
40
+ FADE_FIM_PADRAO = 1.5 # segundos de fade da trilha no fim
41
+ FADE_FIM_FRACAO = 1 / 3 # o fade do fim ocupa no máximo isso do tempo em que a trilha toca (quando não foi pedido)
42
+
43
+
44
+ def erro(msg: str, code: int = 2):
45
+ print("ERRO: " + msg, file=sys.stderr)
46
+ sys.exit(code)
47
+
48
+
49
+ def run(cmd: list, **kw):
50
+ r = subprocess.run(cmd, capture_output=True, text=True, **kw)
51
+ if r.returncode:
52
+ erro("o ffmpeg falhou:\n" + r.stderr[-2000:])
53
+ return r
54
+
55
+
56
+ def sonda(p: str) -> dict:
57
+ r = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
58
+ "stream=codec_type,duration,sample_rate:format=duration", "-of", "json", p],
59
+ capture_output=True, text=True)
60
+ if r.returncode:
61
+ erro(f"não consegui abrir {p}")
62
+ d = json.loads(r.stdout)
63
+ out = dict(formato=float(d.get("format", {}).get("duration", 0) or 0), video=None, audio=None)
64
+ for s in d.get("streams", []):
65
+ if s.get("codec_type") in ("video", "audio") and out[s["codec_type"]] is None:
66
+ out[s["codec_type"]] = float(s.get("duration") or out["formato"])
67
+ return out
68
+
69
+
70
+ def loudness(p: str, af: str = "") -> dict:
71
+ r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", p, "-vn", "-af",
72
+ af + "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"],
73
+ capture_output=True, text=True).stderr
74
+ try:
75
+ return json.loads(r[r.rindex("{"): r.rindex("}") + 1])
76
+ except ValueError:
77
+ erro(f"não consegui medir o volume de {p}")
78
+
79
+
80
+ def cadeia_loudnorm(wav: str, alvo: float = -14.0, tp: float = -1.0) -> tuple[str, dict]:
81
+ """-14 LUFS em 2 passadas, sempre linear. Se o pico real passaria de -1 dBTP, um limitador com o atraso
82
+ compensado (latency=1, não desloca o áudio) segura só os picos antes."""
83
+ j = loudness(wav)
84
+ ganho = alvo - float(j["input_i"])
85
+ pre = ""
86
+ if float(j["input_tp"]) + ganho > tp - 0.2:
87
+ lim = 10 ** ((tp - ganho - 1.0) / 20)
88
+ pre = f"alimiter=limit={max(0.0625, min(1.0, lim)):.5f}:attack=5:release=60:level=false:latency=1,"
89
+ j = loudness(wav, pre)
90
+ af = (pre + f"loudnorm=I={alvo}:TP={tp}:LRA=11:measured_I={j['input_i']}:measured_TP={j['input_tp']}"
91
+ f":measured_LRA={j['input_lra']}:measured_thresh={j['input_thresh']}:offset={j['target_offset']}"
92
+ ":linear=true:print_format=json,aresample=48000")
93
+ return af, j
94
+
95
+ def finalizar(video: str, wav: str, saida: str) -> tuple[dict, str, dict]:
96
+ """junta a imagem (copiada) com o áudio em -14 LUFS e confere o pico real DEPOIS do AAC: o codificador pode
97
+ passar do teto em até ~2 dB. Se passou de -0,5 dBTP, refaz com o teto mais baixo (um limitador leve só nos
98
+ picos; o volume integrado continua -14 LUFS)."""
99
+ tp = -1.0
100
+ for tentativa in range(2):
101
+ af, j = cadeia_loudnorm(wav, tp=tp)
102
+ r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", video, "-i", wav, "-map", "0:v:0",
103
+ "-map", "1:a:0", "-c:v", "copy", "-af", af, "-c:a", "aac", "-b:a", "256k",
104
+ "-movflags", "+faststart", saida], capture_output=True, text=True)
105
+ if r.returncode:
106
+ erro("o ffmpeg falhou ao gravar o vídeo final:\n" + r.stderr[-1500:])
107
+ tipo = "linear"
108
+ try:
109
+ tipo = json.loads(r.stderr[r.stderr.rindex("{"): r.stderr.rindex("}") + 1]).get("normalization_type",
110
+ "linear")
111
+ except ValueError:
112
+ pass
113
+ fim = loudness(saida)
114
+ if float(fim["input_tp"]) <= -0.5:
115
+ break
116
+ tp = round(tp - (float(fim["input_tp"]) + 0.8), 2)
117
+ return j, tipo, fim
118
+
119
+
120
+
121
+ def audio_mono(p: str, sr: int = 16000) -> "np.ndarray":
122
+ raw = subprocess.run(["ffmpeg", "-v", "error", "-i", p, "-vn", "-ac", "1", "-ar", str(sr), "-f", "f32le",
123
+ "-acodec", "pcm_f32le", "-"], capture_output=True).stdout
124
+ return np.frombuffer(raw, dtype=np.float32)
125
+
126
+
127
+ def desvio_sincronia(ref: str, out: str, janelas: int = 10, sr: int = 16000) -> float:
128
+ """maior deslocamento (ms) do áudio de saída em relação ao de entrada, medido em janelas de 1 s com fala"""
129
+ a, b = audio_mono(ref, sr), audio_mono(out, sr)
130
+ n = min(len(a), len(b))
131
+ w, L = sr, int(0.05 * sr)
132
+ if n < 3 * w:
133
+ return 0.0
134
+ en = [float((a[i:i + w] ** 2).mean()) for i in range(L, n - w - L, w)]
135
+ idx = sorted(range(len(en)), key=lambda k: -en[k])[:janelas]
136
+ pior = 0.0
137
+ for k in idx:
138
+ i = L + k * w
139
+ c = np.correlate(b[i - L:i + w + L], a[i:i + w], mode="valid")
140
+ pior = max(pior, abs((int(c.argmax()) - L) / sr * 1000))
141
+ return round(pior, 2)
142
+
143
+
144
+ def env_db(x: "np.ndarray", sr: int, hop: float) -> "np.ndarray":
145
+ w = max(1, int(sr * hop))
146
+ n = len(x) // w
147
+ return 10 * np.log10((x[: n * w].astype(np.float64).reshape(n, w) ** 2).mean(axis=1) + 1e-12)
148
+
149
+
150
+ def ajustar_fade_fim(tocando: float, fade_fim: float) -> tuple[float, str | None]:
151
+ """(fade do fim que cabe, aviso). A trilha precisa soar no nível certo por um tempo: o fade do fim fica com no
152
+ máximo um terço do tempo em que ela toca. Visto em 29/09/2026: --entrada-em 7,95 num vídeo de 9,3 s deixava
153
+ 1,35 s de trilha, e o fade padrão de 1,5 s engolia tudo."""
154
+ maximo = round(max(0.1, tocando * FADE_FIM_FRACAO), 2)
155
+ if fade_fim <= maximo:
156
+ return fade_fim, None
157
+ return maximo, (f"a trilha toca só {tocando:.2f} s; um fade de {fade_fim:g} s no fim engoliria quase tudo. Usei "
158
+ f"{maximo:.2f} s (um terço). Para outro valor, passe --fade-fim.")
159
+
160
+
161
+ # ------------------------------------------------------------------ filtro
162
+ def cadeia_trilha(dur_trilha: float, inicio_trilha: float, precisa: float, entrada: float, dur_video: float,
163
+ ganho_db: float, respiro: float, respiro_db: float, fade_fim: float) -> tuple[str, int]:
164
+ """filtro da trilha (entrada 1) até o rótulo [mus]: laço se precisar, ganho, respiro, fades, atraso,
165
+ apad+atrim na duração do vídeo. Devolve (filtro, quantas cópias da trilha)."""
166
+ util = dur_trilha - inicio_trilha
167
+ partes = []
168
+ if util >= precisa + 0.05:
169
+ partes.append(f"[1:a]atrim=start={inicio_trilha:.4f}:duration={precisa:.4f},asetpts=PTS-STARTPTS[t0];")
170
+ copias = 1
171
+ else: # repete a trilha com crossfade até cobrir o vídeo
172
+ extra = precisa - util
173
+ copias = 1 + math.ceil(extra / max(1.0, dur_trilha - XFADE_LACO)) + 1
174
+ labs = "".join(f"[c{k}]" for k in range(copias))
175
+ partes.append(f"[1:a]asplit={copias}{labs};")
176
+ partes.append(f"[c0]atrim=start={inicio_trilha:.4f},asetpts=PTS-STARTPTS[x0];")
177
+ for k in range(1, copias):
178
+ partes.append(f"[x{k - 1}][c{k}]acrossfade=d={XFADE_LACO}:c1=tri:c2=tri[x{k}];")
179
+ partes.append(f"[x{copias - 1}]atrim=duration={precisa:.4f},asetpts=PTS-STARTPTS[t0];")
180
+ cad = [f"aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo", f"volume={ganho_db:.3f}dB"]
181
+ if respiro > 0:
182
+ rg = 10 ** (respiro_db / 20)
183
+ rampa = 0.6
184
+ cad.append(f"volume='if(lt(t,{respiro:.3f}),{rg:.5f},if(lt(t,{respiro + rampa:.3f}),"
185
+ f"{rg:.5f}+(1-{rg:.5f})*(t-{respiro:.3f})/{rampa},1))':eval=frame")
186
+ cad.append(f"afade=t=in:st=0:d={0.4 if entrada > 0 else 0.15}")
187
+ if fade_fim > 0:
188
+ cad.append(f"afade=t=out:st={max(0.0, precisa - fade_fim):.4f}:d={min(fade_fim, precisa):.4f}")
189
+ fim = ["apad", f"atrim=end={dur_video:.6f}", "asetpts=PTS-STARTPTS"]
190
+ if entrada > 0:
191
+ # silêncio concatenado antes da música. No ffmpeg 8 o adelay seguido de asetpts zera o atraso
192
+ # e a música entrava no segundo 0 (visto em 27/09/2026 com --entrada-em 20.3).
193
+ partes.append("[t0]" + ",".join(cad) + "[tpre];")
194
+ partes.append(f"anullsrc=r=48000:cl=stereo,atrim=duration={entrada:.4f},"
195
+ "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[sil];")
196
+ partes.append("[sil][tpre]concat=n=2:v=0:a=1," + ",".join(fim) + "[mus];")
197
+ else:
198
+ partes.append("[t0]" + ",".join(cad + fim) + "[mus];")
199
+ return "".join(partes), copias
200
+
201
+
202
+ def filtro_completo(args, dur_video: float, dur_trilha: float, ganho_db: float, limiar: float,
203
+ duck_db: float | None = None) -> str:
204
+ precisa = dur_video - args.entrada_em
205
+ trilha, _ = cadeia_trilha(dur_trilha, args.inicio_trilha, precisa, args.entrada_em, dur_video, ganho_db,
206
+ args.respiro, args.respiro_db, args.fade_fim)
207
+ mix = 1 - 10 ** (-(args.duck if duck_db is None else duck_db) / 20)
208
+ return (trilha +
209
+ "[0:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,"
210
+ f"apad,atrim=end={dur_video:.6f},asetpts=PTS-STARTPTS,asplit=2[voz][sc];"
211
+ f"[mus][sc]sidechaincompress=threshold={limiar:.6f}:ratio=20:attack=15:release=350:knee=2:"
212
+ f"detection=rms:link=maximum:mix={mix:.4f}[duck];"
213
+ "[duck]asplit=2[d1][d2];"
214
+ "[voz][d1]amix=inputs=2:normalize=0:duration=first[mix]")
215
+
216
+
217
+ def renderizar(args, dur_video, dur_trilha, ganho_db, limiar, mix_wav, stem_wav, duck_db=None):
218
+ fc = filtro_completo(args, dur_video, dur_trilha, ganho_db, limiar, duck_db)
219
+ run(["ffmpeg", "-v", "error", "-y", "-i", args.video, "-i", args.trilha, "-filter_complex", fc,
220
+ "-map", "[mix]", "-c:a", "pcm_f32le", mix_wav, "-map", "[d2]", "-c:a", "pcm_f32le", stem_wav])
221
+
222
+
223
+ def medir_duck(voz_path: str, stem_path: str, seco_path: str, depois_de: float) -> dict:
224
+ """compara a trilha com duck e a mesma trilha sem duck, em janelas de 100 ms: quanto ela abaixa quando a voz
225
+ fala, quanto tempo fica abaixada, e voz x trilha durante a fala"""
226
+ sr = 16000
227
+ v, m, s = audio_mono(voz_path, sr), audio_mono(stem_path, sr), audio_mono(seco_path, sr)
228
+ n = min(len(v), len(m), len(s))
229
+ ev, em, es = env_db(v[:n], sr, 0.1), env_db(m[:n], sr, 0.1), env_db(s[:n], sr, 0.1)
230
+ k0 = int(depois_de / 0.1) + 1
231
+ ev, em, es = ev[k0:], em[k0:], es[k0:]
232
+ viva = es > -80 # só onde a trilha toca
233
+ if viva.sum() < 20:
234
+ return {}
235
+ fala = (ev > np.percentile(ev, 90) - 18) & viva
236
+ res = {}
237
+ if fala.sum() > 5:
238
+ red = es[fala] - em[fala]
239
+ res["duck_na_fala_db"] = round(float(np.median(red)), 1)
240
+ mf = 10 * np.log10((10 ** (em[fala] / 10)).mean())
241
+ vf = 10 * np.log10((10 ** (ev[fala] / 10)).mean())
242
+ res["voz_menos_trilha_na_fala_db"] = round(float(vf - mf), 1)
243
+ res["tempo_abaixada_pct"] = round(float(((es - em) > 3)[viva].mean() * 100), 0)
244
+ return res
245
+
246
+
247
+ def main(argv=None):
248
+ try:
249
+ sys.stdout.reconfigure(line_buffering=True) # mensagens aparecem na hora, mesmo em log
250
+ except AttributeError:
251
+ pass
252
+ ap = argparse.ArgumentParser(description="Trilha contínua por baixo da voz, com duck, medida em dB abaixo da voz.")
253
+ ap.add_argument("--video", required=True, help="vídeo pronto (sem trilha; pode ter efeitos sonoros)")
254
+ ap.add_argument("--trilha", required=True, help="arquivo da música (wav, mp3, m4a...)")
255
+ ap.add_argument("--abaixo-da-voz", type=float, default=22.0,
256
+ help="quantos dB a trilha fica abaixo da voz (padrão 22; 28 ou mais some no celular)")
257
+ ap.add_argument("--entrada-em", type=float, default=0.0, help="segundo do vídeo em que a trilha entra (padrão 0)")
258
+ ap.add_argument("--respiro", type=float, nargs="?", const=2.0, default=0.0,
259
+ help="a trilha entra mais alta por SEG segundos (padrão 2) e depois assenta")
260
+ ap.add_argument("--respiro-db", type=float, default=10.0, help="quanto o respiro fica acima do nível normal (10)")
261
+ ap.add_argument("--duck", type=float, default=8.0, help="quanto a trilha abaixa quando a voz fala, no máximo (8 dB)")
262
+ ap.add_argument("--fade-fim", type=float, default=None,
263
+ help="fade da trilha no fim do vídeo em segundos (1.5; com pouca trilha, um terço do que ela toca)")
264
+ ap.add_argument("--inicio-trilha", type=float, default=0.0, help="de que segundo da música começar (0)")
265
+ ap.add_argument("--saida", help="vídeo de saída (padrão: <video>_trilha.mp4 ao lado)")
266
+ a = ap.parse_args(argv)
267
+
268
+ for b in ("ffmpeg", "ffprobe"):
269
+ if not shutil.which(b):
270
+ erro(f"não achei o {b}. Instale o ffmpeg (no Mac: brew install ffmpeg).")
271
+ for p in (a.video, a.trilha):
272
+ if not Path(p).exists():
273
+ erro(f"não achei {p}")
274
+ saida = a.saida or str(Path(a.video).with_name(Path(a.video).stem + "_trilha.mp4"))
275
+ if Path(saida).resolve() in (Path(a.video).resolve(), Path(a.trilha).resolve()):
276
+ erro("a saída não pode ter o mesmo nome da entrada")
277
+ if not 6 <= a.abaixo_da_voz <= 40:
278
+ erro("--abaixo-da-voz fica entre 6 e 40 dB (o normal é 20 a 24)")
279
+ sv, st = sonda(a.video), sonda(a.trilha)
280
+ if sv["audio"] is None:
281
+ erro("o vídeo não tem áudio (a voz). A trilha é medida contra a voz.")
282
+ if st["audio"] is None:
283
+ erro("o arquivo da trilha não tem áudio")
284
+ D = sv["video"] or sv["formato"]
285
+ Dm = st["audio"] or st["formato"]
286
+ if a.entrada_em >= D - 1:
287
+ erro(f"--entrada-em {a.entrada_em} passa do fim do vídeo ({D:.2f} s)")
288
+ if a.inicio_trilha >= Dm - 1:
289
+ erro(f"--inicio-trilha {a.inicio_trilha} passa do fim da música ({Dm:.2f} s)")
290
+ if a.fade_fim is None:
291
+ a.fade_fim, aviso_fade = ajustar_fade_fim(D - a.entrada_em, FADE_FIM_PADRAO)
292
+ if aviso_fade:
293
+ print("aviso: " + aviso_fade)
294
+ elif ajustar_fade_fim(D - a.entrada_em, a.fade_fim)[1]:
295
+ print(f"aviso: o --fade-fim {a.fade_fim:g} s ocupa mais de um terço dos {D - a.entrada_em:.2f} s em que a trilha "
296
+ "toca; ficou como você pediu.")
297
+ if a.respiro and a.respiro + 0.6 > D - a.entrada_em - a.fade_fim:
298
+ print(f"aviso: o respiro de {a.respiro:g} s não cabe nos {D - a.entrada_em:.2f} s de trilha: ela fica alta até "
299
+ "o fade do fim. Diminua o --respiro ou entre antes.")
300
+
301
+ jv = loudness(a.video)
302
+ Lv = float(jv["input_i"])
303
+ if Lv < -60:
304
+ erro("o vídeo está praticamente mudo; não dá pra medir a voz")
305
+ limiar = max(0.000977, min(0.5, 10 ** ((Lv - 30) / 20))) # a voz passa ~20 dB acima disso quando fala
306
+ alvo = Lv - a.abaixo_da_voz
307
+ print(f"voz do vídeo: {Lv:.1f} LUFS · alvo da trilha: {alvo:.1f} LUFS ({a.abaixo_da_voz:g} dB abaixo)")
308
+ if Dm - a.inicio_trilha < D - a.entrada_em:
309
+ print(f"a música ({Dm:.1f} s) é mais curta que o trecho ({D - a.entrada_em:.1f} s): vai repetir com "
310
+ f"crossfade de {XFADE_LACO:g} s")
311
+
312
+ with tempfile.TemporaryDirectory() as td:
313
+ mix_wav, stem_wav = os.path.join(td, "mix.wav"), os.path.join(td, "stem.wav")
314
+ ganho = 0.0
315
+ diff = None
316
+ for passada in range(3): # o duck depende só da voz, então o ganho é linear: 1 correção basta
317
+ renderizar(a, D, Dm, ganho, limiar, mix_wav, stem_wav)
318
+ Ls = float(loudness(stem_wav)["input_i"])
319
+ diff = Lv - Ls
320
+ if abs(diff - a.abaixo_da_voz) <= 0.3:
321
+ break
322
+ ganho += alvo - Ls
323
+ seco_wav = os.path.join(td, "seco.wav")
324
+ renderizar(a, D, Dm, ganho, limiar, os.path.join(td, "mix_seco.wav"), seco_wav, duck_db=0.0)
325
+ duck = medir_duck(a.video, stem_wav, seco_wav, a.entrada_em + (a.respiro + 0.6 if a.respiro else 0.0))
326
+ jm, tipo, fim = finalizar(a.video, mix_wav, saida)
327
+
328
+ so = sonda(saida)
329
+ sinc = desvio_sincronia(a.video, saida)
330
+ print(f"trilha: ganho {ganho:+.1f} dB · ficou {diff:.1f} dB abaixo da voz (LUFS integrado, já com o duck)")
331
+ if duck.get("duck_na_fala_db") is not None:
332
+ print(f"duck: a trilha abaixa {duck['duck_na_fala_db']:.1f} dB quando a voz fala (abaixada em "
333
+ f"{duck['tempo_abaixada_pct']:.0f}% do tempo em que a trilha toca; volta nas pausas longas e no fim) · durante a fala a voz fica "
334
+ f"{duck['voz_menos_trilha_na_fala_db']:.1f} dB acima da trilha")
335
+ print(f"saída: {saida}")
336
+ print(f" duração vídeo {so['video']:.3f} s, áudio {so['audio']:.3f} s (entrada {D:.3f} s) · volume final "
337
+ f"{fim['input_i']} LUFS, pico {fim['input_tp']} dBTP (ajuste {tipo}) · voz no lugar: desvio {sinc} ms")
338
+ if abs(so["audio"] - D) > 0.05:
339
+ print(" aviso: a duração do áudio não bate com a do vídeo; confira antes de entregar")
340
+ if sinc > 5:
341
+ print(" aviso: a voz escorregou; não entregue sem conferir")
342
+ if tipo != "linear":
343
+ print(" aviso: o ajuste de volume saiu dinâmico; confira se a voz ficou natural")
344
+ rel = dict(video=a.video, trilha=a.trilha, saida=saida, voz_lufs=Lv, abaixo_pedido_db=a.abaixo_da_voz,
345
+ abaixo_medido_db=round(diff, 2), ganho_trilha_db=round(ganho, 2), entrada_em=a.entrada_em,
346
+ respiro_s=a.respiro, respiro_db=a.respiro_db, duck_max_db=a.duck, fade_fim_s=a.fade_fim, **duck,
347
+ duracao_entrada=round(D, 3), duracao_saida_video=round(so["video"], 3),
348
+ duracao_saida_audio=round(so["audio"], 3), lufs_final=float(fim["input_i"]),
349
+ pico_final_dbtp=float(fim["input_tp"]), ajuste=tipo, desvio_voz_ms=sinc)
350
+ Path(saida + ".trilha.json").write_text(json.dumps(rel, ensure_ascii=False, indent=1))
351
+
352
+
353
+ if __name__ == "__main__":
354
+ main()
@@ -0,0 +1,295 @@
1
+ """First cut built automatically: every phrase once, the last take of each repeated one, no pauses.
2
+
3
+ The agent used to write the EDL range by range. For a talking video that follows a script this
4
+ helper does the mechanical part and the agent only reviews what it dropped:
5
+
6
+ 1. Each source is split into speech blocks at the pauses measured on the waveform (at least
7
+ --pausa s below the noise floor + 10 dB). Whisper decides the words, never the edges.
8
+ 2. A block is a false start when a later block, up to JANELA_BLOCOS ahead and within
9
+ JANELA_S seconds, begins with nearly the same words (difflib >= PARECIDO on the first words):
10
+ the speaker said it again, so the last take wins. Chains ("E nesse exato momento..." three
11
+ times) keep only the last one.
12
+ 3. Blocks without words (breath, cough, noise) or with only hesitation words go out.
13
+ 4. What is left becomes one range per block, and bordas_precisas.refine_edl() puts every edge in
14
+ a measured pause (0.08 s before, 0.12 s after): the junction silence lands at ~0.2 s.
15
+ 5. A take abandoned at the end of a block with no pause before its retake is trimmed off, and a
16
+ whole sentence said again later (even inside a longer take, or in the next video) goes out.
17
+ 6. voice_levels.py sets gain_db on ranges GANHO_DB or more away from the voice reference.
18
+
19
+ Writes <edit>/edl.json (the old one goes to edl.json.bak) and <edit>/corte_auto.md with every
20
+ dropped block, its reason and the take that replaced it. Read corte_auto.md before rendering:
21
+ a block dropped as a retake that was NOT one (two different sentences starting the same way) is
22
+ the error to look for.
23
+
24
+ Usage:
25
+ uv run python helpers/montar_corte.py --edit-dir <edit> [--fontes a.mov b.mov ...]
26
+ [--pausa 0.35] [--formato vertical] [--dry-run]
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import difflib
33
+ import re
34
+ import shutil
35
+ import subprocess
36
+ import sys
37
+ import unicodedata
38
+ from pathlib import Path
39
+
40
+ import numpy as np
41
+
42
+ from bordas_precisas import HOP, OFF_DB, refine_edl, source_levels
43
+ from common import load_json, save_json
44
+
45
+ PAUSA_S = 0.35 # a pause this long splits two blocks
46
+ MIN_ILHA_S = 0.03 # speech islands shorter than this are clicks and join the silence
47
+ # (0.08 swallowed short words: "ser", "o que", "É")
48
+ JANELA_BLOCOS = 4 # a retake is looked for this many blocks ahead ...
49
+ JANELA_S = 25.0 # ... and within this many seconds
50
+ PARECIDO = 0.6 # share of the block's words found, in order, at the start of a near later block
51
+ PARECIDO_LONGE = 0.75 # ... of a later block further away or in a later source (retake of a whole part)
52
+ LONGE_BLOCOS = 12 # how far ahead, in blocks across sources, a whole-part retake is looked for
53
+ MIN_LONGE = 5 # blocks shorter than this are never matched far away
54
+ QUASE_IGUAL = 0.9 # a block of MIN_LONGE+ words found this complete anywhere later is a retake
55
+ CURTO = 3 # a block this short is a false start when all its words open the next block
56
+ VOZ_SEM_PALAVRA_S = 0.25 # a block without transcribed words this long ...
57
+ VOZ_PICO_DB = 20.0 # ... and this loud over the floor is a word Whisper misplaced, not a breath
58
+ HESITACAO = {"é", "e", "ah", "ahn", "hã", "hum", "hm", "uhm", "eh", "éé", "tá", "né", "então", "aí"}
59
+ GANHO_DB = 4.0 # ranges this far from the voice reference get their gain_db (verify uses 4 too)
60
+ FORMATOS = {"vertical": (1080, 1920), "horizontal": (1920, 1080), "square": (1080, 1080)}
61
+
62
+
63
+ def norm(word: str) -> str:
64
+ w = unicodedata.normalize("NFD", word.lower())
65
+ w = "".join(c for c in w if unicodedata.category(c) != "Mn")
66
+ return re.sub(r"[^\w]", "", w)
67
+
68
+
69
+ def blocks_of(db: np.ndarray, floor: float, pausa: float) -> list[tuple[float, float]]:
70
+ """Speech blocks (start, end) in seconds, split at quiet runs of at least `pausa`."""
71
+ loud = db >= floor + OFF_DB
72
+ # clicks: loud runs shorter than MIN_ILHA_S become silence
73
+ k, n, short = 0, loud.size, int(round(MIN_ILHA_S / HOP))
74
+ while k < n:
75
+ if loud[k]:
76
+ j = k
77
+ while j < n and loud[j]:
78
+ j += 1
79
+ if j - k < short:
80
+ loud[k:j] = False
81
+ k = j
82
+ else:
83
+ k += 1
84
+ out, k, gap = [], 0, int(round(pausa / HOP))
85
+ while k < n:
86
+ if not loud[k]:
87
+ k += 1
88
+ continue
89
+ a = k
90
+ quiet = 0
91
+ while k < n and quiet < gap:
92
+ quiet = 0 if loud[k] else quiet + 1
93
+ k += 1
94
+ out.append((a * HOP, (k - quiet) * HOP))
95
+ return out
96
+
97
+
98
+ def words_in(words: list[dict], a: float, b: float) -> list[dict]:
99
+ return [w for w in words if a <= (float(w["start"]) + float(w["end"])) / 2 < b]
100
+
101
+
102
+ def coverage(first: list[str], later: list[str]) -> tuple[float, int]:
103
+ """(share of `first` found in order in `later` in runs of 2+ words, where that match starts)."""
104
+ if not first or not later:
105
+ return 0.0, 0
106
+ sm = difflib.SequenceMatcher(None, first, later, autojunk=False)
107
+ runs = [m for m in sm.get_matching_blocks() if m.size >= 2]
108
+ if not runs:
109
+ return 0.0, 0
110
+ return sum(m.size for m in runs) / len(first), runs[0].b
111
+
112
+
113
+ def retake_of(bl: dict, nxt: list[dict], near: bool) -> tuple[float, int]:
114
+ """(how clearly `bl` is an earlier take of what `nxt` says, which of `nxt` the take starts in);
115
+ (0, 0) if it is not a retake."""
116
+ toks = bl["toks"]
117
+ later = [t for b in nxt for t in b["toks"]]
118
+ if near and len(toks) <= CURTO:
119
+ opening = nxt[0]["toks"][:8]
120
+ hit = opening and all(t in opening for t in toks) and len(opening) > len(toks)
121
+ return (1.0, 0) if hit else (0.0, 0)
122
+ cov, at = coverage(toks, later[: len(toks) + 12])
123
+ if at > 3:
124
+ # the later take must begin with it, not quote it in the middle ... unless it repeats a
125
+ # long stretch almost word for word (a whole sentence said again inside a longer take)
126
+ cov, at = coverage(toks, later)
127
+ if not (len(toks) >= MIN_LONGE and cov >= QUASE_IGUAL):
128
+ return 0.0, 0
129
+ which, acc = 0, 0
130
+ for k, b in enumerate(nxt):
131
+ if at < acc + len(b["toks"]):
132
+ which = k
133
+ break
134
+ acc += len(b["toks"])
135
+ if near:
136
+ return (cov, which) if cov >= PARECIDO and cov * len(toks) >= 3 else (0.0, 0)
137
+ return (cov, which) if len(toks) >= MIN_LONGE and cov >= PARECIDO_LONGE else (0.0, 0)
138
+
139
+
140
+ def main() -> int:
141
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
142
+ ap.add_argument("--edit-dir", type=Path, required=True)
143
+ ap.add_argument("--fontes", nargs="*", help="vídeos na ordem do roteiro (padrão: todos os transcritos, por nome)")
144
+ ap.add_argument("--pausa", type=float, default=PAUSA_S)
145
+ ap.add_argument("--formato", default="vertical", choices=sorted(FORMATOS))
146
+ ap.add_argument("--dry-run", action="store_true", help="só mostra, não grava o edl.json")
147
+ args = ap.parse_args()
148
+ edit = args.edit_dir
149
+
150
+ trs = {}
151
+ for p in sorted((edit / "transcripts").glob("*.json")):
152
+ if p.stem == "cut":
153
+ continue
154
+ d = load_json(p)
155
+ if isinstance(d, dict) and d.get("words") is not None and d.get("source"):
156
+ trs[p.stem] = d
157
+ if args.fontes:
158
+ order = [Path(f).stem for f in args.fontes]
159
+ missing = [s for s in order if s not in trs]
160
+ if missing:
161
+ print(f"sem transcrição: {', '.join(missing)} (rode o transcribe.py antes)", file=sys.stderr)
162
+ return 2
163
+ else:
164
+ order = list(trs)
165
+ if not order:
166
+ print(f"nenhuma transcrição em {edit / 'transcripts'}", file=sys.stderr)
167
+ return 2
168
+
169
+ sources, ranges, dropped, envs = {}, [], [], {}
170
+ allb: list[dict] = [] # every block of every source, in script order
171
+ n = 0
172
+ for stem in order:
173
+ tr = trs[stem]
174
+ src = str(tr["source"])
175
+ lv = source_levels(src, print)
176
+ envs[stem] = lv
177
+ sources[stem] = src
178
+ if lv is None:
179
+ print(f"{stem}: entra inteiro, revise.")
180
+ allb.append({"stem": stem, "a": 0.0, "b": float(tr.get("duration") or 0), "text": "", "toks": ["?"],
181
+ "whole": True})
182
+ continue
183
+ db, floor = lv
184
+ words = [w for w in tr["words"] if w.get("start") is not None]
185
+ for a, b in blocks_of(db, floor, args.pausa):
186
+ ws = words_in(words, a, b)
187
+ text = " ".join(str(w.get("text", "")).strip() for w in ws)
188
+ ws = [w for w in ws if norm(str(w.get("text", "")))] # one token per word, aligned
189
+ peak = float(db[int(a / HOP):max(int(a / HOP) + 1, int(b / HOP))].max()) - floor
190
+ allb.append({"stem": stem, "a": a, "b": b, "text": text, "words": ws, "peak": peak,
191
+ "toks": [norm(str(w.get("text", ""))) for w in ws],
192
+ "cut_off": text.rstrip().endswith(("...", "…", "-"))})
193
+
194
+ for i, bl in enumerate(allb):
195
+ bl["keep"], bl["why"] = True, ""
196
+ if bl.get("whole"):
197
+ continue
198
+ if not bl["toks"]:
199
+ if bl["b"] - bl["a"] >= VOZ_SEM_PALAVRA_S and bl.get("peak", 0.0) >= VOZ_PICO_DB:
200
+ # Whisper put the word somewhere else ("Eu" 1 s early): loud enough to be a word, so it stays
201
+ bl["text"] = "(fala sem transcrição: confira)"
202
+ continue
203
+ bl["keep"], bl["why"] = False, "sem fala (respiro, barulho)"
204
+ continue
205
+ if all(t in HESITACAO for t in bl["toks"]):
206
+ bl["keep"], bl["why"] = False, f"só hesitação (\"{bl['text']}\")"
207
+ continue
208
+ for j in range(i + 1, min(len(allb), i + 1 + LONGE_BLOCOS)):
209
+ later = allb[j]
210
+ if not later["toks"] or later.get("whole"):
211
+ continue
212
+ near = j - i <= JANELA_BLOCOS and (later["stem"] != bl["stem"] or later["a"] - bl["b"] <= JANELA_S)
213
+ if len(bl["toks"]) <= CURTO and j > i + 1 and near:
214
+ continue # a false start is redone right after it, not three blocks later ("Não!" ... "que não")
215
+ r, k = retake_of(bl, allb[j:j + 3], near)
216
+ if r:
217
+ later = allb[j + k]
218
+ onde = later["a"] if later["stem"] == bl["stem"] else f"{later['stem']} {later['a']:.2f}"
219
+ bl["keep"] = False
220
+ bl["why"] = (f"tomada repetida ({r:.0%} dela no começo de {onde if isinstance(onde, str) else f'{onde:.2f}'}s: "
221
+ f"\"{later['text'][:60]}\")")
222
+ break
223
+
224
+ # a take abandoned at the end of a block with no pause before the retake: "... da Shopee e um
225
+ # mecanismo..." + "E um mecanismo com inteligência artificial". The block ends before it.
226
+ kept = [b for b in allb if b["keep"] and not b.get("whole")]
227
+ for bl, nb in zip(kept, kept[1:]):
228
+ if not bl.get("cut_off") or len(bl["toks"]) < 3 or not nb["toks"]:
229
+ continue
230
+ for k in range(min(6, len(bl["toks"]) - 1), 1, -1):
231
+ tail, head = bl["toks"][-k:], nb["toks"][: k + 2]
232
+ cov, at = coverage(tail, head)
233
+ if cov >= 0.99 and at <= 1:
234
+ t0 = float(bl["words"][-k]["start"])
235
+ if t0 - bl["a"] > 0.3:
236
+ dropped.append((bl["stem"], {"a": t0, "b": bl["b"], "text": " ".join(
237
+ str(w.get("text", "")).strip() for w in bl["words"][-k:])},
238
+ f"fim de tomada abandonada, refeita em {nb['a']:.2f}s"))
239
+ bl["b"] = t0
240
+ bl["text"] = " ".join(str(w.get("text", "")).strip() for w in bl["words"][:-k])
241
+ break
242
+
243
+ for bl in allb:
244
+ if bl["keep"]:
245
+ n += 1
246
+ ranges.append({"id": f"r{n:02d}", "source": bl["stem"], "start": round(bl["a"], 3),
247
+ "end": round(bl["b"], 3), "beat": "", "quote": bl["text"][:90],
248
+ "reason": "fonte sem pausas medíveis" if bl.get("whole")
249
+ else "montar_corte: última tomada da frase", "gain_db": 0})
250
+ else:
251
+ dropped.append((bl["stem"], bl, bl["why"]))
252
+
253
+ edl = {"version": 1, "sources": sources,
254
+ "output": {"format": args.formato, "width": FORMATOS[args.formato][0],
255
+ "height": FORMATOS[args.formato][1]},
256
+ "grade": "", "jcut": {"lead_frames": 4, "tail_trim_frames": 2}, "ranges": ranges}
257
+ log_lines: list[str] = []
258
+ alerts = refine_edl(edl, envs, log=log_lines.append)
259
+
260
+ fala = sum(r["end"] - r["start"] for r in ranges)
261
+ bruto = sum(float(t.get("duration") or 0) for s, t in trs.items() if s in order)
262
+ md = [f"# Corte automático · {len(ranges)} trechos, {fala:.1f} s de {bruto:.1f} s de bruto", "",
263
+ f"## Tirado ({len(dropped)} blocos) · confira se alguma frase boa saiu como repetida", ""]
264
+ for stem, bl, reason in dropped:
265
+ md.append(f"- {stem} [{bl['a']:.2f}-{bl['b']:.2f}] \"{bl['text'][:80]}\" · {reason}")
266
+ if alerts:
267
+ md += ["", "## SEM PAUSA (ouça essas bordas)", ""] + [f"- {a}" for a in alerts]
268
+ md += ["", "## Trechos", ""] + [f"- {r['id']} {r['source']} [{r['start']:.2f}-{r['end']:.2f}] {r['quote']}"
269
+ for r in ranges]
270
+ report = "\n".join(md) + "\n"
271
+ print(report)
272
+ if not args.dry_run:
273
+ edl_path = edit / "edl.json"
274
+ if edl_path.exists():
275
+ shutil.copy(edl_path, edit / "edl.json.bak")
276
+ save_json(edl_path, edl)
277
+ # voice level per range now, so the first render does not come back with LOW-LEVEL flags
278
+ vl = subprocess.run([sys.executable, str(Path(__file__).with_name("voice_levels.py")), "--edl", str(edl_path),
279
+ "--drop-db", str(GANHO_DB)], capture_output=True, text=True)
280
+ levels = load_json(edit / "verify" / "voice_levels.json") if vl.returncode == 0 else None
281
+ ganhos = {r["id"]: float(r["gain_db"]) for r in (levels or {}).get("ranges", [])
282
+ if (r.get("low") or r.get("high")) and r.get("gain_db") is not None}
283
+ if ganhos:
284
+ for r in edl["ranges"]:
285
+ if r["id"] in ganhos:
286
+ r["gain_db"] = ganhos[r["id"]]
287
+ save_json(edl_path, edl)
288
+ print("volume ajustado: " + ", ".join(f"{k} {v:+.1f} dB" for k, v in ganhos.items()))
289
+ (edit / "corte_auto.md").write_text(report, encoding="utf-8")
290
+ print(f"gravado: {edl_path} e {edit / 'corte_auto.md'}")
291
+ return 0
292
+
293
+
294
+ if __name__ == "__main__":
295
+ sys.exit(main())
@@ -0,0 +1,13 @@
1
+ """Motion design for editor-video: animated texts and numbers drawn in Python (SPEC Contract 12).
2
+
3
+ from motion import engine, registry, render
4
+ item = render.plan_item("contador", {"value": 1904152.87, "prefix": "R$ "}, fps=30, duration=3.0)
5
+ clip = render.render_clip(item, "edit/renders/motion")
6
+
7
+ Templates live in helpers/motion/templates/<id>.py (one file each, found automatically).
8
+ """
9
+
10
+ from . import engine, registry, render # noqa: F401
11
+ from .engine import MotionError # noqa: F401
12
+
13
+ __all__ = ["engine", "registry", "render", "MotionError"]