primocode 10.15.2 → 10.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSES/edit-ia-pro.txt +32 -0
  2. package/LICENSES/fontes-OFL.txt +476 -0
  3. package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
  4. package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
  5. package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
  6. package/LICENSES/three-MIT.txt +23 -0
  7. package/LICENSES/video-use-MIT.txt +24 -0
  8. package/LICENSES/woven-sfx-CC0.txt +16 -0
  9. package/LICENSES/yunet-MIT.txt +21 -0
  10. package/README.md +2 -2
  11. package/bin/primocode.js +74 -9
  12. package/editor/LICENSE.md +11 -0
  13. package/editor/README.md +321 -0
  14. package/editor/SKILL.md +307 -0
  15. package/editor/THIRD_PARTY_NOTICES.md +228 -0
  16. package/editor/assets/baixar.json +591 -0
  17. package/editor/assets/fonts/anton/OFL.txt +93 -0
  18. package/editor/assets/fonts/caveat/OFL.txt +93 -0
  19. package/editor/assets/fonts/figtree/OFL.txt +93 -0
  20. package/editor/assets/fonts/fonts.json +10 -0
  21. package/editor/assets/fonts/montserrat/OFL.txt +93 -0
  22. package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
  23. package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
  24. package/editor/assets/models/YUNET_LICENSE.txt +21 -0
  25. package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
  26. package/editor/assets/sfx/licencas/cash.txt +76 -0
  27. package/editor/assets/sfx/licencas/charge.txt +31 -0
  28. package/editor/assets/sfx/licencas/click.txt +46 -0
  29. package/editor/assets/sfx/licencas/coin.txt +50 -0
  30. package/editor/assets/sfx/licencas/count.txt +47 -0
  31. package/editor/assets/sfx/licencas/data.txt +31 -0
  32. package/editor/assets/sfx/licencas/error.txt +32 -0
  33. package/editor/assets/sfx/licencas/flash.txt +31 -0
  34. package/editor/assets/sfx/licencas/gears.txt +82 -0
  35. package/editor/assets/sfx/licencas/keyboard.txt +48 -0
  36. package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
  37. package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
  38. package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
  39. package/editor/assets/sfx/licencas/notification.txt +54 -0
  40. package/editor/assets/sfx/licencas/pop.txt +38 -0
  41. package/editor/assets/sfx/licencas/rewind.txt +32 -0
  42. package/editor/assets/sfx/licencas/rise.txt +46 -0
  43. package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
  44. package/editor/assets/sfx/licencas/snap.txt +50 -0
  45. package/editor/assets/sfx/licencas/success.txt +40 -0
  46. package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
  47. package/editor/assets/sfx/licencas/tick.txt +14 -0
  48. package/editor/assets/sfx/licencas/whoosh.txt +47 -0
  49. package/editor/assets/sfx/licencas/wind.txt +31 -0
  50. package/editor/assets/sfx/sfx.json +832 -0
  51. package/editor/assets/sfx/sfx_sinteticos.json +317 -0
  52. package/editor/assets/sfx/sfx_woven.json +387 -0
  53. package/editor/assets/sfx/woven/LICENCA.md +16 -0
  54. package/editor/fichas/briefing-do-video.md +45 -0
  55. package/editor/fichas/conferencia-da-amostra.md +56 -0
  56. package/editor/fichas/matriz-de-ganchos.csv +3 -0
  57. package/editor/fichas/padrao.json +16 -0
  58. package/editor/fichas/planilha-de-cenas.csv +4 -0
  59. package/editor/fichas/planilha-de-versoes.csv +4 -0
  60. package/editor/fichas/plano-em-blocos.md +32 -0
  61. package/editor/fichas/registro-de-materiais.json +27 -0
  62. package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
  63. package/editor/helpers/bordas_precisas.py +272 -0
  64. package/editor/helpers/camera_viva.py +426 -0
  65. package/editor/helpers/captions.py +601 -0
  66. package/editor/helpers/common.py +308 -0
  67. package/editor/helpers/compose_final.py +3555 -0
  68. package/editor/helpers/conferir_corte.py +234 -0
  69. package/editor/helpers/conferir_dados.py +405 -0
  70. package/editor/helpers/contact_sheet.py +282 -0
  71. package/editor/helpers/corte_por_onda.py +433 -0
  72. package/editor/helpers/corte_seco.py +595 -0
  73. package/editor/helpers/denoise.py +971 -0
  74. package/editor/helpers/efeitos.py +408 -0
  75. package/editor/helpers/efeitos_gerar.py +159 -0
  76. package/editor/helpers/elevenlabs_api.py +442 -0
  77. package/editor/helpers/face_layout.py +570 -0
  78. package/editor/helpers/fetch_images.py +423 -0
  79. package/editor/helpers/ganho_por_trecho.py +214 -0
  80. package/editor/helpers/gen_image.py +312 -0
  81. package/editor/helpers/gen_music.py +316 -0
  82. package/editor/helpers/gen_sfx.py +148 -0
  83. package/editor/helpers/grade.py +307 -0
  84. package/editor/helpers/icloud.py +119 -0
  85. package/editor/helpers/make_sfx.py +940 -0
  86. package/editor/helpers/matte.py +1342 -0
  87. package/editor/helpers/medir_referencia.py +2232 -0
  88. package/editor/helpers/mixar_trilha.py +354 -0
  89. package/editor/helpers/montar_corte.py +295 -0
  90. package/editor/helpers/motion/__init__.py +13 -0
  91. package/editor/helpers/motion/engine.py +1915 -0
  92. package/editor/helpers/motion/registry.py +510 -0
  93. package/editor/helpers/motion/render.py +665 -0
  94. package/editor/helpers/motion/templates/barra_progresso.py +370 -0
  95. package/editor/helpers/motion/templates/carimbo.py +132 -0
  96. package/editor/helpers/motion/templates/checklist.py +271 -0
  97. package/editor/helpers/motion/templates/comentario_cta.py +380 -0
  98. package/editor/helpers/motion/templates/contador.py +222 -0
  99. package/editor/helpers/motion/templates/destaque.py +263 -0
  100. package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
  101. package/editor/helpers/motion/templates/grafico_barras.py +510 -0
  102. package/editor/helpers/motion/templates/grafico_linha.py +462 -0
  103. package/editor/helpers/motion/templates/notificacao.py +266 -0
  104. package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
  105. package/editor/helpers/motion/templates/passos.py +321 -0
  106. package/editor/helpers/motion/templates/riscado.py +298 -0
  107. package/editor/helpers/motion/templates/selo_garantia.py +150 -0
  108. package/editor/helpers/motion_render.py +241 -0
  109. package/editor/helpers/motion_sugerir.py +961 -0
  110. package/editor/helpers/noise_check.py +134 -0
  111. package/editor/helpers/pack_transcripts.py +223 -0
  112. package/editor/helpers/palavras_na_onda.py +176 -0
  113. package/editor/helpers/plano_sfx.py +839 -0
  114. package/editor/helpers/portao.py +427 -0
  115. package/editor/helpers/preview_server.py +1177 -0
  116. package/editor/helpers/rastrear_rosto.py +298 -0
  117. package/editor/helpers/render_cut.py +1256 -0
  118. package/editor/helpers/respiros.py +89 -0
  119. package/editor/helpers/sincronizar_cameras.py +571 -0
  120. package/editor/helpers/speech_regions.py +423 -0
  121. package/editor/helpers/style_catalog.json +231 -0
  122. package/editor/helpers/style_samples.py +246 -0
  123. package/editor/helpers/tela_verde.py +2113 -0
  124. package/editor/helpers/text_render.py +2059 -0
  125. package/editor/helpers/texto_na_tela.py +366 -0
  126. package/editor/helpers/timeline_view.py +292 -0
  127. package/editor/helpers/tira_de_quadros.py +113 -0
  128. package/editor/helpers/transcribe.py +734 -0
  129. package/editor/helpers/transcribe_batch.py +129 -0
  130. package/editor/helpers/trilhas.py +1146 -0
  131. package/editor/helpers/trilhas_humores.json +66 -0
  132. package/editor/helpers/valores.py +187 -0
  133. package/editor/helpers/verify_cut.py +820 -0
  134. package/editor/helpers/voice_levels.py +233 -0
  135. package/editor/helpers/voz_check.py +386 -0
  136. package/editor/helpers/voz_limpa.py +81 -0
  137. package/editor/helpers/watch_preview.py +372 -0
  138. package/editor/modelo-projeto/LEIA.md +402 -0
  139. package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
  140. package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
  141. package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
  142. package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
  143. package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
  144. package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
  145. package/editor/modelo-projeto/assets/three/LICENSE +21 -0
  146. package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
  147. package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
  148. package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
  149. package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
  150. package/editor/modelo-projeto/build.py +4828 -0
  151. package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
  152. package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
  153. package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
  154. package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
  155. package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
  156. package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
  157. package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
  158. package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
  159. package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
  160. package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
  161. package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
  162. package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
  163. package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
  164. package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
  165. package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
  166. package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
  167. package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
  168. package/editor/modelo-projeto/hyperframes.json +12 -0
  169. package/editor/modelo-projeto/package.json +11 -0
  170. package/editor/modelo-projeto/projeto.json +16 -0
  171. package/editor/preview/app.css +1009 -0
  172. package/editor/preview/app.js +2122 -0
  173. package/editor/preview/index.html +307 -0
  174. package/editor/pyproject.toml +23 -0
  175. package/editor/references/3d.md +236 -0
  176. package/editor/references/animacao-catalogo.md +672 -0
  177. package/editor/references/animacao.md +498 -0
  178. package/editor/references/comandos.md +92 -0
  179. package/editor/references/corte-seco-como-funciona.md +50 -0
  180. package/editor/references/corte-seco.md +88 -0
  181. package/editor/references/corte.md +290 -0
  182. package/editor/references/direcao-por-blocos.md +218 -0
  183. package/editor/references/enquadramento.md +654 -0
  184. package/editor/references/estilos-prontos.md +355 -0
  185. package/editor/references/estudio.md +100 -0
  186. package/editor/references/exemplo-eventos.json +67 -0
  187. package/editor/references/lettering.md +252 -0
  188. package/editor/references/modelos.md +494 -0
  189. package/editor/references/phase2.md +1007 -0
  190. package/editor/references/referencia-aplicar.md +203 -0
  191. package/editor/references/referencia-medir.md +375 -0
  192. package/editor/references/revisao.md +335 -0
  193. package/editor/references/som-e-trilha.md +146 -0
  194. package/editor/references/tabela-de-sons.md +130 -0
  195. package/editor/references/variacoes.md +234 -0
  196. package/editor/uv.lock +1141 -0
  197. package/lib/act.js +38 -8
  198. package/lib/app-chat.js +97 -0
  199. package/lib/catalogo.js +7 -1
  200. package/lib/chamada-torta.js +81 -0
  201. package/lib/editor-pro.js +786 -0
  202. package/lib/entrega.js +58 -0
  203. package/lib/estudio/conferencia-visual.js +76 -0
  204. package/lib/estudio/diretor.js +6 -1
  205. package/lib/estudio/exportar.js +3 -1
  206. package/lib/estudio/gosto/tela.html +1349 -0
  207. package/lib/estudio/gosto.js +305 -0
  208. package/lib/estudio/index.js +12 -1
  209. package/lib/estudio/kit-ae/base.jsx +119 -13
  210. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  211. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  212. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  213. package/lib/estudio/maquina-diretor.js +70 -12
  214. package/lib/estudio/maquina-foto.js +177 -0
  215. package/lib/estudio/maquina-guarda.js +207 -0
  216. package/lib/estudio/maquina-layout.js +22 -1
  217. package/lib/estudio/maquina.js +64 -11
  218. package/lib/estudio/referencias.js +1 -1
  219. package/lib/estudio/visao/visao.swift +97 -0
  220. package/lib/estudio/visao.js +60 -0
  221. package/lib/headless.js +15 -3
  222. package/lib/local/cerebro.json +285 -7
  223. package/lib/local/motor.js +5 -2
  224. package/lib/memoria-usuario.js +164 -0
  225. package/lib/nuvem.js +33 -2
  226. package/lib/passagem.js +50 -0
  227. package/lib/perigo.js +19 -11
  228. package/lib/protecao.js +134 -0
  229. package/lib/repeticao.js +1 -1
  230. package/lib/sandbox.js +57 -9
  231. package/lib/titulo-conversa.js +46 -0
  232. package/lib/tools.js +23 -4
  233. package/lib/ui.js +2 -2
  234. package/package.json +3 -2
  235. package/skills/editar-meu-video/SKILL.md +6 -2
  236. package/skills/editar-video/SKILL.md +113 -0
  237. package/studio/pessoa.py +52 -0
  238. package/voz/primo_voz/__main__.py +55 -40
  239. package/voz/primo_voz/app.py +500 -4
  240. package/voz/primo_voz/chat.html +1773 -0
  241. package/voz/primo_voz/conversa.py +0 -26
  242. package/voz/primo_voz/hud.html +8 -2
  243. package/voz/primo_voz/hud.py +129 -5
  244. package/voz/primo_voz/identidade.py +1 -1
  245. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,595 @@
