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,820 @@
1
+ """Numeric self-check of a rendered cut. Prints a short PT-BR report and writes
2
+ edit/verify/cut_report.json.
3
+
4
+ Usage:
5
+ uv run python helpers/verify_cut.py <edit>/edl.json <edit>/cut.mp4 [--min-silence 0.7] [--ask-approval]
6
+
7
+ Checks (each problem becomes a FLAG with a timestamp on the cut):
8
+ DURATION video/audio/timeline durations and frame counts disagree
9
+ STALE-TIMELINE the EDL changed after the render (edl_sig differs) or no timeline found
10
+ PREVIEW the file is a low-resolution --preview render
11
+ SOURCE-MISSING a source of the EDL cannot be found or its audio cannot be read (edges unchecked)
12
+ SILENT the cut has no sound at all
13
+ CLICK sample jump at a junction boundary far above the local signal
14
+ EDGE-IN-SPEECH a range starts or ends while the source is still sounding (cut inside a word)
15
+ EDGE-IN-WORD a range edge falls inside a transcribed word and the gap there is only a
16
+ consonant closure (t, k, p): the rest of the word is cut off
17
+ REPEAT a range replays speech of the previous range of the same source
18
+ DEAD-AIR silence >= --min-silence inside the cut (with a concrete start/end to use)
19
+ BLACK black frames
20
+ CLIPPING clipped samples or true peak above -1.0 dBTP (ebur128 or the 2-decimal loudnorm reading)
21
+ LOUDNESS integrated loudness more than 1.5 LU away from -14 LUFS
22
+ LOW-LEVEL a range more than 4 dB under the reference level (speech-weighted median of the
23
+ heard range levels), or the farther one (from the reference) of two neighbours, or of
24
+ the quietest and loudest ranges, more than 6 dB apart. The message gives the final
25
+ gain_db to write in the EDL: it closes the gap between the SOURCE levels (gain_db
26
+ works before loudnorm, whose dynamic mode squeezes the gap heard in the cut)
27
+ HIGH-LEVEL the same, over the reference level
28
+ Notices that are not flags (HDR source, crop without a face) go to "warnings".
29
+
30
+ state.json: when the file is the cut of the edit dir, verify asks for the approval
31
+ (awaiting approval_cut) only when there are no flags, or with --ask-approval after the flags
32
+ were explained. Otherwise awaiting stays null.
33
+ Exit code 0 even with flags; 2 when the EDL or the video cannot be read.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import math
41
+ import re
42
+ import subprocess
43
+ import sys
44
+ from concurrent.futures import ThreadPoolExecutor
45
+ from pathlib import Path
46
+
47
+ import numpy as np
48
+
49
+ from common import OUTPUT_FORMATS, file_signature, fmt_time, load_json, now_iso, probe_video, save_json
50
+ from contact_sheet import abspath, find_timeline
51
+
52
+ SR = 48000
53
+ TARGET_I = -14.0
54
+ TRUE_PEAK_LIMIT = -1.0
55
+ LEVEL_DELTA_DB = 4.0
56
+ ADJACENT_JUMP_DB = 6.0
57
+ GAIN_CAP_DB = 12.0
58
+ CLICK_RATIO = 12.0
59
+ CLICK_MIN_JUMP = 0.02
60
+ EDGE_FLOOR_DB = -50.0
61
+ EDGE_ABOVE_NOISE_DB = 15.0 # an edge is on speech only this far above the source's own noise floor:
62
+ # breath tails and room hiss 10 dB over the floor flooded the report, and
63
+ # a report full of false alarms got real cut words ignored (Criativo 01)
64
+ WORD_MARGIN_S = 0.08 # the quiet run must sit at least this far inside the word on both sides
65
+ WORD_GAP_MAX_S = 0.12 # a quiet run shorter than this around the edge is a closure, not a pause
66
+ WORD_REACH_S = 0.15 # sound must exist on both sides within this distance
67
+ SILENT_DB = -60.0
68
+ SEAMLESS_S = 0.02 # next range restarts the source this close to where the previous ended
69
+ BLOCKS_APPROVAL = {"STALE-TIMELINE", "DURATION", "PREVIEW"}
70
+
71
+
72
+ def db(x: float) -> float:
73
+ return 20 * math.log10(max(float(x), 1e-9))
74
+
75
+
76
+ def decode_audio(path: Path, start: float = 0.0, dur: float | None = None, mono: bool = True,
77
+ af: str | None = None) -> np.ndarray:
78
+ cmd = ["ffmpeg", "-v", "error", "-nostdin"]
79
+ if start > 0:
80
+ cmd += ["-ss", f"{start:.6f}"]
81
+ cmd += ["-i", str(path)]
82
+ if dur is not None:
83
+ cmd += ["-t", f"{dur:.6f}"]
84
+ if af:
85
+ cmd += ["-af", af]
86
+ cmd += ["-vn", "-ac", "1" if mono else "2", "-ar", str(SR), "-f", "f32le", "-"]
87
+ proc = subprocess.run(cmd, capture_output=True)
88
+ if proc.returncode != 0:
89
+ return np.zeros(0 if mono else (0, 2), dtype=np.float32)
90
+ data = np.frombuffer(proc.stdout, dtype=np.float32)
91
+ return data if mono else data.reshape(-1, 2)
92
+
93
+
94
+ def rms_frames(x: np.ndarray, win_s: float) -> np.ndarray:
95
+ win = max(1, int(win_s * SR))
96
+ n = x.size // win
97
+ if n == 0:
98
+ return np.zeros(0)
99
+ return np.sqrt(np.mean(x[: n * win].reshape(n, win) ** 2, axis=1))
100
+
101
+
102
+ def power_mean_db(values_db: np.ndarray) -> float:
103
+ return float(10 * np.log10(np.mean(10 ** (values_db / 10)))) if values_db.size else -120.0
104
+
105
+
106
+ def weighted_median(values: list[float], weights: list[float]) -> float:
107
+ """Level that half of the speech time sits at or above (ties go to the louder level)."""
108
+ pairs = sorted(zip(values, weights), key=lambda p: -p[0])
109
+ total = sum(max(w, 1e-6) for _, w in pairs)
110
+ acc = 0.0
111
+ for v, w in pairs:
112
+ acc += max(w, 1e-6)
113
+ if acc >= total / 2 - 1e-9:
114
+ return v
115
+ return pairs[-1][0]
116
+
117
+
118
+ def ffmpeg_stderr(cmd: list[str]) -> str:
119
+ return subprocess.run(cmd, capture_output=True, text=True).stderr
120
+
121
+
122
+ def fmt_db(value: float | None) -> str:
123
+ return "--" if value is None else f"{value:.1f}"
124
+
125
+
126
+ # ------------------------------------------------------------------ checks
127
+
128
+
129
+ def loudness_stats(video: Path) -> dict:
130
+ err = ffmpeg_stderr(["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-i", str(video), "-vn",
131
+ "-af", "ebur128=peak=true:framelog=quiet", "-f", "null", "-"])
132
+ tail = err[err.rfind("Summary:"):] if "Summary:" in err else err
133
+
134
+ def grab(pattern: str) -> float | None:
135
+ m = re.search(pattern, tail)
136
+ if not m:
137
+ return None
138
+ return -120.0 if "inf" in m.group(1) else float(m.group(1))
139
+
140
+ return {"integrated": grab(r"I:\s+(-?inf|-?[\d.]+) LUFS"), "lra": grab(r"LRA:\s+(-?inf|-?[\d.]+) LU"),
141
+ "true_peak": grab(r"Peak:\s+(-?inf|-?[\d.]+) dBFS")}
142
+
143
+
144
+ def black_frames(video: Path) -> tuple[list[dict], int]:
145
+ """Black intervals and the number of frames that really decode (full decode pass)."""
146
+ proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-i", str(video), "-an",
147
+ "-vf", "scale=180:-2,blackdetect=d=0.08:pic_th=0.97:pix_th=0.10",
148
+ "-progress", "pipe:1", "-f", "null", "-"], capture_output=True, text=True)
149
+ out = []
150
+ for m in re.finditer(r"black_start:(-?[\d.]+) black_end:(-?[\d.]+) black_duration:([\d.]+)", proc.stderr):
151
+ out.append({"start": float(m.group(1)), "end": float(m.group(2)), "duration": float(m.group(3))})
152
+ decoded = [int(x) for x in re.findall(r"^frame=(\d+)", proc.stdout, flags=re.M)]
153
+ return out, (decoded[-1] if decoded else 0)
154
+
155
+
156
+ def dead_air(mono: np.ndarray, noise_db: float, min_silence: float) -> list[dict]:
157
+ """Silence runs >= min_silence on 20 ms RMS windows (a 3-window median ignores single bumps).
158
+
159
+ ffmpeg's silencedetect tests every sample's amplitude, so room noise peaks split real pauses
160
+ into short pieces after loudness normalization; RMS windows measure what the ear hears.
161
+ """
162
+ win = int(0.02 * SR)
163
+ n = mono.size // win
164
+ if n < 3:
165
+ return []
166
+ vals = 20 * np.log10(np.maximum(np.sqrt(np.mean(mono[: n * win].reshape(n, win) ** 2, axis=1)), 1e-9))
167
+ smooth = np.median(np.stack([np.r_[vals[:1], vals[:-1]], vals, np.r_[vals[1:], vals[-1:]]]), axis=0)
168
+ quiet = smooth < noise_db
169
+ out, i = [], 0
170
+ while i < n:
171
+ if not quiet[i]:
172
+ i += 1
173
+ continue
174
+ j = i
175
+ while j < n and quiet[j]:
176
+ j += 1
177
+ a, b = i * 0.02, min(j * 0.02, mono.size / SR)
178
+ if b - a >= min_silence - 1e-3:
179
+ out.append({"start": round(a, 3), "end": round(b, 3), "duration": round(b - a, 3)})
180
+ i = j
181
+ return out
182
+
183
+
184
+ def click_probe(audio: np.ndarray, t: float) -> dict:
185
+ """Max first-difference in +-10 ms around t vs the RMS of the first difference in +-300 ms."""
186
+ c = int(round(t * SR))
187
+ lo, hi = max(1, c - int(0.3 * SR)), min(audio.shape[0], c + int(0.3 * SR))
188
+ if hi - lo < int(0.05 * SR):
189
+ return {"jump": 0.0, "ratio": 0.0}
190
+ seg = audio[lo - 1:hi]
191
+ d = np.abs(np.diff(seg, axis=0))
192
+ if d.ndim == 2:
193
+ d = d.max(axis=1)
194
+ k0, k1 = max(0, c - lo - int(0.01 * SR)), min(d.size, c - lo + int(0.01 * SR))
195
+ near = d[k0:k1]
196
+ mask = np.ones(d.size, dtype=bool)
197
+ mask[k0:k1] = False
198
+ base = float(np.sqrt(np.mean(d[mask] ** 2))) if mask.any() else 0.0
199
+ jump = float(near.max()) if near.size else 0.0
200
+ return {"jump": round(jump, 5), "ratio": round(jump / max(base, 1e-5), 2)}
201
+
202
+
203
+ def loudest_channel(x: np.ndarray) -> np.ndarray:
204
+ """Stereo -> the louder channel (a phase-cancelled pair would vanish in an L+R mix)."""
205
+ if x.ndim == 1 or x.shape[0] == 0:
206
+ return x.reshape(-1)
207
+ return x[:, int(np.argmax(np.mean(x ** 2, axis=0)))]
208
+
209
+
210
+ RUMBLE_AF = "highpass=f=300,highpass=f=300" # same filter as bordas_precisas: car, road, AC rumble out
211
+ RUMBLE_RANGE_DB = 20.0 # a source whose voice stands less than this over its floor
212
+
213
+
214
+ def noise_floor_db(src: Path) -> tuple[float | None, str | None]:
215
+ """(room noise of a whole source, audio filter its edges are measured with). The floor is the 10th
216
+ percentile of the 10 ms level of the louder channel. A source with rumble (voice less than
217
+ RUMBLE_RANGE_DB over the floor) is measured without the lows, or the rumble reads as speech at every edge."""
218
+ for af in (None, RUMBLE_AF):
219
+ x = loudest_channel(decode_audio(src, mono=False, af=af))
220
+ vals = rms_frames(x, 0.01)
221
+ if not vals.size:
222
+ return None, None
223
+ lo, hi = db(np.percentile(vals, 10)), db(np.percentile(vals, 90))
224
+ if hi - lo >= RUMBLE_RANGE_DB or af:
225
+ return float(lo), af
226
+ return None, None
227
+
228
+
229
+ def edge_probe(src: Path, t_edge: float, side: str, ref_db: float, floor_db: float | None = None,
230
+ af: str | None = None) -> dict:
231
+ """Level just inside and just outside a source cut edge (40 ms each)."""
232
+ w = 0.04
233
+ x = loudest_channel(decode_audio(src, max(0.0, t_edge - w), 2 * w, mono=False, af=af))
234
+ half = x.size // 2
235
+ before = db(np.sqrt(np.mean(x[:half] ** 2))) if half else -120.0
236
+ after = db(np.sqrt(np.mean(x[half:] ** 2))) if x.size - half > 0 else -120.0
237
+ inside, outside = (after, before) if side == "start" else (before, after)
238
+ gate = max(ref_db - 20.0, EDGE_FLOOR_DB,
239
+ floor_db + EDGE_ABOVE_NOISE_DB if floor_db is not None else EDGE_FLOOR_DB)
240
+ return {"inside_db": round(inside, 1), "outside_db": round(outside, 1), "gate_db": round(gate, 1),
241
+ "in_speech": bool(inside > gate and outside > gate and t_edge > w)}
242
+
243
+
244
+ def word_at(transcript: dict | None, t: float) -> dict | None:
245
+ if not transcript:
246
+ return None
247
+ for w in transcript.get("words", []):
248
+ if float(w.get("start", 0)) < t < float(w.get("end", 0)):
249
+ return w
250
+ return None
251
+
252
+
253
+ def word_edge_probe(src: Path, t_edge: float, ref_db: float, transcript: dict | None,
254
+ af: str | None = None) -> dict | None:
255
+ """Edge inside a transcribed word: is the quiet run around it only a consonant closure?
256
+
257
+ A stop consonant (t, k, p) makes 50-60 ms of near silence inside a word, so the 40 ms level
258
+ probe reads one side as quiet. Here the 10 ms level shows a quiet run shorter than
259
+ WORD_GAP_MAX_S with sound on both sides, and that run sits inside a transcribed word with
260
+ WORD_MARGIN_S of the word on each side: the word goes on past the edge. A pause between
261
+ words is longer, or lies at a word boundary (Whisper word starts drift into short pauses,
262
+ hence the margin). Calibrated on the test footage: 90% of mid-word edges caught, no hit on
263
+ pause edges with the SKILL padding nor on the edges of the real sessions.
264
+ """
265
+ w = word_at(transcript, t_edge)
266
+ if w is None:
267
+ return None
268
+ t0 = max(0.0, t_edge - WORD_REACH_S)
269
+ x = loudest_channel(decode_audio(src, t0, t_edge + WORD_REACH_S - t0, mono=False, af=af))
270
+ vals = np.array([db(v) for v in rms_frames(x, 0.01)])
271
+ if vals.size < 4:
272
+ return None
273
+ gate = max(ref_db - 20.0, EDGE_FLOOR_DB)
274
+ loud = vals > gate
275
+ c = min(max(int((t_edge - t0) / 0.01), 0), vals.size - 1)
276
+ lo = hi = c
277
+ if not loud[c]:
278
+ while lo > 0 and not loud[lo - 1]:
279
+ lo -= 1
280
+ while hi < vals.size - 1 and not loud[hi + 1]:
281
+ hi += 1
282
+ gap = (hi - lo + 1) * 0.01
283
+ else:
284
+ gap = 0.0
285
+ g0, g1 = t0 + lo * 0.01, t0 + (hi + 1) * 0.01
286
+ hit = bool(gap < WORD_GAP_MAX_S and loud[:lo].any() and loud[hi + 1:].any()
287
+ and float(w["start"]) + WORD_MARGIN_S <= g0 and g1 <= float(w["end"]) - WORD_MARGIN_S)
288
+ return {"word": str(w.get("text", "")).strip(), "word_start": float(w["start"]), "word_end": float(w["end"]),
289
+ "gap_s": round(gap, 2), "in_word": hit}
290
+
291
+
292
+ # ------------------------------------------------------------------ state
293
+
294
+
295
+ def update_state(edit_dir: Path, video: Path, flags: list[dict], ask_approval: bool) -> tuple[str | None, bool | None]:
296
+ """Ask for the approval of the cut in state.json only after this check.
297
+
298
+ Returns (PT-BR line, approval requested) or (None, None) when the video is not the cut of the edit dir."""
299
+ state_path = edit_dir / "state.json"
300
+ state = load_json(state_path, default={}) or {}
301
+ if not isinstance(state, dict):
302
+ state = {}
303
+ if video.parent != edit_dir or video.name != (state.get("cut_video") or "cut.mp4"):
304
+ return None, None
305
+ blocking = sorted({f["code"] for f in flags if f["code"] in BLOCKS_APPROVAL})
306
+ ready = not blocking and (not flags or ask_approval)
307
+ if ready:
308
+ state.update(phase="cut", awaiting="approval_cut",
309
+ message="Corte pronto. Assista e aprove ou marque o que mudar.")
310
+ line = "Preview: aprovação do corte pedida."
311
+ else:
312
+ n = len(flags)
313
+ state.update(phase="cut", awaiting=None,
314
+ message=f"Conferindo o corte: {n} {'ponto' if n == 1 else 'pontos'} para ajustar antes de mostrar.")
315
+ if blocking:
316
+ line = f"Preview: aprovação NÃO pedida ({', '.join(blocking)}): renderize de novo."
317
+ else:
318
+ line = ("Preview: aprovação ainda não pedida. Corrija e renderize de novo, ou rode com "
319
+ "--ask-approval depois de explicar os avisos que vão ficar.")
320
+ state["updated_at"] = now_iso()
321
+ save_json(state_path, state)
322
+ return line, ready
323
+
324
+
325
+ # ------------------------------------------------------------------ main
326
+
327
+
328
+ def verify(edl_path: Path, video: Path, min_silence: float) -> tuple[dict, list[str]]:
329
+ from render_cut import ASPECT_WARN, edl_signature, resolve_source, true_peak_db
330
+
331
+ edl = load_json(edl_path)
332
+ edit_dir = edl_path.parent
333
+ info = probe_video(video)
334
+ if not info.get("has_video") or not info.get("duration"):
335
+ raise ValueError("o vídeo não tem imagem ou duração")
336
+ flags: list[dict] = []
337
+ warnings: list[str] = []
338
+
339
+ def flag(code: str, t: float | None, message: str, rid: str | None = None, end: float | None = None) -> None:
340
+ item = {"code": code, "time": None if t is None else round(t, 3), "message": message}
341
+ if end is not None:
342
+ item["end"] = round(end, 3)
343
+ if rid:
344
+ item["range"] = rid
345
+ flags.append(item)
346
+
347
+ # ---- duration vs timeline
348
+ tl_path = find_timeline(video)
349
+ if tl_path is None and (edit_dir / "cut_timeline.json").exists():
350
+ tl_path = edit_dir / "cut_timeline.json"
351
+ timeline = load_json(tl_path) if tl_path else None
352
+ fps = float((timeline or {}).get("fps") or info.get("fps") or 30)
353
+ streams = json.loads(subprocess.run(
354
+ ["ffprobe", "-v", "error", "-show_entries", "stream=codec_type,duration,nb_frames", "-of", "json", str(video)],
355
+ capture_output=True, text=True).stdout or "{}").get("streams", [])
356
+ v_dur = next((float(s.get("duration") or 0) for s in streams if s.get("codec_type") == "video"), info["duration"])
357
+ a_dur = next((float(s.get("duration") or 0) for s in streams if s.get("codec_type") == "audio"), None)
358
+ v_frames = next((int(s.get("nb_frames") or 0) for s in streams if s.get("codec_type") == "video"), 0)
359
+ dur_info = {"video": round(v_dur, 3), "audio": None if a_dur is None else round(a_dur, 3), "frames": v_frames,
360
+ "fps": fps}
361
+
362
+ if timeline:
363
+ segs = timeline.get("segments") or []
364
+ dur_info.update(timeline=timeline.get("duration"), timeline_frames=timeline.get("frames"))
365
+ if timeline.get("edl_sig") and edl_signature(edl) != timeline["edl_sig"]:
366
+ flag("STALE-TIMELINE", None, "o EDL mudou depois do render: renderize de novo antes de aprovar")
367
+ if abs(v_dur - float(timeline.get("duration") or 0)) > 1.0 / fps + 1e-3:
368
+ flag("DURATION", v_dur, f"vídeo tem {v_dur:.3f}s e o timeline diz {float(timeline['duration']):.3f}s")
369
+ if timeline.get("frames") and v_frames and int(timeline["frames"]) != v_frames:
370
+ flag("DURATION", None, f"vídeo tem {v_frames} quadros e o timeline diz {timeline['frames']}")
371
+ else:
372
+ flag("STALE-TIMELINE", None, "timeline não encontrado: medições por trecho usam a conta do EDL")
373
+ segs, pos = [], 0.0
374
+ for r in edl.get("ranges", []):
375
+ d = float(r["end"]) - float(r["start"])
376
+ segs.append({"id": r.get("id"), "source": r["source"], "src_start": float(r["start"]),
377
+ "src_end": float(r["end"]), "out_start": pos, "out_end": pos + d,
378
+ "audio_out_start": pos, "audio_out_end": pos + d, "audio_src_start": float(r["start"])})
379
+ pos += d
380
+ if a_dur is None:
381
+ flag("DURATION", None, "o corte não tem áudio")
382
+ elif abs(a_dur - v_dur) > 0.05:
383
+ flag("DURATION", v_dur, f"áudio ({a_dur:.3f}s) e vídeo ({v_dur:.3f}s) com durações diferentes")
384
+
385
+ # ---- preview render (half resolution) passing as the approval file
386
+ out_cfg = edl.get("output") or {}
387
+ fmt_wh = OUTPUT_FORMATS.get(out_cfg.get("format") or "vertical", (0, 0))
388
+ want_w, want_h = int(out_cfg.get("width") or fmt_wh[0]), int(out_cfg.get("height") or fmt_wh[1])
389
+ got_w, got_h = int(info.get("display_width") or 0), int(info.get("display_height") or 0)
390
+ if (timeline or {}).get("preview") or (want_w and got_w and (got_w < want_w - 2 or got_h < want_h - 2)):
391
+ flag("PREVIEW", None, f"prévia em baixa resolução ({got_w}x{got_h}, o EDL pede {want_w}x{want_h}): "
392
+ "renderize sem --preview antes de pedir aprovação")
393
+
394
+ # ---- full decode: black frames + how many frames really decode
395
+ blacks, decoded_frames = black_frames(video)
396
+ dur_info["decoded_frames"] = decoded_frames
397
+ if decoded_frames == 0:
398
+ raise ValueError("nenhum quadro decodifica (arquivo incompleto ou corrompido)")
399
+ if v_frames and decoded_frames < v_frames - 1:
400
+ flag("DURATION", decoded_frames / fps, f"só {decoded_frames} de {v_frames} quadros decodificam: "
401
+ "arquivo incompleto ou corrompido, renderize de novo")
402
+
403
+ # ---- audio decode (stereo for peaks/clicks, mono mix for levels)
404
+ stereo = decode_audio(video, mono=False) if a_dur else np.zeros((0, 2), dtype=np.float32)
405
+ mono = stereo.mean(axis=1) if stereo.size else np.zeros(0, dtype=np.float32)
406
+ if a_dur and stereo.shape[0] < (a_dur - 0.1) * SR:
407
+ flag("DURATION", stereo.shape[0] / SR, f"só {stereo.shape[0] / SR:.2f}s de {a_dur:.2f}s de áudio "
408
+ "decodificam: arquivo incompleto ou corrompido")
409
+
410
+ loud = loudness_stats(video) if a_dur else {}
411
+ peak = float(np.abs(stereo).max()) if stereo.size else 0.0
412
+ clipped = int((np.abs(stereo) >= 0.999).sum()) if stereo.size else 0
413
+ # ebur128 prints the true peak with 1 decimal (-0.96 reads -1.0); the loudnorm reading used by
414
+ # render_cut's peak guard has 2. Either one above the limit is a flag.
415
+ loud.update(sample_peak_db=round(db(peak), 2), clipped_samples=clipped,
416
+ true_peak_loudnorm=true_peak_db(video) if a_dur else None)
417
+ silent = a_dur is not None and (db(peak) < SILENT_DB or (loud.get("integrated") is not None
418
+ and loud["integrated"] <= -69.0))
419
+ if silent:
420
+ flag("SILENT", None, "o corte está sem som: confira se as fontes têm áudio")
421
+ if clipped > 2:
422
+ flag("CLIPPING", float(np.argmax(np.abs(stereo).max(axis=1) >= 0.999)) / SR,
423
+ f"{clipped} amostras estouradas (clipping)")
424
+ tp_worst = max((v for v in (loud.get("true_peak"), loud.get("true_peak_loudnorm")) if v is not None), default=None)
425
+ if not silent and tp_worst is not None and tp_worst > TRUE_PEAK_LIMIT:
426
+ flag("CLIPPING", None, f"pico real {tp_worst:.2f} dBTP, acima de {TRUE_PEAK_LIMIT:.1f}: "
427
+ "renderize de novo (o render baixa o volume para caber)")
428
+ if not silent and loud.get("integrated") is not None and abs(loud["integrated"] - TARGET_I) > 1.5:
429
+ flag("LOUDNESS", None, f"loudness {loud['integrated']:.1f} LUFS, alvo {TARGET_I:.0f}")
430
+
431
+ # ---- speech reference and adaptive silence threshold
432
+ frames_db = np.array([db(v) for v in rms_frames(mono, 0.05)]) if mono.size else np.zeros(0)
433
+ speech_ref = float(np.percentile(frames_db, 90)) if frames_db.size else -20.0
434
+ floor_db = float(np.percentile(frames_db, 10)) if frames_db.size else -60.0
435
+ noise_db = min(-20.0, max(floor_db + 10.0, speech_ref - 25.0))
436
+
437
+ # ---- junction click probe
438
+ junctions = []
439
+ for a, b in zip(segs, segs[1:]):
440
+ probes = [click_probe(stereo, float(b["out_start"]))]
441
+ if float(b.get("audio_out_start", b["out_start"])) < float(b["out_start"]) - 1e-6:
442
+ probes.append(click_probe(stereo, float(b["audio_out_start"])))
443
+ worst = max(probes, key=lambda p: p["ratio"])
444
+ click = worst["ratio"] > CLICK_RATIO and worst["jump"] > CLICK_MIN_JUMP
445
+ junctions.append({"time": round(float(b["out_start"]), 3), "between": [a["id"], b["id"]], **worst,
446
+ "click": click})
447
+ if click:
448
+ flag("CLICK", float(b["out_start"]), f"estalo na junção {a['id']} -> {b['id']} "
449
+ f"(salto {worst['jump']:.3f}, {worst['ratio']:.0f}x o normal)", b["id"])
450
+ if segs and stereo.size and not silent:
451
+ for t, label in ((0.0, "início"), (v_dur, "fim")):
452
+ p = click_probe(stereo, min(max(t, 0.012), v_dur - 0.012))
453
+ if p["ratio"] > CLICK_RATIO and p["jump"] > CLICK_MIN_JUMP:
454
+ flag("CLICK", t, f"estalo no {label} do vídeo")
455
+
456
+ # ---- sources: resolved like render_cut, probed once
457
+ sources = edl.get("sources") or {}
458
+ src_info: dict[str, dict] = {}
459
+ for key in dict.fromkeys(s["source"] for s in segs):
460
+ value = sources.get(key)
461
+ if not value:
462
+ flag("SOURCE-MISSING", None, f"a fonte {key!r} não está em \"sources\" do EDL: bordas não conferidas")
463
+ continue
464
+ path = resolve_source(str(value), edit_dir)
465
+ if not path.exists():
466
+ flag("SOURCE-MISSING", None, f"a fonte {key} não foi encontrada ({path}): as bordas dos trechos dela "
467
+ "não foram conferidas. Corrija o caminho no EDL")
468
+ continue
469
+ try:
470
+ pinfo = probe_video(path)
471
+ except Exception: # noqa: BLE001
472
+ flag("SOURCE-MISSING", None, f"não consegui ler a fonte {key} ({path.name}): bordas não conferidas")
473
+ continue
474
+ sig = file_signature(path)
475
+ tr = load_json(edit_dir / "transcripts" / f"{path.stem}.json")
476
+ if isinstance(tr, dict) and tr.get("source_sig") and tr["source_sig"] != sig:
477
+ tr = None # the source changed after the transcription: word times are not trustworthy
478
+ reg = load_json(edit_dir / "regions" / f"{path.stem}.json")
479
+ speech = ([[float(a), float(b)] for a, b in reg["speech"]]
480
+ if isinstance(reg, dict) and isinstance(reg.get("speech"), list)
481
+ and reg.get("source_sig") in (None, sig) and "start" not in reg else None)
482
+ src_info[key] = {"path": path, "info": pinfo, "transcript": tr if isinstance(tr, dict) else None,
483
+ "speech": speech}
484
+ src_info[key]["floor"], src_info[key]["af"] = noise_floor_db(path) if pinfo.get("has_audio") else (None, None)
485
+ if pinfo.get("is_hdr"):
486
+ warnings.append(f"fonte HDR ({key}): as cores foram convertidas para SDR. Confira a cor no preview "
487
+ "e, nos próximos vídeos, grave com o HDR do celular desligado.")
488
+ for s in segs:
489
+ crop = s.get("crop") if isinstance(s.get("crop"), dict) else None
490
+ if crop and crop.get("focus_from") == "centro" and float(crop.get("kept") or 1) < 1 / ASPECT_WARN:
491
+ warnings.append(f"trecho {s['id']}: a imagem foi cortada no centro ({float(crop['kept']):.0%} guardado) "
492
+ "sem rosto encontrado. Confira o enquadramento; ajuste focus_x/focus_y no trecho.")
493
+
494
+ # ---- cut edges inside speech or inside a word (source side)
495
+ edge_jobs = []
496
+ for s in segs:
497
+ si = src_info.get(s["source"])
498
+ if si is None or not si["info"].get("has_audio"):
499
+ continue
500
+ a0 = float(s.get("audio_src_start", s["src_start"]))
501
+ a1 = float(s["src_end"])
502
+ edge_jobs.append((s, si, a0, a1))
503
+
504
+ def edges_for(job):
505
+ s, si, a0, a1 = job
506
+ src = si["path"]
507
+ body = loudest_channel(decode_audio(src, a0, a1 - a0, mono=False))
508
+ if body.size == 0:
509
+ return s, si, None
510
+ body_db = np.array([db(v) for v in rms_frames(body, 0.05)])
511
+ ref = float(np.percentile(body_db, 90)) if body_db.size else -20.0
512
+ active = body_db[body_db > ref - 20.0]
513
+ src_level = power_mean_db(active) if active.size >= 4 else None # the voice level: unfiltered
514
+ edge_ref = ref
515
+ if si.get("af"): # the edge gates read the same filtered audio the edge probes read
516
+ fb = loudest_channel(decode_audio(src, a0, a1 - a0, mono=False, af=si["af"]))
517
+ fdb = np.array([db(v) for v in rms_frames(fb, 0.05)])
518
+ edge_ref = float(np.percentile(fdb, 90)) if fdb.size else ref
519
+ probes = {side: (edge_probe(src, t, side, edge_ref, si.get("floor"), si.get("af")),
520
+ word_edge_probe(src, t, edge_ref, si["transcript"], si.get("af")))
521
+ for side, t in (("start", a0), ("end", a1))}
522
+ return s, si, (ref, src_level, probes)
523
+
524
+ # a range that picks up where the previous one of the same source stopped plays as one take
525
+ seamless: set[tuple[str, str]] = set()
526
+ for a, b in zip(segs, segs[1:]):
527
+ if a["source"] == b["source"] and \
528
+ abs(float(b.get("audio_src_start", b["src_start"])) - float(a["src_end"])) <= SEAMLESS_S:
529
+ seamless |= {(a["id"], "end"), (b["id"], "start")}
530
+
531
+ edges = []
532
+ source_levels: dict[str, float | None] = {}
533
+ gains = {str(r.get("id")): float(r.get("gain_db") or 0.0) for r in edl.get("ranges", [])}
534
+ # corte_por_onda.py ended these ranges in the valley between two words said together: the next word's attack
535
+ # is right there, so "still sounding" at that end is expected (the transcription of the cut is the check)
536
+ valley_ends = {str(r.get("id")) for r in edl.get("ranges", []) if r.get("fim_no_vale")}
537
+ unreadable: set[str] = set()
538
+ with ThreadPoolExecutor(max_workers=4) as pool:
539
+ for s, si, result in pool.map(edges_for, edge_jobs):
540
+ if result is None:
541
+ if s["source"] not in unreadable:
542
+ unreadable.add(s["source"])
543
+ flag("SOURCE-MISSING", None, f"não consegui ler o áudio da fonte {s['source']} "
544
+ f"({si['path'].name}): as bordas dos trechos dela não foram conferidas")
545
+ continue
546
+ ref, src_level, probes = result
547
+ source_levels[s["id"]] = None if src_level is None else src_level + gains.get(str(s["id"]), 0.0)
548
+ for side, t_src, t_out in (("start", float(s.get("audio_src_start", s["src_start"])),
549
+ float(s.get("audio_out_start", s["out_start"]))),
550
+ ("end", float(s["src_end"]), float(s["out_end"]))):
551
+ e, wp = probes[side]
552
+ if (s["id"], side) in seamless: # the audio runs on into the next range: no cut heard
553
+ e = {**e, "in_speech": False}
554
+ wp = None
555
+ if side == "end" and str(s["id"]) in valley_ends:
556
+ e = {**e, "in_speech": False, "fim_no_vale": True}
557
+ wp = None
558
+ in_word = bool(wp and wp["in_word"])
559
+ edges.append({"range": s["id"], "side": side, "src_time": round(t_src, 3), "out_time": round(t_out, 3),
560
+ **e, "word": wp["word"] if wp else None, "in_word": in_word,
561
+ "word_gap_s": wp["gap_s"] if wp else None})
562
+ what = "start" if side == "start" else "end"
563
+ where = "começa" if side == "start" else "termina"
564
+ if e["in_speech"]:
565
+ extra = f" na palavra \"{wp['word']}\"" if wp else ""
566
+ flag("EDGE-IN-SPEECH", t_out, f"{s['id']} {where} com som ainda saindo{extra} "
567
+ f"(fonte {t_src:.2f}s, {e['inside_db']:.0f}/{e['outside_db']:.0f} dB): ajuste o "
568
+ f"{what} para uma pausa", s["id"])
569
+ elif in_word:
570
+ edge_to = wp["word_start"] if side == "start" else wp["word_end"]
571
+ flag("EDGE-IN-WORD", t_out, f"{s['id']} {where} no meio da palavra \"{wp['word']}\" "
572
+ f"(fonte {t_src:.2f}s, palavra {wp['word_start']:.2f}-{wp['word_end']:.2f}s): mova o "
573
+ f"{what} para perto de {edge_to:.2f}s e confira no speech_regions.py", s["id"])
574
+
575
+ # ---- the same speech played twice (consecutive ranges of one source that overlap)
576
+ for a, b in zip(segs, segs[1:]):
577
+ if a["source"] != b["source"]:
578
+ continue
579
+ a0, a1 = float(a.get("audio_src_start", a["src_start"])), float(a["src_end"])
580
+ b0, b1 = float(b.get("audio_src_start", b["src_start"])), float(b["src_end"])
581
+ overlap = min(a1, b1) - max(a0, b0)
582
+ if b0 < a1 and overlap > 1.0 / fps + 0.005:
583
+ flag("REPEAT", float(b.get("audio_out_start", b["out_start"])),
584
+ f"{b['id']} repete {overlap:.2f}s da fala de {a['id']} (mesma fonte, {max(a0, b0):.2f}-"
585
+ f"{min(a1, b1):.2f}s): mova o start de {b['id']} para {a1:.2f}s ou mais, ou encurte o end de {a['id']}",
586
+ b["id"])
587
+
588
+ # ---- dead air, placed on each range's own audio span (up to where the next range's audio starts)
589
+ def own_span(i: int) -> tuple[float, float]:
590
+ s = segs[i]
591
+ a = float(s.get("audio_out_start", s["out_start"]))
592
+ b = float(s["out_end"])
593
+ if i + 1 < len(segs):
594
+ b = min(b, float(segs[i + 1].get("audio_out_start", segs[i + 1]["out_start"])))
595
+ return a, b
596
+
597
+ def to_src(i: int, t_out: float) -> float:
598
+ s = segs[i]
599
+ return float(s.get("audio_src_start", s["src_start"])) + (t_out - float(s.get("audio_out_start", s["out_start"])))
600
+
601
+ def src_speech(i: int) -> list[list[float]] | None:
602
+ return (src_info.get(segs[i]["source"]) or {}).get("speech")
603
+
604
+ def new_end_for(i: int, t_out: float) -> float:
605
+ """Where the speech before a silence ends in the source (regions/<stem>.json), plus 0.15 s.
606
+ The cut's own silence starts late on soft word endings, so the regions win when present."""
607
+ t = to_src(i, t_out)
608
+ ends = [b for a, b in (src_speech(i) or []) if t - 1.0 < b <= t + 0.3]
609
+ return (max(ends) if ends else t) + 0.15
610
+
611
+ def new_start_for(i: int, t_out: float) -> float:
612
+ """Where the speech after a silence starts in the source, minus 0.10 s (soft onsets like 'g'
613
+ are heard in the cut later than they start)."""
614
+ t = to_src(i, t_out)
615
+ starts = [a for a, b in (src_speech(i) or []) if t - 0.3 <= a < t + 1.0]
616
+ return (min(starts) if starts else t - 0.15) - 0.10
617
+
618
+ silences = dead_air(mono, noise_db, min_silence) if mono.size and not silent else []
619
+ for sil in silences:
620
+ over = [i for i in range(len(segs)) if min(own_span(i)[1], sil["end"]) - max(own_span(i)[0], sil["start"]) > 0.02]
621
+ mid = (sil["start"] + sil["end"]) / 2
622
+ rid = next((s["id"] for s in segs if float(s["out_start"]) <= mid < float(s["out_end"])), None)
623
+ sil["range"] = rid
624
+ new_end = lambda i: new_end_for(i, sil["start"]) # noqa: E731
625
+ new_start = lambda i: new_start_for(i, sil["end"]) # noqa: E731
626
+ if not over:
627
+ msg = "encurte a pausa"
628
+ elif len(over) >= 2:
629
+ i, j = over[0], over[-1]
630
+ msg = (f"na junção {segs[i]['id']} -> {segs[j]['id']}: encurte o end de {segs[i]['id']} para "
631
+ f"{new_end(i):.2f}s e/ou mova o start de {segs[j]['id']} para {new_start(j):.2f}s")
632
+ else:
633
+ i = over[0]
634
+ a, b = own_span(i)
635
+ sid = segs[i]["id"]
636
+ if sil["end"] >= b - 0.1:
637
+ where = "no fim do vídeo" if sil["end"] >= v_dur - 0.05 else f"no fim do trecho {sid}"
638
+ msg = f"{where}: encurte o end de {sid} para {new_end(i):.2f}s"
639
+ elif sil["start"] <= a + 0.1:
640
+ where = "no começo do vídeo" if sil["start"] <= 0.05 else f"no começo do trecho {sid}"
641
+ msg = f"{where}: mova o start de {sid} para {new_start(i):.2f}s"
642
+ else:
643
+ msg = (f"no meio do trecho {sid}: divida em dois, um terminando em {new_end(i):.2f}s e o outro "
644
+ f"começando em {new_start(i):.2f}s")
645
+ flag("DEAD-AIR", sil["start"], f"silêncio de {sil['duration']:.2f}s {msg}", rid, sil["end"])
646
+
647
+ # ---- black frames
648
+ for b in blacks:
649
+ flag("BLACK", b["start"], f"tela preta por {b['duration']:.2f}s", end=b["end"])
650
+
651
+ # ---- range level balance against a reference level (speech-weighted median of the heard levels)
652
+ ranges_out = []
653
+ reference = None
654
+ if mono.size and segs and not silent:
655
+ win = int(0.05 * SR)
656
+ for i, s in enumerate(segs):
657
+ a, b = float(s["out_start"]), own_span(i)[1]
658
+ x = mono[int(a * SR):int(b * SR)]
659
+ vals = np.array([db(v) for v in rms_frames(x, 0.05)]) if x.size >= win else np.zeros(0)
660
+ gate = max(float(np.percentile(vals, 90)) - 20.0, floor_db + 6.0) if vals.size else 0.0
661
+ active = vals[vals > gate]
662
+ level = power_mean_db(active) if active.size >= 4 else None
663
+ src_level = source_levels.get(s["id"])
664
+ ranges_out.append({"id": s["id"], "level_db": None if level is None else round(level, 1),
665
+ "source_level_db": None if src_level is None else round(src_level, 1),
666
+ "speech_seconds": round(active.size * 0.05, 2),
667
+ "gain_db": gains.get(str(s["id"]), 0.0)})
668
+ measured = [r for r in ranges_out if r["level_db"] is not None]
669
+ if len(measured) >= 2:
670
+ reference = weighted_median([r["level_db"] for r in measured], [r["speech_seconds"] for r in measured])
671
+ ref_range = next(r for r in measured if r["level_db"] == reference)
672
+ for r in measured:
673
+ r["delta_db"] = round(r["level_db"] - reference, 1)
674
+ # A jump between neighbours is heard even when both sit near the reference, and so is a
675
+ # drift from the first ranges to the last in small steps: fix the one farther from the reference.
676
+ jump_fix: dict[str, str] = {}
677
+ farther = lambda a, b: (a, b) if abs(a["level_db"] - reference) >= abs(b["level_db"] - reference) else (b, a) # noqa: E731
678
+ for a, b in zip(measured, measured[1:]):
679
+ if abs(a["level_db"] - b["level_db"]) > ADJACENT_JUMP_DB:
680
+ fix, other = farther(a, b)
681
+ jump_fix.setdefault(fix["id"], other["id"])
682
+ lo, hi = min(measured, key=lambda r: r["level_db"]), max(measured, key=lambda r: r["level_db"])
683
+ if hi["level_db"] - lo["level_db"] > ADJACENT_JUMP_DB:
684
+ fix, other = farther(lo, hi)
685
+ jump_fix.setdefault(fix["id"], other["id"])
686
+ src_ref = ref_range.get("source_level_db")
687
+ for r, s in zip(ranges_out, segs):
688
+ if r["level_db"] is None:
689
+ continue
690
+ delta = r["level_db"] - reference
691
+ if abs(delta) <= LEVEL_DELTA_DB and r["id"] not in jump_fix:
692
+ continue
693
+ # gain_db acts before loudnorm, which in its dynamic mode squeezes the gap heard in the cut.
694
+ # The gap between the sources (gain included) is the one gain_db closes; it is used when it
695
+ # points the same way and is not smaller than the heard one.
696
+ change = delta
697
+ src_gap = None if src_ref is None or r.get("source_level_db") is None else r["source_level_db"] - src_ref
698
+ if src_gap is not None and src_gap * delta > 0 and abs(src_gap) >= abs(delta):
699
+ change = src_gap
700
+ final = round(min(GAIN_CAP_DB, max(-GAIN_CAP_DB, r["gain_db"] - change)), 1)
701
+ r["suggested_gain_db"] = final
702
+ code, how = ("LOW-LEVEL", "abaixo") if delta < 0 else ("HIGH-LEVEL", "acima")
703
+ why = (f"{abs(delta):.1f} dB {how} do nível de referência ({reference:.1f} dB)"
704
+ if abs(delta) > LEVEL_DELTA_DB else
705
+ f"mais de {ADJACENT_JUMP_DB:.0f} dB diferente do trecho {jump_fix[r['id']]} "
706
+ f"({abs(delta):.1f} dB {how} da referência)")
707
+ flag(code, float(s["out_start"]), f"{r['id']} está {why}: use gain_db {final:+.1f} nele no EDL "
708
+ f"(hoje {r['gain_db']:+.1f})", r["id"], float(s["out_end"]))
709
+
710
+ flags.sort(key=lambda f: (f["time"] is None, f["time"] or 0.0))
711
+ report = {
712
+ "video": str(video), "edl": str(edl_path), "timeline": str(tl_path) if tl_path else None,
713
+ "created_at": now_iso(), "min_silence": min_silence,
714
+ "duration": dur_info, "loudness": loud,
715
+ "speech_ref_db": round(speech_ref, 1), "silence_noise_db": round(noise_db, 1),
716
+ "junctions": junctions, "edges": edges, "dead_air": silences, "black": blacks,
717
+ "reference_level_db": None if reference is None else round(reference, 1),
718
+ "ranges": ranges_out, "warnings": warnings, "flags": flags, "ok": not flags,
719
+ }
720
+
721
+ # ---- PT-BR summary
722
+ lines = [f"Verificação do corte · {video.name}"]
723
+ tl_txt = f" · timeline {float(timeline['duration']):.2f}s" if timeline else " · sem timeline"
724
+ a_txt = f" · áudio {a_dur:.2f}s" if a_dur is not None else " · sem áudio"
725
+ lines.append(f"Duração: {v_dur:.2f}s ({v_frames} quadros){a_txt}{tl_txt}")
726
+ if silent:
727
+ lines.append("Loudness: sem som")
728
+ elif loud.get("integrated") is not None:
729
+ tp_ln = loud.get("true_peak_loudnorm")
730
+ tp_txt = f"{tp_ln:.2f}" if tp_ln is not None else fmt_db(loud.get("true_peak"))
731
+ lines.append(f"Loudness: {loud['integrated']:.1f} LUFS · pico real {tp_txt} dBTP"
732
+ f" · LRA {fmt_db(loud.get('lra'))} · amostras estouradas {clipped}")
733
+ n_click = sum(1 for j in junctions if j["click"])
734
+ lines.append(f"Junções: {len(junctions)} · estalos {n_click}"
735
+ + (f" · pior {max(j['ratio'] for j in junctions):.1f}x" if junctions else ""))
736
+ n_edge = sum(1 for e in edges if e["in_speech"])
737
+ n_word = sum(1 for e in edges if e["in_word"] and not e["in_speech"])
738
+ lines.append(f"Bordas dos trechos: {len(edges)} · dentro da fala {n_edge} · no meio de palavra {n_word}")
739
+ if reference is not None:
740
+ spread = [r["delta_db"] for r in ranges_out if r.get("delta_db") is not None]
741
+ lines.append(f"Nível dos trechos: de {min(spread):+.1f} a {max(spread):+.1f} dB da referência ({reference:.1f} dB)")
742
+ lines.append(f"Silêncio >= {min_silence:g}s: {len(silences)} · tela preta: {len(blacks)}")
743
+ for w in warnings:
744
+ lines.append(f"aviso: {w}")
745
+ if flags:
746
+ lines.append("")
747
+ lines.append(f"FLAGS ({len(flags)}):")
748
+ for f in flags:
749
+ t = fmt_time(f["time"]) if f["time"] is not None else " -- "
750
+ lines.append(f" {t} {f['code']:15s} {f['message']}")
751
+ else:
752
+ lines.append("")
753
+ lines.append("Nenhum problema encontrado.")
754
+ return report, lines
755
+
756
+
757
+ def main() -> int:
758
+ ap = argparse.ArgumentParser(description="Confere o corte renderizado e lista problemas com o tempo de cada um.")
759
+ ap.add_argument("edl", type=Path, help="edl.json usado no render")
760
+ ap.add_argument("video", type=Path, help="cut.mp4")
761
+ ap.add_argument("--min-silence", type=float, default=0.7, help="silêncio mínimo para marcar DEAD-AIR (padrão 0.7s)")
762
+ ap.add_argument("--ask-approval", action="store_true",
763
+ help="pede a aprovação no preview mesmo com avisos (use depois de explicar à pessoa os que ficam)")
764
+ ap.add_argument("-o", "--output", type=Path, help="JSON do relatório (padrão: edit/verify/cut_report.json)")
765
+ ap.add_argument("--sem-aprovacao", action="store_true",
766
+ help="só confere, não mexe no preview (o conferir_corte.py roda assim, junto da fala)")
767
+ ap.add_argument("--aprovar", action="store_true",
768
+ help="não confere de novo: pede a aprovação a partir do relatório já feito deste cut.mp4")
769
+ args = ap.parse_args()
770
+
771
+ edl_path, video = abspath(args.edl), abspath(args.video)
772
+ if args.aprovar:
773
+ out = abspath(args.output) if args.output else edl_path.parent / "verify" / "cut_report.json"
774
+ report = load_json(out)
775
+ if (not isinstance(report, dict) or not video.exists() or report.get("video") != str(video)
776
+ or video.stat().st_mtime > out.stat().st_mtime):
777
+ print(f"erro: {out.name} não é deste {video.name} (ou o corte mudou depois). Rode o verify sem --aprovar.",
778
+ file=sys.stderr)
779
+ return 2
780
+ state_line, report["approval_requested"] = update_state(edl_path.parent, video, report["flags"],
781
+ args.ask_approval)
782
+ save_json(out, report)
783
+ print(state_line or "Esse vídeo não é o corte da pasta: nada a aprovar.")
784
+ return 0
785
+ if not edl_path.exists():
786
+ print(f"erro: EDL não encontrado: {edl_path}", file=sys.stderr)
787
+ return 2
788
+ if not video.exists():
789
+ print(f"erro: vídeo não encontrado: {video}", file=sys.stderr)
790
+ return 2
791
+ try:
792
+ edl = json.loads(edl_path.read_text(encoding="utf-8"))
793
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
794
+ print(f"erro: o EDL não é um JSON válido: {exc}", file=sys.stderr)
795
+ return 2
796
+ if not isinstance(edl, dict) or not isinstance(edl.get("ranges"), list):
797
+ print("erro: o EDL não tem trechos em \"ranges\"", file=sys.stderr)
798
+ return 2
799
+ try:
800
+ report, lines = verify(edl_path, video, args.min_silence)
801
+ except (ValueError, RuntimeError, json.JSONDecodeError) as exc:
802
+ print(f"erro: não consegui ler {video.name}: {exc}", file=sys.stderr)
803
+ return 2
804
+ except (TypeError, KeyError) as exc:
805
+ print(f"erro: o EDL ou o timeline tem um campo faltando ou inválido ({exc.__class__.__name__}: {exc})",
806
+ file=sys.stderr)
807
+ return 2
808
+ out = abspath(args.output) if args.output else edl_path.parent / "verify" / "cut_report.json"
809
+ state_line, report["approval_requested"] = (None, None) if args.sem_aprovacao else \
810
+ update_state(edl_path.parent, video, report["flags"], args.ask_approval)
811
+ save_json(out, report)
812
+ print("\n".join(lines))
813
+ if state_line:
814
+ print(state_line)
815
+ print(f"\nRelatório: {out}")
816
+ return 0
817
+
818
+
819
+ if __name__ == "__main__":
820
+ sys.exit(main())