primocode 10.16.0 → 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 (234) 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 +11 -2
  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/nuvem.js +11 -1
  222. package/lib/passagem.js +50 -0
  223. package/lib/perigo.js +19 -11
  224. package/lib/protecao.js +134 -0
  225. package/lib/titulo-conversa.js +46 -0
  226. package/lib/tools.js +3 -0
  227. package/lib/ui.js +1 -1
  228. package/package.json +3 -2
  229. package/skills/editar-meu-video/SKILL.md +6 -2
  230. package/skills/editar-video/SKILL.md +113 -0
  231. package/studio/pessoa.py +52 -0
  232. package/voz/primo_voz/app.py +43 -4
  233. package/voz/primo_voz/chat.html +17 -6
  234. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,427 @@
1
+ """Quality gate for a rendered video: the edit is only delivered when this prints "PASSOU".
2
+
3
+ Checks the exported file (not the studio):
4
+ 1. black frames (0.1 s or more anywhere, the very first 0.1 s excepted);
5
+ 2. loudness: integrated -14 LUFS with 1.5 of margin, true peak under -1 dBTP;
6
+ 3. breaths: no silence of 0.25 s or more (helpers/respiros.py). The silence after the last spoken word does not
7
+ count when the project has a still end (`segura_fim`) or a closing card (`cartao_final`): it is the end of the
8
+ video, not a breath;
9
+ 4. text outside the Reels safe zone (x 140-940, y 230-1530 on 1080x1920) or touching the edge of the frame,
10
+ read with the same reader as conferir_dados.py (Apple Vision on the Mac, Tesseract elsewhere). The stretches
11
+ of `prova`, `contida`, `comparar` and `tela_verde` blocks are not read: the text there is on the person's own
12
+ footage (a screen recording), not text of the edit. Without a reader the check says so and does not block;
13
+ 4b. (PrimoCode) text glued to the face: in the full-screen stretches (the person fills the frame) a line of text
14
+ closer than ROSTO_PX to the face box fails (the box from the hair line down to just under the mouth: the chin is
15
+ where the captions sit by design). The face is found in the same frames the reader reads, with the YuNet
16
+ detector of face_layout.py (the face as it is on screen: zoom and camera included). Without the detector the
17
+ check says so and does not block;
18
+ 5. a sheet of frames with the safe zone box drawn on it, saved next to the report;
19
+ 6. (PrimoCode) the loudnorm of every step (cut, clean voice, soundtrack) stayed LINEAR: the dynamic mode is a
20
+ compressor ffmpeg turns on silently and the voice pumps (helpers/ganho_por_trecho.py prevents it);
21
+ 7. (PrimoCode) no sample at the digital ceiling (|x| >= 0.999) in the exported audio.
22
+
23
+ Usage:
24
+ uv run python helpers/portao.py <final.mp4> --projeto <PROJ> [--fps 3] [--folha <png>] [--ajustar-volume] [--json]
25
+
26
+ `--ajustar-volume`: when only the loudness is off, rewrites the audio to -14 LUFS (two passes, the picture is not
27
+ re-encoded), keeps the original as `<nome>_antes_do_volume.mp4` and measures again. No new render needed.
28
+ Exit code 0 when everything passes, 1 when something failed, 2 on error.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import re
36
+ import subprocess
37
+ import sys
38
+ import tempfile
39
+ from pathlib import Path
40
+
41
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
42
+ import conferir_dados as cd # noqa: E402
43
+ import corte_por_onda as onda # noqa: E402
44
+ import respiros # noqa: E402
45
+ from common import fmt_time, probe_video, require_binary # noqa: E402
46
+
47
+ ALVO_LUFS, MARGEM_LUFS, PICO_MAX_DB = -14.0, 1.5, -1.0
48
+ PRETO_MIN_S = 0.1
49
+ PRETO_INICIO_S = 0.1 # black this short at the very start (the first frame) is the video opening
50
+ BLOCOS_DE_MATERIAL = ("prova", "contida", "comparar", "tela_verde") # footage of the person: its text is not ours
51
+ ZONA = (140, 230, 940, 1530) # x0, y0, x1, y1 on 1080x1920
52
+ BORDA_PX = 24 # text closer than this to the edge of the frame is "touching"
53
+ CONF_MIN = 0.5
54
+ FOLGA_PX = 6 # the reader's box is not exact
55
+ ROSTO_PX = 40 # text closer than this to the face box is "glued to the face" (px on 1920 high)
56
+ CENAS_COM_ROSTO = ("tela_cheia",) # stretches where the person fills the frame (moldura/prova/...: the face is small or not ours)
57
+
58
+
59
+ def distancia_ao_rosto(caixa: tuple[float, float, float, float], rosto: tuple[float, float, float, float]) -> float:
60
+ """Gap in px between a text box and the face box, both (x0, y0, x1, y1). Negative when they overlap (the depth of
61
+ the overlap on the shallower axis)."""
62
+ dx = max(rosto[0] - caixa[2], caixa[0] - rosto[2])
63
+ dy = max(rosto[1] - caixa[3], caixa[1] - rosto[3])
64
+ if dx < 0 and dy < 0:
65
+ return max(dx, dy)
66
+ return max(dx, dy) if (dx < 0 or dy < 0) else (dx * dx + dy * dy) ** 0.5
67
+
68
+
69
+ def colado_no_rosto(caixa, rosto, h: int = 1920, px: float = ROSTO_PX) -> bool:
70
+ """True when the text box is closer than `px` (scaled to the video height) to the face box."""
71
+ return rosto is not None and distancia_ao_rosto(caixa, rosto) < px * h / 1920
72
+
73
+
74
+ def rostos_dos_quadros(pasta: Path, n: int, w: int, h: int) -> dict[int, tuple[float, float, float, float] | None] | None:
75
+ """The face box (px) in each extracted frame f_00001.jpg..., with the YuNet detector of face_layout.py. None when the
76
+ detector is not available here (no OpenCV or no model)."""
77
+ try:
78
+ import cv2 # noqa: PLC0415
79
+ import face_layout as fl # noqa: PLC0415
80
+ fl._cv2()
81
+ except Exception: # noqa: BLE001
82
+ return None
83
+ out: dict[int, tuple[float, float, float, float] | None] = {}
84
+ for k in range(n):
85
+ img = cv2.imread(str(pasta / f"f_{k + 1:05d}.jpg"))
86
+ f = fl.detect_array(img) if img is not None else None
87
+ out[k] = caixa_do_rosto(f, w, h) if f else None
88
+ return out
89
+
90
+
91
+ QUEIXO = 0.08 # the face box for the rule ends this far (fraction of the face height) under the mouth line
92
+
93
+
94
+ def caixa_do_rosto(f: dict, w: int, h: int) -> tuple[float, float, float, float]:
95
+ """The face box (px) the text has to keep away from: the YuNet box from its top (hair line) down to just under the
96
+ mouth. The chin under the mouth is where the captions of the edit sit by design (face_layout: they miss the eyes and
97
+ the mouth), so it is not part of it."""
98
+ y0, y1 = f["y0"] * h, f["y1"] * h
99
+ boca = f.get("mouth_y")
100
+ if boca is not None:
101
+ y1 = min(y1, boca * h + QUEIXO * (y1 - y0))
102
+ return (f["x0"] * w, y0, f["x1"] * w, y1)
103
+
104
+
105
+ def zona_para(w: int, h: int) -> tuple[int, int, int, int] | None:
106
+ """The safe zone scaled to the video size; only defined for vertical 9:16 (other formats have wider margins)."""
107
+ if abs(w / h - 9 / 16) > 0.02:
108
+ return None
109
+ k = w / 1080
110
+ return tuple(round(v * k) for v in ZONA) # type: ignore[return-value]
111
+
112
+
113
+ def quadros_pretos(video: Path, dur: float) -> list[tuple[float, float]]:
114
+ r = subprocess.run(["ffmpeg", "-nostdin", "-v", "info", "-i", str(video), "-vf",
115
+ f"blackdetect=d={PRETO_MIN_S - 0.02:.2f}:pix_th=0.06", "-an", "-f", "null", "-"],
116
+ capture_output=True, text=True)
117
+ out = []
118
+ for m in re.finditer(r"black_start:([\d.]+)\s+black_end:([\d.]+)", r.stderr):
119
+ a, b = float(m[1]), float(m[2])
120
+ if a < 0.05 and b - a <= PRETO_INICIO_S + 0.05:
121
+ continue
122
+ out.append((a, b))
123
+ return out
124
+
125
+
126
+ def volume(video: Path, completo: bool = False) -> dict:
127
+ r = subprocess.run(["ffmpeg", "-nostdin", "-v", "info", "-i", str(video), "-vn", "-af",
128
+ "loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json", "-f", "null", "-"],
129
+ capture_output=True, text=True)
130
+ m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", r.stderr, re.S)
131
+ if not m:
132
+ raise RuntimeError("não consegui medir o volume (o arquivo tem áudio?)")
133
+ d = json.loads(m.group(0))
134
+ out = {"lufs": float(d["input_i"]), "pico_db": float(d["input_tp"])}
135
+ if completo:
136
+ out["_loudnorm"] = d
137
+ return out
138
+
139
+
140
+ def ajustar_volume(video: Path) -> Path:
141
+ """Two-pass loudnorm to -14 LUFS (true peak -1.5), picture copied. The original stays next to it."""
142
+ d = volume(video, completo=True)["_loudnorm"]
143
+ antes = video.with_name(video.stem + "_antes_do_volume" + video.suffix)
144
+ novo = video.with_name(video.stem + "_novo_volume" + video.suffix)
145
+ af = ("loudnorm=I=-14:TP=-1.5:LRA=11:linear=true:"
146
+ f"measured_I={d['input_i']}:measured_TP={d['input_tp']}:measured_LRA={d['input_lra']}:"
147
+ f"measured_thresh={d['input_thresh']}:offset={d['target_offset']}")
148
+ r = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", str(video), "-map", "0:v", "-map", "0:a",
149
+ "-c:v", "copy", "-af", af, "-ar", "48000", "-c:a", "aac", "-b:a", "192k",
150
+ "-movflags", "+faststart", str(novo)], capture_output=True, text=True)
151
+ if r.returncode != 0 or not novo.is_file():
152
+ raise RuntimeError("não consegui ajustar o volume: " + r.stderr[-300:])
153
+ if not antes.exists():
154
+ video.rename(antes)
155
+ else:
156
+ video.unlink()
157
+ novo.rename(video)
158
+ return antes
159
+
160
+
161
+ def texto_fora(video: Path, w: int, h: int, fps: float, ignorar: list[tuple[float, float]] | None = None,
162
+ com_rosto: list[tuple[float, float]] | None = None) -> dict:
163
+ """Lines of text outside the safe zone, touching the edge or glued to the face (in `com_rosto` stretches).
164
+ {'leitor': ..., 'achados': [...], 'rosto': True | False | None}."""
165
+ zona = zona_para(w, h)
166
+ motor = cd.leitor()
167
+ if motor is None:
168
+ return {"leitor": None, "achados": [], "zona": zona, "ignorado": ignorar or [], "rosto": None}
169
+ info = probe_video(video)
170
+ fps_video = float(info.get("fps") or 30.0)
171
+ dur = float(info["duration"])
172
+ n_total = int(dur * fps - 1e-6) + 1
173
+ achados: list[dict] = []
174
+ with tempfile.TemporaryDirectory(prefix="portao-") as tmp:
175
+ pasta = Path(tmp)
176
+ n = cd.extrair(video, pasta, 0, n_total, fps, fps_video, True, "")
177
+ bruto = cd.ler_pasta(motor, pasta) if n else {}
178
+ rostos = rostos_dos_quadros(pasta, n, w, h) if (n and com_rosto) else None
179
+ ignorar = ignorar or []
180
+ com_rosto = com_rosto or []
181
+ for k in range(n):
182
+ t = k / fps
183
+ if any(x - 0.05 <= t <= y + 0.05 for x, y in ignorar):
184
+ continue
185
+ for it in bruto.get(f"f_{k + 1:05d}.jpg", []):
186
+ if float(it.get("c", 1)) < CONF_MIN or len(re.sub(r"\W", "", str(it.get("t", "")))) < 2:
187
+ continue
188
+ x0, y1 = float(it["x"]) * w, (1 - float(it["y"]) - float(it["h"])) * h
189
+ x1, y2 = x0 + float(it["w"]) * w, y1 + float(it["h"]) * h
190
+ motivo = None
191
+ if x0 < BORDA_PX or y1 < BORDA_PX or x1 > w - BORDA_PX or y2 > h - BORDA_PX:
192
+ motivo = "encostado na borda"
193
+ elif zona and (x0 < zona[0] - FOLGA_PX or x1 > zona[2] + FOLGA_PX
194
+ or y1 < zona[1] - FOLGA_PX or y2 > zona[3] + FOLGA_PX):
195
+ motivo = "fora da zona segura"
196
+ elif rostos and any(x - 0.05 <= t <= y + 0.05 for x, y in com_rosto) and \
197
+ colado_no_rosto((x0 + FOLGA_PX, y1 + FOLGA_PX, x1 - FOLGA_PX, y2 - FOLGA_PX), rostos.get(k), h):
198
+ motivo = f"colado no rosto (a menos de {round(ROSTO_PX * h / 1920)} px)"
199
+ if motivo:
200
+ achados.append({"t": round(t, 2), "texto": str(it["t"]).strip(), "motivo": motivo,
201
+ "caixa": [round(x0), round(y1), round(x1 - x0), round(y2 - y1)]})
202
+ # the same line seen in a row of samples is one finding
203
+ juntos: list[dict] = []
204
+ for a in sorted(achados, key=lambda a: (a["texto"], a["t"])):
205
+ if juntos and juntos[-1]["texto"] == a["texto"] and a["t"] - juntos[-1]["ate"] <= 1.5 / fps + 1e-6:
206
+ juntos[-1]["ate"] = a["t"]
207
+ else:
208
+ juntos.append(dict(a, ate=a["t"]))
209
+ return {"leitor": motor[0], "achados": sorted(juntos, key=lambda a: a["t"]), "zona": zona, "ignorado": ignorar,
210
+ "rosto": (rostos is not None) if com_rosto else None}
211
+
212
+
213
+ def faixas_de_material(cfg: dict, projeto: Path | None, dur: float, cenas=BLOCOS_DE_MATERIAL) -> list[tuple[float, float]]:
214
+ """Time ranges (s) of the blocks that show the person's own footage (or of the `cenas` asked). A time written as a
215
+ word is looked up in the words of the cut; without the transcript only the blocks written in seconds are found."""
216
+ import camera_viva as cv # noqa: PLC0415
217
+ ws: list[dict] = []
218
+ vdur = dur
219
+ if projeto:
220
+ try:
221
+ edit = (projeto / cfg.get("edit", "../edit")).resolve()
222
+ ws = cv.palavras_do_corte(edit)
223
+ vdur = float(json.loads((edit / "cut_timeline.json").read_text(encoding="utf-8"))["duration"])
224
+ except Exception: # noqa: BLE001
225
+ ws = []
226
+ out = []
227
+ for b in cfg.get("blocos") or []:
228
+ if not isinstance(b, dict) or b.get("cena") not in cenas:
229
+ continue
230
+ de = cv.tempo_do_campo(b.get("de", 0.0), ws)
231
+ ate = cv.tempo_do_campo(b["ate"], ws, de or 0.0) if "ate" in b else vdur
232
+ if de is not None and ate is not None:
233
+ out.append((de, ate))
234
+ return out
235
+
236
+
237
+ def faixas_com_rosto(cfg: dict, projeto: Path | None, dur: float) -> list[tuple[float, float]]:
238
+ """Time ranges (s) where the person fills the frame: everything outside the blocks that are not `tela_cheia`
239
+ (moldura, prova, contida, comparar, tela_verde). There the face box is the one that matters for the text."""
240
+ outros = sorted(faixas_de_material(cfg, projeto, dur, cenas=("moldura", "prova", "contida", "comparar", "tela_verde")))
241
+ return livres_entre(outros, dur)
242
+
243
+
244
+ def livres_entre(ocupados: list[tuple[float, float]], dur: float) -> list[tuple[float, float]]:
245
+ """[0, dur] minus the sorted `ocupados` ranges."""
246
+ out, t = [], 0.0
247
+ for de, ate in sorted(ocupados):
248
+ if de > t + 0.05:
249
+ out.append((t, de))
250
+ t = max(t, ate)
251
+ if t < dur - 0.05:
252
+ out.append((t, dur))
253
+ return out
254
+
255
+
256
+ def fim_da_fala(cfg: dict, dur: float) -> float | None:
257
+ """Where the voice ends in the final video: the duration minus the still end and the closing card. None when
258
+ the project has neither (then the last silence counts like any other)."""
259
+ segura = float(cfg.get("segura_fim") or 0)
260
+ cf = cfg.get("cartao_final") or {}
261
+ cartao = float(cf.get("segundos", 1.8)) if cf.get("ligado") else 0.0
262
+ return dur - segura - cartao if (segura > 0 or cartao > 0) else None
263
+
264
+
265
+ def folha(video: Path, w: int, h: int, tempos: list[float], saida: Path) -> Path:
266
+ from PIL import Image, ImageDraw # noqa: PLC0415
267
+ import contact_sheet as cs # noqa: PLC0415
268
+ tw = 270
269
+ th = round(tw * h / w)
270
+ quadros = cs.extract_frames(video, tempos, width=tw, height=th)
271
+ zona = zona_para(w, h)
272
+ for im in quadros:
273
+ d = ImageDraw.Draw(im)
274
+ if zona:
275
+ k = tw / w
276
+ d.rectangle([round(v * k) for v in zona], outline=(255, 60, 60), width=2)
277
+ rotulos = [fmt_time(t) for t in tempos]
278
+ cs.build_sheet(quadros, rotulos, 6, f"{video.name} · caixa vermelha = zona segura").save(saida)
279
+ return saida
280
+
281
+
282
+ def modos_do_volume(video: Path, projeto: Path | None, cfg: dict) -> list[tuple[str, str]]:
283
+ """PrimoCode: o modo de cada loudnorm do caminho (corte, voz limpa, trilha). Dinâmico é um compressor que o
284
+ ffmpeg liga calado quando o ganho linear passaria do pico: a voz bombeia. Lista (etapa, modo)."""
285
+ out: list[tuple[str, str]] = []
286
+ def ler(p: Path) -> dict:
287
+ try:
288
+ return json.loads(p.read_text(encoding="utf-8"))
289
+ except (OSError, ValueError):
290
+ return {}
291
+ edit = None
292
+ if projeto and cfg.get("edit"):
293
+ edit = (projeto / cfg["edit"]).resolve()
294
+ if edit and edit.is_dir():
295
+ m = ler(edit / "cut_timeline.json").get("loudnorm_modo")
296
+ if m:
297
+ out.append(("corte", m))
298
+ m = (ler(edit / "verify" / "voz_limpa.json").get("relatorio") or {}).get("loudnorm_modo")
299
+ if m:
300
+ out.append(("voz limpa", m))
301
+ m = ler(video.with_name(video.name + ".trilha.json")).get("ajuste")
302
+ if m:
303
+ out.append(("trilha", m))
304
+ return out
305
+
306
+
307
+ def amostras_clipadas(video: Path) -> int:
308
+ """PrimoCode: amostras no teto digital (|x| >= 0,999) no áudio exportado. Uma já é estalo."""
309
+ import numpy as np
310
+ raw = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-i", str(video), "-vn", "-f", "f32le", "-acodec",
311
+ "pcm_f32le", "-ar", "48000", "-"], capture_output=True).stdout
312
+ if not raw:
313
+ return 0
314
+ x = np.frombuffer(raw[: len(raw) // 4 * 4], dtype=np.float32)
315
+ return int(np.count_nonzero(np.abs(x) >= 0.999))
316
+
317
+
318
+ def main(argv: list[str] | None = None) -> int:
319
+ ap = argparse.ArgumentParser(description="Portão de qualidade do vídeo exportado: só entrega com PASSOU.")
320
+ ap.add_argument("video", type=Path)
321
+ ap.add_argument("--projeto", type=Path, help="a pasta do projeto (lê o segura_fim e guarda a folha em renders/)")
322
+ ap.add_argument("--fps", type=float, default=3.0, help="quadros lidos por segundo para o texto (3)")
323
+ ap.add_argument("--folha", type=Path, help="PNG da folha de quadros (padrão: <projeto>/renders/portao.png)")
324
+ ap.add_argument("--ajustar-volume", action="store_true",
325
+ help="se só o volume estiver fora, regrava o áudio em -14 LUFS (sem novo render) e mede de novo")
326
+ ap.add_argument("--json", action="store_true")
327
+ a = ap.parse_args(argv)
328
+ if not a.video.is_file():
329
+ print(f"ERRO: não achei {a.video}", file=sys.stderr)
330
+ return 2
331
+ try:
332
+ require_binary("ffmpeg")
333
+ info = probe_video(a.video)
334
+ W, H = int(info.get("display_width") or info["width"]), int(info.get("display_height") or info["height"])
335
+ dur = float(info["duration"])
336
+ cfg = {}
337
+ if a.projeto and (a.projeto / "projeto.json").is_file():
338
+ cfg = json.loads((a.projeto / "projeto.json").read_text(encoding="utf-8"))
339
+ pretos = quadros_pretos(a.video, dur)
340
+ vol = volume(a.video)
341
+ volume_ajustado = None
342
+ if a.ajustar_volume and (abs(vol["lufs"] - ALVO_LUFS) > MARGEM_LUFS or vol["pico_db"] > PICO_MAX_DB):
343
+ volume_ajustado = ajustar_volume(a.video)
344
+ vol = volume(a.video)
345
+ db = onda.envelope(a.video, None)
346
+ th = onda.threshold(db)
347
+ resp = respiros.find(db, th, respiros.MIN_S)
348
+ fim_voz = fim_da_fala(cfg, dur)
349
+ resp_fim = []
350
+ if fim_voz is not None: # silence after the last word is the still end / the card, not a breath
351
+ resp_fim = [r for r in resp if r[0] >= fim_voz - 0.3]
352
+ resp = [r for r in resp if r not in resp_fim]
353
+ txt = texto_fora(a.video, W, H, a.fps, faixas_de_material(cfg, a.projeto, dur), faixas_com_rosto(cfg, a.projeto, dur))
354
+ modos = modos_do_volume(a.video, a.projeto, cfg)
355
+ clip = amostras_clipadas(a.video)
356
+ except (RuntimeError, OSError, ValueError) as e:
357
+ print(f"ERRO: {e}", file=sys.stderr)
358
+ return 2
359
+
360
+ falhas: list[str] = []
361
+ if pretos:
362
+ falhas.append("quadro preto: " + ", ".join(f"{fmt_time(x)} a {fmt_time(y)}" for x, y in pretos))
363
+ if abs(vol["lufs"] - ALVO_LUFS) > MARGEM_LUFS:
364
+ falhas.append(f"volume {vol['lufs']:.1f} LUFS (o alvo é {ALVO_LUFS:.0f}, margem {MARGEM_LUFS:g}): "
365
+ "ajuste o ganho do arquivo e confira de novo")
366
+ if vol["pico_db"] > PICO_MAX_DB:
367
+ falhas.append(f"pico {vol['pico_db']:.1f} dBTP (o limite é {PICO_MAX_DB:g}): baixe o ganho ou use o limitador")
368
+ dinamicos = [e for e, m in modos if str(m).lower() not in ("linear",)]
369
+ if dinamicos:
370
+ falhas.append("volume em modo dinâmico (" + ", ".join(dinamicos) + "): o loudnorm comprimiu a voz; rode o "
371
+ "helpers/ganho_por_trecho.py no edl_manual.json, refaça o corte e renderize")
372
+ if clip:
373
+ falhas.append(f"clipou: {clip} amostra(s) no teto digital; baixe o ganho do trecho ou o volume dos sons")
374
+ if resp:
375
+ falhas.append("respiro: " + ", ".join(f"{fmt_time(x)} ({y - x:.2f} s)" for x, y in resp))
376
+ for f in txt["achados"]:
377
+ falhas.append(f"texto {f['motivo']}: \"{f['texto'][:40]}\" em {fmt_time(f['t'])}"
378
+ + (f" a {fmt_time(f['ate'])}" if f["ate"] > f["t"] else ""))
379
+ saida = a.folha or ((a.projeto / "renders" / "portao.png") if a.projeto else a.video.with_name("portao.png"))
380
+ tempos = sorted({round(min(dur - 0.05, x), 2) for x in
381
+ [0.1] + [f["t"] for f in txt["achados"]][:6] + [i * dur / 11 for i in range(1, 12)]})[:18]
382
+ try:
383
+ saida.parent.mkdir(parents=True, exist_ok=True)
384
+ folha(a.video, W, H, tempos, saida)
385
+ except Exception as e: # noqa: BLE001 (the sheet is a help to the eye, it never fails the gate)
386
+ saida = None
387
+ print(f"aviso: não montei a folha de quadros ({e})", file=sys.stderr)
388
+
389
+ if txt.get("rosto") is False:
390
+ print("aviso: o rosto não foi conferido (sem o detector YuNet aqui); texto colado no rosto não reprova", file=sys.stderr)
391
+ rel = {"passou": not falhas, "falhas": falhas, "duracao": round(dur, 2), "volume": vol, "rosto_conferido": txt.get("rosto"),
392
+ "modos_do_volume": dict(modos), "amostras_clipadas": clip,
393
+ "limiar_respiro_db": th, "leitor_de_texto": txt["leitor"], "folha": str(saida) if saida else None}
394
+ if a.json:
395
+ print(json.dumps(rel, ensure_ascii=False))
396
+ return 0 if not falhas else 1
397
+ print(f"portão · {a.video.name} · {dur:.1f} s · {W}x{H}")
398
+ print(f" quadro preto: {'nenhum' if not pretos else len(pretos)}")
399
+ print(f" volume: {vol['lufs']:.1f} LUFS, pico {vol['pico_db']:.1f} dBTP"
400
+ + (f" (ajustado; o original ficou em {volume_ajustado.name})" if volume_ajustado else ""))
401
+ print(" ajuste de volume: " + (", ".join(f"{e} {m}" for e, m in modos) if modos else "sem registro")
402
+ + f" · amostras clipadas: {clip}")
403
+ print(f" respiros de 0,25 s ou mais: {'nenhum' if not resp else len(resp)}"
404
+ + (f" (o silêncio do fim, {resp_fim[-1][1] - resp_fim[0][0]:.1f} s, é o fim parado ou o cartão e não conta)" if resp_fim else ""))
405
+ if txt["leitor"] is None:
406
+ print(" texto na tela: NÃO conferido (sem leitor de texto neste computador; olhe a folha de quadros)")
407
+ elif txt["zona"] is None:
408
+ print(f" texto na tela: só a borda foi conferida (formato {W}x{H}; a zona segura vale para 9:16)")
409
+ else:
410
+ print(f" texto fora da zona segura, na borda ou colado no rosto: {'nenhum' if not txt['achados'] else len(txt['achados'])} "
411
+ f"(leitor {txt['leitor']})"
412
+ + (f"; sem ler as telas gravadas em {', '.join(fmt_time(x) + '-' + fmt_time(y) for x, y in txt['ignorado'])}"
413
+ if txt.get("ignorado") else ""))
414
+ if saida:
415
+ print(f" folha de quadros: {saida}")
416
+ if falhas:
417
+ print("REPROVOU:")
418
+ for f in falhas:
419
+ print(f" - {f}")
420
+ print("Conserte o que está acima, renderize de novo e rode o portão outra vez. Não entregue assim.")
421
+ return 1
422
+ print("PASSOU")
423
+ return 0
424
+
425
+
426
+ if __name__ == "__main__":
427
+ raise SystemExit(main())