1
+ #!/usr/bin/env python3
2
+ """Corte seco no vídeo JÁ pronto: tira as pausas que sobraram entre as frases (references/corte-seco.md).
3
+
4
+ Como o corte é feito no vídeo final (imagem e som juntos, no mesmo quadro), legenda, animações e efeitos
5
+ continuam no lugar em relação à fala. Em cada pausa maior que --pausa-min, sobra só --pausa-max de silêncio.
6
+
7
+ Onde cortar dentro da pausa:
8
+ - nunca na palavra: as bordas são medidas na onda (limiar pela voz do próprio vídeo) e ainda andam até 0,12 s
9
+ pra fora enquanto houver som fraco (consoante fraca, fim de "s"), com folga de 0,06 s depois da palavra
10
+ e 0,07 s antes da próxima;
11
+ - no trecho em que a imagem menos muda: com --sem-grafismo, olha só os grafismos (render sem a pessoa);
12
+ sem ele, olha a diferença entre quadros do próprio vídeo;
13
+ - nunca no ataque de um efeito sonoro (--sfx com o plano de sons; com --voz, qualquer som que não seja voz).
14
+
15
+ Uso (<SKILL> é a pasta desta skill; o Python dela tem numpy):
16
+ uv run --directory "<SKILL>" python helpers/corte_seco.py VIDEO_FINAL [--pausa-max 0.15]
17
+ [--sem-grafismo RENDER_SO_GRAFISMO.mp4] [--sfx PLANO_SFX.json] [--voz AUDIO_SO_DA_VOZ.mp4] [--cortar 41.06-41.76,...]
18
+ [--saida SAIDA.mp4] [--pausa-min 0.25] [--inicio 0.08] [--limiar -42] [--so-plano] [--crf 16]
19
+
20
+ Saída: o vídeo cortado (-14 LUFS, 2 passadas, linear) e SAIDA.corte.json com os cortes e o mapa de tempo.
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 fractions import Fraction
33
+ from pathlib import Path
34
+
35
+ try:
36
+ import numpy as np
37
+ except ImportError: # pragma: no cover
38
+ sys.exit('Falta o numpy. Rode com o Python da skill: uv run --directory "<SKILL>" python helpers/corte_seco.py ...')
39
+
40
+ HOP = 0.01 # envelope de 10 ms
41
+ PRE0, POS0 = 0.06, 0.07 # folga mínima depois do fim da palavra / antes da próxima (borda no limiar)
42
+ POS1 = 0.04 # folga antes do começo "de verdade" da próxima palavra (borda estendida pelo som fraco)
43
+ ANDAR = 0.12 # quanto a borda da próxima palavra anda pra trás enquanto tiver som fraco (o "S" de "Só")
44
+ FADE = 0.008 # micro fade na emenda do áudio (sem estalo)
45
+ SR_ENV = 16000
46
+
47
+
48
+ def erro(msg: str, code: int = 2):
49
+ print("ERRO: " + msg, file=sys.stderr)
50
+ sys.exit(code)
51
+
52
+
53
+ def aviso(msg: str):
54
+ print("aviso: " + msg)
55
+
56
+
57
+ def run(cmd: list, texto=True, **kw):
58
+ r = subprocess.run(cmd, capture_output=True, text=texto, **kw)
59
+ if r.returncode:
60
+ err = r.stderr if texto else r.stderr.decode(errors="replace")
61
+ erro("o ffmpeg falhou:\n" + err[-2000:])
62
+ return r
63
+
64
+
65
+ # ------------------------------------------------------------------ leitura
66
+ def sonda(p: str) -> dict:
67
+ r = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
68
+ "stream=index,codec_type,r_frame_rate,avg_frame_rate,width,height,duration,start_time,"
69
+ "sample_rate,channels,nb_frames,color_space,color_primaries,color_transfer,color_range"
70
+ ":format=duration", "-of", "json", p], capture_output=True, text=True)
71
+ if r.returncode:
72
+ erro(f"não consegui abrir {p}")
73
+ d = json.loads(r.stdout)
74
+ v = next((s for s in d.get("streams", []) if s.get("codec_type") == "video"), None)
75
+ a = next((s for s in d.get("streams", []) if s.get("codec_type") == "audio"), None)
76
+ fmt = float(d.get("format", {}).get("duration", 0) or 0)
77
+ info = dict(formato=fmt, v=v, a=a)
78
+ if v:
79
+ fr = Fraction(v.get("r_frame_rate") or v.get("avg_frame_rate") or "30/1")
80
+ if fr <= 0:
81
+ fr = Fraction(30, 1)
82
+ info["fps"] = fr
83
+ info["dur_v"] = float(v.get("duration") or fmt)
84
+ info["v0"] = float(v.get("start_time") or 0)
85
+ info["w"], info["h"] = int(v.get("width", 0)), int(v.get("height", 0))
86
+ if a:
87
+ info["dur_a"] = float(a.get("duration") or fmt)
88
+ info["a0"] = float(a.get("start_time") or 0)
89
+ info["sr"] = int(a.get("sample_rate", 48000))
90
+ info["ch"] = int(a.get("channels", 2))
91
+ return info
92
+
93
+
94
+ def audio_mono(p: str, sr: int = SR_ENV) -> "np.ndarray":
95
+ raw = subprocess.run(["ffmpeg", "-v", "error", "-i", p, "-vn", "-ac", "1", "-ar", str(sr), "-f", "f32le",
96
+ "-acodec", "pcm_f32le", "-"], capture_output=True).stdout
97
+ return np.frombuffer(raw, dtype=np.float32)
98
+
99
+
100
+ def env_db(x: "np.ndarray", sr: int = SR_ENV, hop: float = HOP) -> "np.ndarray":
101
+ w = int(sr * hop)
102
+ n = len(x) // w
103
+ return 10 * np.log10((x[: n * w].astype(np.float64).reshape(n, w) ** 2).mean(axis=1) + 1e-12)
104
+
105
+
106
+ def quadros(p: str, largura: int, altura: int):
107
+ """lê o vídeo quadro a quadro em cinza, pequeno, sem guardar tudo na memória"""
108
+ proc = subprocess.Popen(["ffmpeg", "-v", "error", "-i", p, "-an", "-vf",
109
+ f"scale={largura}:{altura}:flags=area,format=gray", "-f", "rawvideo", "-"],
110
+ stdout=subprocess.PIPE)
111
+ n = largura * altura
112
+ while True:
113
+ buf = proc.stdout.read(n)
114
+ if len(buf) < n:
115
+ break
116
+ yield np.frombuffer(buf, dtype=np.uint8)
117
+ proc.wait()
118
+
119
+
120
+ def atividade(p: str, w: int, h: int, guardar: bool = False):
121
+ """atividade[i] = quanto o quadro i mudou em relação ao anterior. Com guardar=True devolve também os quadros
122
+ miniatura (40 px) pra conferência do corte."""
123
+ lw = 96
124
+ lh = max(2, int(round(lw * h / max(1, w) / 2)) * 2)
125
+ act, mini, ant = [], [], None
126
+ for f in quadros(p, lw, lh):
127
+ g = f.astype(np.int16)
128
+ act.append(0.0 if ant is None else float(np.abs(g - ant).mean()))
129
+ ant = g
130
+ if guardar:
131
+ mini.append(f.reshape(lh, lw)[::2, ::2].copy())
132
+ return np.array(act), (np.stack(mini) if guardar and mini else None)
133
+
134
+
135
+ def carregar_sfx(p: str) -> list:
136
+ """intervalos [a, b] onde não pode cortar: do ataque ao pico de cada som (+0,1 s)"""
137
+ try:
138
+ d = json.loads(Path(p).read_text())
139
+ except (OSError, json.JSONDecodeError) as e:
140
+ erro(f"não consegui ler o plano de sons {p}: {e}")
141
+ itens = d.get("sons", d.get("eventos", [])) if isinstance(d, dict) else d
142
+ out = []
143
+ for s in itens:
144
+ pico = s.get("pico", s.get("peak", s.get("t")))
145
+ if pico is None:
146
+ continue
147
+ pico = float(pico)
148
+ ini = float(s["inicio"]) if "inicio" in s else pico - 0.06
149
+ out.append((max(ini, pico - 0.3) - 0.04, pico + 0.06))
150
+ return out
151
+
152
+
153
+ def intervalos(mask: "np.ndarray", hop: float = HOP) -> list:
154
+ out, i = [], 0
155
+ while i < len(mask):
156
+ if mask[i]:
157
+ j = i
158
+ while j < len(mask) and mask[j]:
159
+ j += 1
160
+ out.append((i * hop, j * hop))
161
+ i = j
162
+ else:
163
+ i += 1
164
+ return out
165
+
166
+
167
+ def lag_ms(a: "np.ndarray", b: "np.ndarray", t_a: float, t_b: float, meio: float = 0.25, busca: float = 0.05,
168
+ sr: int = SR_ENV) -> float | None:
169
+ """deslocamento (ms) do trecho de b em t_b em relação ao de a em t_a (janela de 2*meio s)"""
170
+ i, j, L, w = int(t_a * sr), int(t_b * sr), int(busca * sr), int(meio * sr)
171
+ x = a[i - w:i + w]
172
+ y = b[j - w - L:j + w + L]
173
+ if i - w < 0 or j - w - L < 0 or len(x) < 2 * w or len(y) < 2 * w + 2 * L or float((x ** 2).mean()) < 1e-7:
174
+ return None
175
+ c = np.correlate(y, x, mode="valid")
176
+ return (int(c.argmax()) - L) / sr * 1000
177
+
178
+
179
+ def loudness(p: str, af: str = "") -> dict:
180
+ r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", p, "-vn", "-af",
181
+ af + "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"],
182
+ capture_output=True, text=True).stderr
183
+ try:
184
+ return json.loads(r[r.rindex("{"): r.rindex("}") + 1])
185
+ except ValueError:
186
+ erro(f"não consegui medir o volume de {p}")
187
+
188
+
189
+ def cadeia_loudnorm(wav: str, alvo: float = -14.0, tp: float = -1.0) -> tuple[str, dict]:
190
+ """-14 LUFS em 2 passadas, sempre linear. Se o pico real passaria de -1 dBTP, um limitador com o atraso
191
+ compensado (latency=1, não desloca o áudio) segura só os picos antes."""
192
+ j = loudness(wav)
193
+ ganho = alvo - float(j["input_i"])
194
+ pre = ""
195
+ if float(j["input_tp"]) + ganho > tp - 0.2:
196
+ lim = 10 ** ((tp - ganho - 1.0) / 20)
197
+ pre = f"alimiter=limit={max(0.0625, min(1.0, lim)):.5f}:attack=5:release=60:level=false:latency=1,"
198
+ j = loudness(wav, pre)
199
+ af = (pre + f"loudnorm=I={alvo}:TP={tp}:LRA=11:measured_I={j['input_i']}:measured_TP={j['input_tp']}"
200
+ f":measured_LRA={j['input_lra']}:measured_thresh={j['input_thresh']}:offset={j['target_offset']}"
201
+ ":linear=true:print_format=json,aresample=48000")
202
+ return af, j
203
+
204
+ def finalizar(video: str, wav: str, saida: str) -> tuple[dict, str, dict]:
205
+ """junta a imagem (copiada) com o áudio em -14 LUFS e confere o pico real DEPOIS do AAC: o codificador pode
206
+ passar do teto em até ~2 dB. Se passou de -0,5 dBTP, refaz com o teto mais baixo (um limitador leve só nos
207
+ picos; o volume integrado continua -14 LUFS)."""
208
+ tp = -1.0
209
+ for tentativa in range(2):
210
+ af, j = cadeia_loudnorm(wav, tp=tp)
211
+ r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", video, "-i", wav, "-map", "0:v:0",
212
+ "-map", "1:a:0", "-c:v", "copy", "-af", af, "-c:a", "aac", "-b:a", "256k",
213
+ "-movflags", "+faststart", saida], capture_output=True, text=True)
214
+ if r.returncode:
215
+ erro("o ffmpeg falhou ao gravar o vídeo final:\n" + r.stderr[-1500:])
216
+ tipo = "linear"
217
+ try:
218
+ tipo = json.loads(r.stderr[r.stderr.rindex("{"): r.stderr.rindex("}") + 1]).get("normalization_type",
219
+ "linear")
220
+ except ValueError:
221
+ pass
222
+ fim = loudness(saida)
223
+ if float(fim["input_tp"]) <= -0.5:
224
+ break
225
+ tp = round(tp - (float(fim["input_tp"]) + 0.8), 2)
226
+ return j, tipo, fim
227
+
228
+
229
+
230
+ # ------------------------------------------------------------------ plano
231
+ def planejar(a, info: dict, det: "np.ndarray", mix_env: "np.ndarray", usa_voz: bool, act: "np.ndarray",
232
+ bloqueios: list, n_quadros: int) -> dict:
233
+ fps = float(info["fps"])
234
+ fd = 1.0 / fps
235
+ floor5, floor10, fala = (float(np.percentile(det, q)) for q in (5, 10, 90))
236
+ thr = a.limiar if a.limiar is not None else max(floor5 + 10, fala - 30)
237
+ fraco = max(floor10 + 6, thr - 12)
238
+ if fala - floor5 < 40 and a.limiar is None:
239
+ aviso(f"a diferença entre a fala ({fala:.0f} dB) e o fundo ({floor5:.0f} dB) é pequena. Se o vídeo já tem "
240
+ "trilha, rode o corte seco ANTES da trilha (ou passe --voz com o áudio só da voz).")
241
+ sil = det < thr
242
+ dur_det = len(det) * HOP
243
+ gaps = [(x, y) for x, y in intervalos(sil) if (y - x) >= a.pausa_min - 1e-6 and y < dur_det - 0.05]
244
+ if not gaps and not a.cortar:
245
+ print("nenhuma pausa maior que", a.pausa_min, "s: nada pra cortar")
246
+
247
+ # bloqueios: ataque dos efeitos sonoros do plano + (com --voz) o ataque de qualquer som que não é voz.
248
+ # A cauda de um efeito pode ser cortada (a emenda tem micro fade); o ataque não.
249
+ blq = list(bloqueios)
250
+ if usa_voz:
251
+ n = min(len(det), len(mix_env))
252
+ blq += [(x - 0.04, min(y, x + 0.10)) for x, y in intervalos((mix_env[:n] > thr) & (det[:n] < thr))
253
+ if y - x >= 0.03]
254
+
255
+ def bloqueado(s0, s1):
256
+ return any(s0 < y and s1 > x for x, y in blq)
257
+
258
+ def k_env(t):
259
+ return min(len(det) - 1, max(0, int(t / HOP)))
260
+
261
+ cortes, pulados = [], []
262
+ for x, y in gaps:
263
+ ini = x < 0.02
264
+ # bordas na onda: a palavra seguinte pode começar com consoante fraca ("S", "F") que o limiar não pega.
265
+ # Anda pra trás até 0,12 s enquanto houver som fraco e protege esse trecho também.
266
+ y2 = y
267
+ while y2 > y - ANDAR and det[k_env(y2 - HOP)] > fraco:
268
+ y2 -= HOP
269
+ if ini: # começo do vídeo: entra quase direto na 1ª palavra
270
+ k_lo, k_fim = 0, math.floor((y2 - a.inicio) * fps + 1e-6)
271
+ nL = k_fim
272
+ else:
273
+ lo = x + PRE0
274
+ hi = min(y - POS0, y2 - POS1)
275
+ k_lo, k_fim = math.ceil(lo * fps - 1e-6), math.floor(hi * fps + 1e-6) # quadros onde o corte cabe
276
+ nL = min(math.floor(((y - x) - a.pausa_max) * fps + 1e-6), k_fim - k_lo)
277
+ if nL < 2:
278
+ pulados.append(dict(pausa=[round(x, 3), round(y, 3)], motivo="pausa curta demais depois das folgas"))
279
+ continue
280
+
281
+ def busca(nL):
282
+ best = None
283
+ centro = (k_lo + k_fim - nL) / 2
284
+ for k in range(k_lo, (k_lo if ini else k_fim - nL) + 1):
285
+ if k + nL >= n_quadros:
286
+ break
287
+ s0, s1 = k * fd, (k + nL) * fd
288
+ custo = float(act[k: k + nL + 1].sum()) if len(act) else 0.0
289
+ if bloqueado(s0, s1):
290
+ custo += 1000
291
+ custo += 1e-3 * abs(k - centro)
292
+ if best is None or custo < best[0]:
293
+ best = (custo, k, nL)
294
+ return best
295
+
296
+ best = busca(nL)
297
+ while best is not None and best[0] >= 1000 and nL > 3: # encolhe até não pegar efeito sonoro
298
+ nL -= 1
299
+ best = busca(nL)
300
+ if best is None or best[0] >= 1000:
301
+ pulados.append(dict(pausa=[round(x, 3), round(y, 3)], motivo="só dava pra cortar em cima de efeito sonoro"))
302
+ continue
303
+ custo, k, nL = best
304
+ # trava de segurança: o trecho removido não pode ter voz acima do limiar
305
+ s0, s1 = k * fd, (k + nL) * fd
306
+ if float(det[k_env(s0):k_env(s1) + 1].max()) >= thr:
307
+ pulados.append(dict(pausa=[round(x, 3), round(y, 3)], motivo="encostaria na fala"))
308
+ continue
309
+ cortes.append(dict(pausa=[round(x, 3), round(y, 3)], q=[k, k + nL], removido=[round(s0, 4), round(s1, 4)],
310
+ dur=round(nL * fd, 4), sobra=round((y - x) - nL * fd, 3), imagem=round(custo, 2),
311
+ inicio=ini))
312
+
313
+ # cortes pedidos (ex.: tomada repetida que a transcrição juntou numa palavra só)
314
+ forc = []
315
+ if a.cortar:
316
+ for par in a.cortar.split(","):
317
+ try:
318
+ p0, p1 = (float(v) for v in par.strip().split("-"))
319
+ except ValueError:
320
+ erro(f"--cortar '{par}' inválido. Formato: 41.06-41.76,50.2-51.0 (segundos do vídeo de entrada)")
321
+ if p1 <= p0:
322
+ erro(f"--cortar '{par}': o fim tem que ser depois do começo")
323
+ k0, k1 = int(round(p0 * fps)), int(round(p1 * fps))
324
+ forc.append(dict(pausa=[p0, p1], q=[k0, k1], removido=[round(k0 * fd, 4), round(k1 * fd, 4)],
325
+ dur=round((k1 - k0) * fd, 4), forcado=True))
326
+ cortes = [c for c in cortes if not any(c["q"][0] < f["q"][1] and c["q"][1] > f["q"][0] for f in forc)]
327
+ cortes += forc
328
+ cortes.sort(key=lambda c: c["q"][0])
329
+ # junta cortes que se encostam
330
+ junt = []
331
+ for c in cortes:
332
+ if junt and c["q"][0] <= junt[-1]["q"][1]:
333
+ junt[-1]["q"][1] = max(junt[-1]["q"][1], c["q"][1])
334
+ junt[-1]["removido"][1] = round(junt[-1]["q"][1] * fd, 4)
335
+ junt[-1]["dur"] = round((junt[-1]["q"][1] - junt[-1]["q"][0]) * fd, 4)
336
+ else:
337
+ junt.append(c)
338
+ cortes = junt
339
+ manter, t = [], 0
340
+ for c in cortes:
341
+ if c["q"][0] > t:
342
+ manter.append([t, c["q"][0]])
343
+ t = max(t, c["q"][1])
344
+ if t < n_quadros:
345
+ manter.append([t, n_quadros])
346
+ return dict(limiar_db=round(thr, 1), fraco_db=round(fraco, 1), fundo_db=round(floor5, 1), fala_db=round(fala, 1),
347
+ cortes=cortes, pulados=pulados, manter_q=manter)
348
+
349
+
350
+ # ------------------------------------------------------------------ render
351
+ def renderizar(a, info: dict, plano: dict, saida: str, td: str) -> dict:
352
+ fps = info["fps"]
353
+ num, den = fps.numerator, fps.denominator
354
+ manter = plano["manter_q"]
355
+ # vídeo: select pelos números dos quadros (um decodificador só, exato no quadro)
356
+ sel = "+".join(f"between(n\\,{k0}\\,{k1 - 1})" for k0, k1 in manter)
357
+ vf = f"select={sel},setpts=N*{den}/{num}/TB"
358
+ script = os.path.join(td, "vf.txt")
359
+ Path(script).write_text(vf)
360
+ v_tmp = os.path.join(td, "video.mp4")
361
+ extra = []
362
+ v = info["v"]
363
+ for chave, opt in (("color_space", "-colorspace"), ("color_primaries", "-color_primaries"),
364
+ ("color_transfer", "-color_trc"), ("color_range", "-color_range")):
365
+ if v.get(chave) and v[chave] not in ("unknown", "reserved"):
366
+ extra += [opt, v[chave]]
367
+ preset = "slow" if info["dur_v"] <= 180 else "medium"
368
+ run(["ffmpeg", "-v", "error", "-y", "-i", a.video, "-an", "-/vf", script, "-fps_mode", "cfr", "-r", f"{num}/{den}",
369
+ "-c:v", "libx264", "-preset", preset, "-crf", str(a.crf), "-pix_fmt", "yuv420p", *extra, v_tmp])
370
+ # áudio: corte por amostra, com micro fade em cada emenda
371
+ sr, ch = info["sr"], info["ch"]
372
+ raw = run(["ffmpeg", "-v", "error", "-i", a.video, "-vn", "-f", "f32le", "-acodec", "pcm_f32le", "-"],
373
+ texto=False).stdout
374
+ au = np.frombuffer(raw, dtype=np.float32).reshape(-1, ch)
375
+ off = info["v0"] - info["a0"] # áudio que começa depois do vídeo (raro em vídeo renderizado)
376
+ nf = int(FADE * sr)
377
+ pedacos = []
378
+ for i, (k0, k1) in enumerate(manter):
379
+ s0 = max(0, int(round((k0 * den / num + off) * sr)))
380
+ s1 = min(len(au), int(round((k1 * den / num + off) * sr)))
381
+ p = au[s0:s1].copy()
382
+ rampa = np.linspace(0, 1, min(nf, len(p)), dtype=np.float32)[:, None]
383
+ if i > 0 and len(p):
384
+ p[: len(rampa)] *= rampa
385
+ if i < len(manter) - 1 and len(p):
386
+ p[len(p) - len(rampa):] *= rampa[::-1]
387
+ pedacos.append(p)
388
+ alvo = sum(k1 - k0 for k0, k1 in manter) * den / num
389
+ au_out = np.concatenate(pedacos) if pedacos else np.zeros((0, ch), np.float32)
390
+ falta = int(round(alvo * sr)) - len(au_out)
391
+ if falta > 0: # áudio mais curto que o vídeo no fim: completa com silêncio
392
+ au_out = np.concatenate([au_out, np.zeros((falta, ch), np.float32)])
393
+ a_tmp = os.path.join(td, "audio.wav")
394
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "f32le", "-ar", str(sr), "-ac", str(ch), "-i", "-",
395
+ "-c:a", "pcm_f32le", a_tmp], input=au_out.tobytes(), check=True)
396
+ j, tipo, _ = finalizar(v_tmp, a_tmp, saida)
397
+ return dict(volume_antes=float(j["input_i"]), ajuste=tipo)
398
+
399
+
400
+ def mapa_tempo(manter: list, fd: float) -> list:
401
+ """[[início na entrada, fim na entrada, início na saída], ...] em segundos"""
402
+ out, t = [], 0
403
+ for k0, k1 in manter:
404
+ out.append([round(k0 * fd, 4), round(k1 * fd, 4), round(t * fd, 4)])
405
+ t += k1 - k0
406
+ return out
407
+
408
+
409
+ def conferir(a, info: dict, plano: dict, saida: str, mini_src, thr_entrada: float, lufs_antes: float) -> dict:
410
+ fps = float(info["fps"])
411
+ fd = 1 / fps
412
+ manter = plano["manter_q"]
413
+ res = {}
414
+ so = sonda(saida)
415
+ esperado = sum(k1 - k0 for k0, k1 in manter) * fd
416
+ res["duracao_esperada"] = round(esperado, 3)
417
+ res["duracao_video"] = round(so.get("dur_v", 0), 3)
418
+ res["duracao_audio"] = round(so.get("dur_a", 0), 3)
419
+ # imagem: cada quadro da saída tem que ser o quadro certo da entrada
420
+ if mini_src is not None:
421
+ idx = np.concatenate([np.arange(k0, k1) for k0, k1 in manter]) if manter else np.zeros(0, int)
422
+ lh, lw = mini_src.shape[1] * 2, mini_src.shape[2] * 2
423
+ certos = total = 0
424
+ piores = []
425
+ for j, f in enumerate(quadros(saida, lw, lh)):
426
+ if j >= len(idx):
427
+ break
428
+ m = f.reshape(lh, lw)[::2, ::2].astype(np.int16)
429
+ e = int(idx[j])
430
+ viz = {d: float(np.abs(m - mini_src[e + d]).mean()) for d in (-2, -1, 0, 1, 2) if 0 <= e + d < len(mini_src)}
431
+ total += 1
432
+ if viz[0] <= min(viz.values()) + 0.35:
433
+ certos += 1
434
+ else:
435
+ piores.append(j)
436
+ res["quadros_conferidos"] = total
437
+ res["quadros_no_lugar"] = certos
438
+ res["quadros_fora"] = [round(j * fd, 3) for j in piores[:10]]
439
+ # som: cada trecho mantido tem que estar no mesmo lugar da imagem (correlação com a entrada)
440
+ ent, sai = audio_mono(a.video), audio_mono(saida)
441
+ lags = []
442
+ for (t_in0, t_in1, t_out0) in mapa_tempo(manter, fd):
443
+ if t_in1 - t_in0 < 0.7:
444
+ continue
445
+ meio = (t_in1 - t_in0) / 2
446
+ l = lag_ms(ent, sai, t_in0 + meio, t_out0 + meio)
447
+ if l is not None:
448
+ lags.append(abs(l))
449
+ res["trechos_de_som_conferidos"] = len(lags)
450
+ res["desvio_som_ms"] = round(max(lags), 2) if lags else None
451
+ # pausas que sobraram (medidas na saída)
452
+ fim = loudness(saida)
453
+ res["lufs_final"] = float(fim["input_i"])
454
+ res["pico_final_dbtp"] = float(fim["input_tp"])
455
+ ganho = res["lufs_final"] - lufs_antes # a saída foi normalizada: o limiar acompanha o ganho
456
+ es = env_db(sai)
457
+ sobras = [(x, y) for x, y in intervalos(es < thr_entrada + ganho)
458
+ if (y - x) > a.pausa_max + 0.07 and x > 0.02 and y < len(es) * HOP - 0.05]
459
+ res["pausas_que_sobraram"] = [[round(x, 2), round(y - x, 2)] for x, y in sobras]
460
+ return res
461
+
462
+
463
+ def main(argv=None):
464
+ try:
465
+ sys.stdout.reconfigure(line_buffering=True) # mensagens aparecem na hora, mesmo em log
466
+ except AttributeError:
467
+ pass
468
+ ap = argparse.ArgumentParser(description="Tira as pausas que sobraram no vídeo pronto, sem cortar palavra.")
469
+ ap.add_argument("video", help="vídeo final já renderizado (sem trilha, de preferência)")
470
+ ap.add_argument("--pausa-max", type=float, default=0.15, help="silêncio que fica entre as frases (0.15 s)")
471
+ ap.add_argument("--pausa-min", type=float, default=0.25, help="só mexe em pausas a partir disso (0.25 s)")
472
+ ap.add_argument("--sem-grafismo", metavar="RENDER", help="render só com os grafismos (sem a pessoa), mesma duração")
473
+ ap.add_argument("--sfx", metavar="PLANO", help="plano de sons (plano_sfx.json): não corta no ataque de efeito")
474
+ ap.add_argument("--voz", metavar="AUDIO", help="áudio só da voz no mesmo tempo do vídeo (ex.: o cut.mp4 do corte, Fase 1)")
475
+ ap.add_argument("--cortar", help="trechos a tirar de qualquer jeito, em segundos: 41.06-41.76,50.2-51.0")
476
+ ap.add_argument("--inicio", type=float, default=0.08, help="silêncio antes da 1ª palavra (0.08 s)")
477
+ ap.add_argument("--limiar", type=float, help="dBFS abaixo do qual é silêncio (padrão: medido no vídeo)")
478
+ ap.add_argument("--crf", type=int, default=16, help="qualidade do vídeo (16 = muito alta)")
479
+ ap.add_argument("--saida", help="arquivo de saída (padrão: <video>_seco.mp4)")
480
+ ap.add_argument("--so-plano", action="store_true", help="só mostra e salva o plano, sem renderizar")
481
+ a = ap.parse_args(argv)
482
+
483
+ for b in ("ffmpeg", "ffprobe"):
484
+ if not shutil.which(b):
485
+ erro(f"não achei o {b}. Instale o ffmpeg (no Mac: brew install ffmpeg).")
486
+ if not Path(a.video).exists():
487
+ erro(f"não achei {a.video}")
488
+ if not 0.05 <= a.pausa_max <= 1.0:
489
+ erro("--pausa-max fica entre 0.05 e 1.0 s (o André usa 0.15)")
490
+ if a.pausa_min < a.pausa_max + 0.05:
491
+ a.pausa_min = round(a.pausa_max + 0.1, 3)
492
+ saida = a.saida or str(Path(a.video).with_name(Path(a.video).stem + "_seco.mp4"))
493
+ if Path(saida).resolve() == Path(a.video).resolve():
494
+ erro("a saída não pode ser o mesmo arquivo da entrada")
495
+ info = sonda(a.video)
496
+ if not info.get("v") or not info.get("a"):
497
+ erro("o vídeo precisa ter imagem e som")
498
+ fps = float(info["fps"])
499
+ fd = 1 / fps
500
+
501
+ print(f"lendo {Path(a.video).name}: {info['dur_v']:.2f} s, {info['w']}x{info['h']}, {fps:g} quadros/s")
502
+ mix = audio_mono(a.video)
503
+ mix_env = env_db(mix)
504
+ usa_voz = False
505
+ det = mix_env
506
+ if a.voz:
507
+ if not Path(a.voz).exists():
508
+ erro(f"não achei {a.voz}")
509
+ voz = audio_mono(a.voz)
510
+ lags = []
511
+ for t in np.linspace(2, min(len(voz), len(mix)) / SR_ENV - 2, 8):
512
+ l = lag_ms(voz, mix, float(t), float(t))
513
+ if l is not None:
514
+ lags.append(abs(l))
515
+ if lags and max(lags) <= 20:
516
+ det, usa_voz = env_db(voz), True
517
+ else:
518
+ aviso("o áudio de --voz não está no mesmo tempo do vídeo; vou medir as pausas no próprio vídeo")
519
+ bloqueios = carregar_sfx(a.sfx) if a.sfx else []
520
+
521
+ act_final, mini = atividade(a.video, info["w"], info["h"], guardar=not a.so_plano)
522
+ n_quadros = len(act_final) or int(round(info["dur_v"] * fps))
523
+ act = act_final
524
+ fonte_img = "diferença entre quadros do próprio vídeo"
525
+ if a.sem_grafismo:
526
+ ig = sonda(a.sem_grafismo)
527
+ if not ig.get("v"):
528
+ erro(f"{a.sem_grafismo} não tem vídeo")
529
+ act_g, _ = atividade(a.sem_grafismo, ig["w"], ig["h"])
530
+ if abs(len(act_g) - n_quadros) <= 1:
531
+ act = act_g[:n_quadros] if len(act_g) >= n_quadros else np.pad(act_g, (0, n_quadros - len(act_g)))
532
+ fonte_img = "render só com grafismo"
533
+ else:
534
+ aviso(f"o render de grafismo tem {len(act_g)} quadros e o vídeo {n_quadros}; usei o próprio vídeo")
535
+
536
+ plano = planejar(a, info, det, mix_env, usa_voz, act, bloqueios, n_quadros)
537
+ cortes = plano["cortes"]
538
+ removido = sum(c["dur"] for c in cortes)
539
+ dur_saida = sum(k1 - k0 for k0, k1 in plano["manter_q"]) * fd
540
+ print(f"limiar de silêncio {plano['limiar_db']} dB (fala {plano['fala_db']}, fundo {plano['fundo_db']}) · "
541
+ f"ponto de corte pela {fonte_img}" + (" · pausas medidas no áudio da voz" if usa_voz else ""))
542
+ print(f"{len(cortes)} cortes, {removido:.2f} s fora: {info['dur_v']:.2f} -> {dur_saida:.2f} s")
543
+ for c in cortes:
544
+ if c.get("forcado"):
545
+ print(f" corte pedido {c['removido'][0]:7.3f} - {c['removido'][1]:7.3f} ({c['dur']:.3f} s)")
546
+ else:
547
+ print(f" pausa {c['pausa'][0]:7.2f}-{c['pausa'][1]:6.2f} tira {c['removido'][0]:7.3f}-{c['removido'][1]:7.3f}"
548
+ f" ({c['dur']:.3f} s, sobra {c['sobra']:.2f} s, imagem {c['imagem']:.1f})"
549
+ + (" começo" if c.get("inicio") else ""))
550
+ for p in plano["pulados"]:
551
+ print(f" mantida: pausa {p['pausa'][0]:.2f}-{p['pausa'][1]:.2f} ({p['motivo']})")
552
+ rel = dict(video=a.video, saida=saida, pausa_max=a.pausa_max, pausa_min=a.pausa_min, fps=fps,
553
+ ponto_de_corte=fonte_img, sfx=a.sfx, voz=a.voz if usa_voz else None,
554
+ limiar_db=plano["limiar_db"], fraco_db=plano["fraco_db"], cortes=cortes, mantidas=plano["pulados"],
555
+ removido_s=round(removido, 3), duracao_entrada=round(info["dur_v"], 3), duracao_saida=round(dur_saida, 3),
556
+ mapa=mapa_tempo(plano["manter_q"], fd))
557
+ rel_path = (saida + ".corte.json")
558
+ if a.so_plano:
559
+ Path(rel_path).write_text(json.dumps(rel, ensure_ascii=False, indent=1))
560
+ print(f"plano salvo em {rel_path} (nada foi renderizado)")
561
+ return
562
+ with tempfile.TemporaryDirectory() as td:
563
+ r = renderizar(a, info, plano, saida, td)
564
+ conf = conferir(a, info, plano, saida, mini, plano["limiar_db"], r["volume_antes"])
565
+ rel["conferencia"] = conf
566
+ rel["ajuste_volume"] = r["ajuste"]
567
+ Path(rel_path).write_text(json.dumps(rel, ensure_ascii=False, indent=1))
568
+ print(f"saída: {saida}")
569
+ print(f" duração vídeo {conf['duracao_video']:.3f} s, áudio {conf['duracao_audio']:.3f} s "
570
+ f"(esperado {conf['duracao_esperada']:.3f}) · volume {conf['lufs_final']} LUFS, pico "
571
+ f"{conf['pico_final_dbtp']} dBTP (ajuste {r['ajuste']})")
572
+ if "quadros_conferidos" in conf:
573
+ print(f" imagem: {conf['quadros_no_lugar']} de {conf['quadros_conferidos']} quadros no lugar certo · som: "
574
+ f"{conf['trechos_de_som_conferidos']} trechos conferidos, desvio máximo {conf['desvio_som_ms']} ms")
575
+ if conf["pausas_que_sobraram"]:
576
+ print(f" pausas acima de {a.pausa_max + 0.07:.2f} s que ficaram (efeito sonoro ou som fraco na borda): "
577
+ + ", ".join(f"{x:.2f} s ({d:.2f})" for x, d in conf["pausas_que_sobraram"][:12]))
578
+ else:
579
+ print(f" nenhuma pausa acima de {a.pausa_max + 0.07:.2f} s ficou")
580
+ ok = True
581
+ if abs(conf["duracao_video"] - conf["duracao_esperada"]) > 1.5 * fd or abs(conf["duracao_audio"] - conf["duracao_video"]) > 0.05:
582
+ aviso("a duração não bate com o esperado; confira antes de entregar")
583
+ ok = False
584
+ if conf.get("desvio_som_ms") is not None and conf["desvio_som_ms"] > 5:
585
+ aviso("o som escorregou em relação à imagem; não entregue sem conferir")
586
+ ok = False
587
+ if "quadros_conferidos" in conf and conf["quadros_no_lugar"] < conf["quadros_conferidos"] * 0.98:
588
+ aviso(f"quadros fora do lugar em {conf['quadros_fora']}; confira")
589
+ ok = False
590
+ print(" tudo certo" if ok else " ATENÇÃO: veja os avisos acima")
591
+ print(f"relatório: {rel_path}")
592
+
593
+
594
+ if __name__ == "__main__":
595
+ main()