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,2232 @@
1
+ #!/usr/bin/env python3
2
+ """Mede a linguagem de edição de um vídeo de referência, sem copiar o conteúdo dele.
3
+
4
+ Uso (<SKILL> é a pasta desta skill; o Python dela já tem numpy, opencv e pillow):
5
+
6
+ uv run --directory "<SKILL>" python helpers/medir_referencia.py VIDEO --saida PASTA
7
+ [--limiar 0.20] [--sem-fala] [--sem-texto] [--transcricao ARQUIVO.json]
8
+ [--editor-video OUTRA_PASTA_DA_SKILL]
9
+
10
+ Usa os helpers irmãos (transcribe.py e texto_na_tela.py) e o detector de rosto e a fonte da própria skill.
11
+ --editor-video (ou a variável EDITOR_VIDEO_DIR) aponta para outra instalação, se precisar.
12
+
13
+ O que sai na PASTA:
14
+
15
+ guia-de-estilo.md o que foi medido, separado em MEDIDO, ESTIMADO e NÃO DÁ PRA SABER
16
+ trechos-medidos.csv um segundo por linha: o que aparece, o que está escrito na tela, som
17
+ regras-da-referencia.json valores prontos para o motor de edição (ritmo, legenda, cores, sons, trilha)
18
+ folha-de-quadros/ 1 quadro por segundo em grade, com o tempo e os cortes marcados
19
+ cortes-para-conferir*.jpg o quadro antes e o depois de cada corte achado
20
+ recortes/ amostras da legenda e dos textos fixos em tamanho real
21
+ medidas/ dados brutos (nota de cena por quadro, áudio, texto lido, transcrição)
22
+
23
+ Nada é escrito na pasta do vídeo de referência. Pasta medida por uma versão antiga pode ter os mesmos dois arquivos
24
+ com o nome de antes (mapa.csv e receita.json); quem lê a pasta usa o nome novo e, se ele faltar, o antigo.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import csv
31
+ import hashlib
32
+ import json
33
+ import math
34
+ import os
35
+ import re
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ import tempfile
40
+ import time
41
+ import unicodedata
42
+ from collections import Counter, defaultdict
43
+ from concurrent.futures import ThreadPoolExecutor
44
+ from pathlib import Path
45
+
46
+ try:
47
+ import numpy as np
48
+ except ImportError: # pragma: no cover
49
+ print("erro: falta o numpy. Rode com o Python da skill:\n"
50
+ ' uv run --directory "<SKILL>" python helpers/medir_referencia.py ...', file=sys.stderr)
51
+ sys.exit(2)
52
+ os.environ.setdefault("OPENCV_LOG_LEVEL", "ERROR")
53
+ try:
54
+ import cv2
55
+ except ImportError: # pragma: no cover
56
+ cv2 = None
57
+ from PIL import Image, ImageDraw, ImageFont
58
+
59
+ VERSAO = 1
60
+ SKILL = Path(__file__).resolve().parent.parent # esta skill (helpers/..)
61
+ EV_PADRAO = Path(os.environ.get("EDITOR_VIDEO_DIR") or SKILL)
62
+ SR = 22050 # taxa do áudio analisado
63
+ JANELA = 1024 # amostras por janela de espectro (46 ms)
64
+ PASSO = 256 # amostras entre janelas (11,6 ms)
65
+ BANDAS = {"sub": (25, 70), "grave": (70, 200), "corpo": (200, 800), "voz": (800, 3000), "agudo": (3500, 10000)}
66
+ PISO_CENA = 0.08 # nota de cena mínima para olhar um pico
67
+ RAZAO_PROVAVEL = 8.0 # pico fraco conta como corte provável se for 8x maior que a vizinhança
68
+ ARQ_TRECHOS = "trechos-medidos.csv" # um segundo por linha
69
+ ARQ_REGRAS = "regras-da-referencia.json" # os números para máquina
70
+ NOMES_ANTIGOS = {ARQ_TRECHOS: "mapa.csv", ARQ_REGRAS: "receita.json"} # como a versão antiga chamava
71
+
72
+
73
+ class ErroMedida(RuntimeError):
74
+ pass
75
+
76
+
77
+ # ----------------------------------------------------------------------------- pequenos utilitários
78
+
79
+ _T0 = time.time()
80
+
81
+
82
+ def falar(msg: str) -> None:
83
+ if re.match(r"\d/\d ", msg):
84
+ msg = f"{msg} ({int(time.time() - _T0)} s)"
85
+ print(msg, flush=True)
86
+
87
+
88
+ def aviso(msg: str) -> None:
89
+ print(f"aviso: {msg}", flush=True)
90
+
91
+
92
+ def utf8_console() -> None:
93
+ for s in (sys.stdout, sys.stderr):
94
+ try:
95
+ s.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
96
+ except (AttributeError, ValueError, OSError):
97
+ pass
98
+
99
+
100
+ def rodar(cmd: list[str], cwd: Path | None = None, checar: bool = True) -> subprocess.CompletedProcess:
101
+ try:
102
+ r = subprocess.run(cmd, cwd=cwd, capture_output=True)
103
+ except FileNotFoundError as exc:
104
+ raise ErroMedida(f"não achei o programa '{cmd[0]}'. Instale o ffmpeg (no Mac: brew install ffmpeg).") from exc
105
+ if checar and r.returncode != 0:
106
+ fim = r.stderr.decode("utf-8", "replace").strip()[-600:]
107
+ raise ErroMedida(f"o comando {Path(cmd[0]).name} falhou: {fim}")
108
+ return r
109
+
110
+
111
+ def mmss(t: float) -> str:
112
+ t = max(0.0, float(t))
113
+ m = int(t // 60)
114
+ return f"{m:02d}:{t - 60 * m:05.2f}"
115
+
116
+
117
+ def mmss_curto(t: float) -> str:
118
+ t = max(0.0, float(t))
119
+ return f"{int(t // 60):02d}:{int(t % 60):02d}"
120
+
121
+
122
+ def br(x: float | None, casas: int = 1) -> str:
123
+ if x is None or (isinstance(x, float) and not math.isfinite(x)):
124
+ return "?"
125
+ return f"{x:.{casas}f}".replace(".", ",")
126
+
127
+
128
+ def pct(x: float | None, casas: int = 0) -> str:
129
+ return "?" if x is None else f"{br(100 * x, casas)}%"
130
+
131
+
132
+ def hexcor(rgb) -> str:
133
+ return "#%02X%02X%02X" % tuple(int(round(max(0, min(255, float(c))))) for c in rgb)
134
+
135
+
136
+ def norm_txt(s: str) -> str:
137
+ s = unicodedata.normalize("NFKD", s or "")
138
+ s = "".join(c for c in s if not unicodedata.combining(c)).lower()
139
+ return re.sub(r"[^a-z0-9 ]+", "", re.sub(r"\s+", " ", s)).strip()
140
+
141
+
142
+ def salvar_json(caminho: Path, dados) -> None:
143
+ tmp = caminho.with_suffix(caminho.suffix + ".tmp")
144
+ tmp.write_text(json.dumps(dados, ensure_ascii=False, indent=2), encoding="utf-8")
145
+ tmp.replace(caminho)
146
+
147
+
148
+ def python_ev(ev: Path) -> list[str]:
149
+ """Python que roda os helpers da skill em ev: o mesmo deste processo quando ev é esta skill."""
150
+ try:
151
+ if ev.resolve() == SKILL:
152
+ return [sys.executable]
153
+ except OSError:
154
+ pass
155
+ for p in (ev / ".venv" / "bin" / "python", ev / ".venv" / "Scripts" / "python.exe"):
156
+ if p.exists():
157
+ return [str(p)]
158
+ return ["uv", "run", "--directory", str(ev), "python"]
159
+
160
+
161
+ def carregar_fonte(ev: Path, tamanho: int):
162
+ for p in (ev / "assets" / "fonts" / "figtree" / "Figtree-Variable.ttf",
163
+ Path("/System/Library/Fonts/Menlo.ttc"),
164
+ Path("C:/Windows/Fonts/arial.ttf"),
165
+ Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")):
166
+ try:
167
+ f = ImageFont.truetype(str(p), tamanho)
168
+ try:
169
+ f.set_variation_by_axes([700])
170
+ except Exception:
171
+ pass
172
+ return f
173
+ except Exception:
174
+ continue
175
+ try:
176
+ return ImageFont.load_default(size=tamanho)
177
+ except TypeError:
178
+ return ImageFont.load_default()
179
+
180
+
181
+ # ----------------------------------------------------------------------------- 1. ficha técnica
182
+
183
+ def _fracao(txt: str | None) -> float:
184
+ if not txt or txt in ("0/0", "N/A"):
185
+ return 0.0
186
+ if "/" in txt:
187
+ a, b = txt.split("/", 1)
188
+ return float(a) / float(b) if float(b) else 0.0
189
+ return float(txt)
190
+
191
+
192
+ def _proporcao(w: int, h: int) -> str:
193
+ alvo = {"9:16": 9 / 16, "4:5": 4 / 5, "1:1": 1.0, "16:9": 16 / 9, "3:4": 3 / 4, "4:3": 4 / 3, "2:3": 2 / 3}
194
+ r = w / h
195
+ nome, val = min(alvo.items(), key=lambda kv: abs(kv[1] - r))
196
+ return nome if abs(val - r) / val < 0.03 else f"{w}:{h}"
197
+
198
+
199
+ def ficha_tecnica(video: Path) -> dict:
200
+ r = rodar(["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(video)])
201
+ d = json.loads(r.stdout.decode("utf-8", "replace"))
202
+ vs = [s for s in d.get("streams", []) if s.get("codec_type") == "video"
203
+ and not (s.get("disposition") or {}).get("attached_pic")]
204
+ if not vs:
205
+ raise ErroMedida("esse arquivo não tem imagem de vídeo.")
206
+ v = vs[0]
207
+ a = next((s for s in d.get("streams", []) if s.get("codec_type") == "audio"), None)
208
+ dur = float(d.get("format", {}).get("duration") or v.get("duration") or 0)
209
+ w, h = int(v["width"]), int(v["height"])
210
+ rot = 0
211
+ for sd in v.get("side_data_list") or []:
212
+ if "rotation" in sd:
213
+ rot = int(round(float(sd["rotation"])))
214
+ if "rotate" in (v.get("tags") or {}):
215
+ rot = int(v["tags"]["rotate"])
216
+ if abs(rot) % 180 == 90:
217
+ w, h = h, w
218
+ fps_med = _fracao(v.get("avg_frame_rate"))
219
+ fps_nom = _fracao(v.get("r_frame_rate"))
220
+ fps = fps_med or fps_nom or 30.0
221
+ sha = hashlib.sha256()
222
+ with open(video, "rb") as fh:
223
+ for bloco in iter(lambda: fh.read(1 << 20), b""):
224
+ sha.update(bloco)
225
+ return {
226
+ "arquivo": video.name,
227
+ "sha256": sha.hexdigest(),
228
+ "tamanho_mb": round(video.stat().st_size / 1e6, 2),
229
+ "duracao_s": round(dur, 3),
230
+ "largura": w, "altura": h, "rotacao": rot,
231
+ "proporcao": _proporcao(w, h),
232
+ "vertical": h > w,
233
+ "fps": round(fps, 3),
234
+ "fps_variavel": bool(fps_nom and fps_med and abs(fps_nom - fps_med) > 0.5),
235
+ "codec_video": v.get("codec_name"),
236
+ "tem_audio": a is not None,
237
+ "codec_audio": a.get("codec_name") if a else None,
238
+ "canais_audio": int(a.get("channels") or 0) if a else 0,
239
+ "taxa_audio_hz": int(a.get("sample_rate") or 0) if a else 0,
240
+ }
241
+
242
+
243
+ # ----------------------------------------------------------------------------- 2. cortes
244
+
245
+ def pontuar_cenas(video: Path, tmp: Path) -> tuple[np.ndarray, np.ndarray]:
246
+ """Nota de mudança de cena do ffmpeg (0 a 1) para cada quadro."""
247
+ rodar(["ffmpeg", "-v", "error", "-nostdin", "-i", str(video), "-an", "-sn", "-dn", "-vf",
248
+ "scale=320:-2,select='gte(scene\\,0)',metadata=print:key=lavfi.scene_score:file=cenas.txt",
249
+ "-f", "null", "-"], cwd=tmp)
250
+ tempos: list[float] = []
251
+ notas: list[float] = []
252
+ t = None
253
+ for linha in (tmp / "cenas.txt").read_text(errors="replace").splitlines():
254
+ m = re.search(r"pts_time:(-?[\d.]+)", linha)
255
+ if m:
256
+ t = float(m.group(1))
257
+ continue
258
+ m = re.search(r"scene_score=([\d.]+)", linha)
259
+ if m and t is not None:
260
+ tempos.append(t)
261
+ notas.append(float(m.group(1)))
262
+ t = None
263
+ return np.array(tempos, dtype=np.float64), np.array(notas, dtype=np.float64)
264
+
265
+
266
+ def achar_cortes(tempos: np.ndarray, notas: np.ndarray, dur: float, limiar: float) -> list[dict]:
267
+ n = len(notas)
268
+ cand: list[dict] = []
269
+ for i in range(1, n):
270
+ t, s = float(tempos[i]), float(notas[i])
271
+ if s < PISO_CENA or t < 0.1 or t > dur - 0.05:
272
+ continue
273
+ if s < notas[max(0, i - 3):i + 4].max():
274
+ continue
275
+ viz = np.concatenate([notas[max(0, i - 15):max(0, i - 2)], notas[i + 3:i + 16]])
276
+ base = float(np.median(viz)) if len(viz) else 0.0
277
+ razao = s / max(base, 0.003)
278
+ if s >= limiar:
279
+ tipo = "corte"
280
+ elif razao >= RAZAO_PROVAVEL:
281
+ tipo = "provavel"
282
+ else:
283
+ continue
284
+ cand.append({"t": round(t, 3), "quadro": i, "nota": round(s, 3), "razao": round(razao, 1), "tipo": tipo})
285
+ grupos: list[list[dict]] = []
286
+ for c in cand:
287
+ if grupos and c["t"] - grupos[-1][-1]["t"] < 0.25:
288
+ grupos[-1].append(c)
289
+ else:
290
+ grupos.append([c])
291
+ cortes = []
292
+ for g in grupos:
293
+ melhor = dict(max(g, key=lambda c: c["nota"]))
294
+ if any(c["tipo"] == "corte" for c in g):
295
+ melhor["tipo"] = "corte"
296
+ melhor["picos_juntos"] = len(g)
297
+ if len(g) >= 3:
298
+ melhor["obs"] = "vários quadros mudando seguidos: pode ser transição animada"
299
+ cortes.append(melhor)
300
+ return cortes
301
+
302
+
303
+ def medir_ritmo(cortes_t: list[float], dur: float) -> dict:
304
+ cortes_t = sorted(cortes_t)
305
+ bordas = [0.0] + cortes_t + [dur]
306
+ planos = [b - a for a, b in zip(bordas, bordas[1:]) if b - a > 0.01]
307
+ inter = [b - a for a, b in zip(cortes_t, cortes_t[1:])]
308
+ n = len(cortes_t)
309
+ maior_i = int(np.argmax(planos)) if planos else 0
310
+ por10 = []
311
+ for a in range(0, int(math.ceil(dur / 10.0))):
312
+ por10.append(sum(1 for t in cortes_t if a * 10 <= t < (a + 1) * 10))
313
+ return {
314
+ "cortes": n,
315
+ "cortes_por_minuto": round(n / (dur / 60.0), 1) if dur > 0 else 0.0,
316
+ "plano_medio_s": round(float(np.mean(planos)), 2) if planos else round(dur, 2),
317
+ "plano_mediano_s": round(float(np.median(planos)), 2) if planos else round(dur, 2),
318
+ "intervalo_medio_entre_cortes_s": round(float(np.mean(inter)), 2) if inter else None,
319
+ "plano_mais_curto_s": round(min(planos), 2) if planos else None,
320
+ "plano_mais_longo_s": round(max(planos), 2) if planos else None,
321
+ "maior_trecho_sem_corte": {"inicio": round(bordas[maior_i], 2), "fim": round(bordas[maior_i + 1], 2)}
322
+ if planos else None,
323
+ "planos_curtos_pct": round(sum(1 for p in planos if p < 1.5) / len(planos), 3) if planos else 0.0,
324
+ "cortes_nos_3_primeiros_s": sum(1 for t in cortes_t if t < 3.0),
325
+ "primeiro_corte_s": round(cortes_t[0], 3) if cortes_t else None,
326
+ "cortes_por_10s": por10,
327
+ }
328
+
329
+
330
+ # ----------------------------------------------------------------------------- 3. quadros
331
+
332
+ def tela_padrao(w: int, h: int) -> tuple[int, int]:
333
+ """Tela de referência para converter medidas: 1080x1920 (vertical), 1920x1080 (horizontal) ou 1080x1080."""
334
+ r = w / h
335
+ if r < 0.9:
336
+ return 1080, 1920
337
+ if r > 1.1:
338
+ return 1920, 1080
339
+ return 1080, 1080
340
+
341
+
342
+ def tamanho_saida(w: int, h: int, lado: int) -> tuple[int, int]:
343
+ esc = min(1.0, lado / max(w, h))
344
+ return max(2, int(round(w * esc / 2)) * 2), max(2, int(round(h * esc / 2)) * 2)
345
+
346
+
347
+ def _tempos_showinfo(stderr: bytes) -> list[float]:
348
+ txt = stderr.decode("utf-8", "replace")
349
+ return [float(m.group(1)) for m in re.finditer(r"\bn:\s*\d+\s+pts:\s*-?\d+\s+pts_time:(-?[\d.]+)", txt)]
350
+
351
+
352
+ def quadros_por_segundo(video: Path, pasta: Path, w: int, h: int) -> list[tuple[float, Path]]:
353
+ pasta.mkdir(parents=True, exist_ok=True)
354
+ vf = f"select='isnan(prev_t)+gt(floor(t)\\,floor(prev_t))',scale={w}:{h},showinfo"
355
+ r = rodar(["ffmpeg", "-hide_banner", "-nostdin", "-nostats", "-loglevel", "info", "-i", str(video),
356
+ "-an", "-sn", "-vf", vf, "-fps_mode", "vfr", "-q:v", "3", str(pasta / "q_%05d.jpg")])
357
+ tempos = _tempos_showinfo(r.stderr)
358
+ arqs = sorted(pasta.glob("q_*.jpg"))
359
+ if len(tempos) != len(arqs):
360
+ tempos = [float(i) for i in range(len(arqs))]
361
+ return list(zip(tempos, arqs))
362
+
363
+
364
+ def quadros_por_indice(video: Path, tempos_q: np.ndarray, indices: list[int], pasta: Path,
365
+ w: int, h: int) -> dict[int, Path]:
366
+ """Tira quadros exatos pelo número (o mesmo da nota de cena). Quadros seguidos saem numa chamada só."""
367
+ n_total = len(tempos_q)
368
+ pedidos = sorted({int(i) for i in indices if 0 <= i < n_total})
369
+ if not pedidos:
370
+ return {}
371
+ pasta.mkdir(parents=True, exist_ok=True)
372
+ grupos: list[list[int]] = []
373
+ for i in pedidos:
374
+ if grupos and i - grupos[-1][-1] == 1 and len(grupos[-1]) < 8:
375
+ grupos[-1].append(i)
376
+ else:
377
+ grupos.append([i])
378
+
379
+ def tirar(g: list[int]) -> list[tuple[int, Path]]:
380
+ k0 = g[0]
381
+ alvo = (float(tempos_q[k0 - 1]) + float(tempos_q[k0])) / 2 if k0 > 0 else 0.0
382
+ pref = f"g{k0:07d}_"
383
+ subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-ss", f"{alvo:.4f}", "-i", str(video), "-an", "-sn",
384
+ "-frames:v", str(len(g)), "-vf", f"scale={w}:{h}", "-fps_mode", "passthrough", "-q:v", "3",
385
+ str(pasta / f"{pref}%02d.jpg")], capture_output=True)
386
+ return list(zip(g, sorted(pasta.glob(f"{pref}*.jpg"))))
387
+
388
+ saida: dict[int, Path] = {}
389
+ with ThreadPoolExecutor(max_workers=max(2, min(6, (os.cpu_count() or 4) // 2))) as ex:
390
+ for pares in ex.map(tirar, grupos):
391
+ saida.update(dict(pares))
392
+ return saida
393
+
394
+
395
+ # ----------------------------------------------------------------------------- 4. rosto
396
+
397
+ class Rostos:
398
+ def __init__(self, ev: Path):
399
+ self.det = None
400
+ modelo = ev / "assets" / "models" / "face_detection_yunet_2023mar.onnx"
401
+ if cv2 is not None and hasattr(cv2, "FaceDetectorYN") and modelo.exists():
402
+ try:
403
+ self.det = cv2.FaceDetectorYN.create(str(modelo), "", (320, 320), 0.7, 0.3, 50)
404
+ except Exception:
405
+ self.det = None
406
+
407
+ @property
408
+ def ok(self) -> bool:
409
+ return self.det is not None
410
+
411
+ def medir(self, img) -> dict | None:
412
+ if self.det is None or img is None:
413
+ return None
414
+ if max(img.shape[:2]) > 640: # as medidas são em fração do quadro; imagem menor acha o mesmo rosto mais rápido
415
+ esc = 640.0 / max(img.shape[:2])
416
+ img = cv2.resize(img, (int(img.shape[1] * esc), int(img.shape[0] * esc)), interpolation=cv2.INTER_AREA)
417
+ h, w = img.shape[:2]
418
+ self.det.setInputSize((w, h))
419
+ try:
420
+ _, faces = self.det.detect(img)
421
+ except Exception:
422
+ return None
423
+ if faces is None or len(faces) == 0:
424
+ return None
425
+ f = max(faces, key=lambda r: r[2] * r[3])
426
+ x, y, fw, fh = [float(v) for v in f[:4]]
427
+ return {"altura": round(fh / h, 4), "largura": round(fw / w, 4),
428
+ "cx": round((x + fw / 2) / w, 4), "cy": round((y + fh / 2) / h, 4),
429
+ "olhos_y": round(float(f[5] + f[7]) / 2 / h, 4), "conf": round(float(f[14]), 3),
430
+ "rostos": int(len(faces))}
431
+
432
+
433
+ def mesma_cena(a, b) -> bool:
434
+ """Dois quadros parecem da mesma gravação (mesmas cores de fundo), mesmo com zoom diferente."""
435
+ if a is None or b is None:
436
+ return False
437
+ ha = cv2.calcHist([cv2.cvtColor(a, cv2.COLOR_BGR2HSV)], [0, 1], None, [30, 16], [0, 180, 0, 256])
438
+ hb = cv2.calcHist([cv2.cvtColor(b, cv2.COLOR_BGR2HSV)], [0, 1], None, [30, 16], [0, 180, 0, 256])
439
+ cv2.normalize(ha, ha)
440
+ cv2.normalize(hb, hb)
441
+ return float(cv2.compareHist(ha, hb, cv2.HISTCMP_CORREL)) >= 0.75
442
+
443
+
444
+ def tipo_de_plano(altura_rosto: float | None) -> str:
445
+ if altura_rosto is None:
446
+ return "sem rosto"
447
+ if altura_rosto >= 0.30:
448
+ return "rosto bem perto"
449
+ if altura_rosto >= 0.17:
450
+ return "rosto perto"
451
+ if altura_rosto >= 0.09:
452
+ return "plano médio"
453
+ return "plano aberto"
454
+
455
+
456
+ # ----------------------------------------------------------------------------- 5. cores
457
+
458
+ def _lab(px_bgr: np.ndarray) -> np.ndarray:
459
+ lab = cv2.cvtColor(px_bgr.reshape(-1, 1, 3).astype(np.uint8), cv2.COLOR_BGR2LAB).reshape(-1, 3).astype(np.float32)
460
+ lab[:, 0] *= 100.0 / 255.0
461
+ lab[:, 1:] -= 128.0
462
+ return lab
463
+
464
+
465
+ def _lab_hex(lab) -> str:
466
+ l8 = np.array([[[lab[0] * 255.0 / 100.0, lab[1] + 128.0, lab[2] + 128.0]]]).clip(0, 255).astype(np.uint8)
467
+ return hexcor(cv2.cvtColor(l8, cv2.COLOR_LAB2RGB)[0, 0])
468
+
469
+
470
+ def _kmeans(lab: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]:
471
+ k = max(1, min(k, len(lab)))
472
+ crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.5)
473
+ cv2.setRNGSeed(7)
474
+ _, rot, cen = cv2.kmeans(lab.astype(np.float32), k, None, crit, 3, cv2.KMEANS_PP_CENTERS)
475
+ return rot.ravel(), cen
476
+
477
+
478
+ def _juntar_cores(itens: list[dict], limite: float) -> list[dict]:
479
+ """itens: {lab, peso}. Junta cores parecidas (distância Lab menor que o limite)."""
480
+ grupos: list[dict] = []
481
+ for it in sorted(itens, key=lambda x: -x["peso"]):
482
+ for g in grupos:
483
+ if float(np.linalg.norm(g["lab"] - it["lab"])) < limite:
484
+ tot = g["peso"] + it["peso"]
485
+ g["lab"] = (g["lab"] * g["peso"] + it["lab"] * it["peso"]) / tot
486
+ g["peso"] = tot
487
+ g["n"] = g.get("n", 1) + it.get("n", 1)
488
+ break
489
+ else:
490
+ grupos.append({"lab": np.array(it["lab"], dtype=np.float32), "peso": it["peso"], "n": it.get("n", 1)})
491
+ return sorted(grupos, key=lambda g: -g["peso"])
492
+
493
+
494
+ def descrever_cor(lab) -> str:
495
+ l, a, b = [float(v) for v in lab]
496
+ croma = math.hypot(a, b)
497
+ if croma < 10:
498
+ if l > 85:
499
+ return "branco"
500
+ if l < 15:
501
+ return "preto"
502
+ return "cinza claro" if l > 60 else ("cinza" if l > 35 else "cinza escuro")
503
+ ang = (math.degrees(math.atan2(b, a)) + 360) % 360
504
+ nomes = [(20, "rosa"), (45, "vermelho"), (70, "laranja"), (100, "amarelo"), (135, "verde-amarelado"),
505
+ (170, "verde"), (215, "verde-azulado"), (260, "azul"), (300, "roxo"), (340, "lilás"), (361, "rosa")]
506
+ nome = next(n for lim, n in nomes if ang < lim)
507
+ if nome == "laranja" and l < 45:
508
+ nome = "marrom"
509
+ tom = "claro" if l > 70 else ("escuro" if l < 35 else "")
510
+ return f"{nome} {tom}".strip()
511
+
512
+
513
+ def paleta_da_imagem(quadros: list[tuple[float, Path]]) -> dict:
514
+ if cv2 is None or not quadros:
515
+ return {}
516
+ amostras = []
517
+ brilho = []
518
+ satur = []
519
+ for _, arq in quadros:
520
+ img = cv2.imread(str(arq))
521
+ if img is None:
522
+ continue
523
+ peq = cv2.resize(img, (48, int(48 * img.shape[0] / img.shape[1])), interpolation=cv2.INTER_AREA)
524
+ amostras.append(peq.reshape(-1, 3))
525
+ hsv = cv2.cvtColor(peq, cv2.COLOR_BGR2HSV)
526
+ brilho.append(float(hsv[..., 2].mean()) / 255.0)
527
+ satur.append(float(hsv[..., 1].mean()) / 255.0)
528
+ if not amostras:
529
+ return {}
530
+ px = np.concatenate(amostras)
531
+ lab = _lab(px)
532
+ rot, cen = _kmeans(lab, 10)
533
+ itens = [{"lab": cen[i], "peso": float((rot == i).mean())} for i in range(len(cen))]
534
+ grupos = _juntar_cores(itens, 12.0)
535
+ dominantes = [{"hex": _lab_hex(g["lab"]), "fracao": round(g["peso"], 3), "nome": descrever_cor(g["lab"]),
536
+ "luz": round(float(g["lab"][0]), 1), "croma": round(float(np.hypot(g["lab"][1], g["lab"][2])), 1)}
537
+ for g in grupos[:7]]
538
+ # cores saturadas que aparecem pouco mas chamam atenção (candidatas a destaque)
539
+ rot2, cen2 = _kmeans(lab[np.hypot(lab[:, 1], lab[:, 2]) > 30], 6) if (np.hypot(lab[:, 1], lab[:, 2]) > 30).sum() > 50 \
540
+ else (np.array([]), np.zeros((0, 3)))
541
+ destaque = []
542
+ if len(cen2):
543
+ itens2 = [{"lab": cen2[i], "peso": float((rot2 == i).mean())} for i in range(len(cen2))]
544
+ tot_sat = float((np.hypot(lab[:, 1], lab[:, 2]) > 30).mean())
545
+ for g in _juntar_cores(itens2, 15.0)[:4]:
546
+ destaque.append({"hex": _lab_hex(g["lab"]), "nome": descrever_cor(g["lab"]),
547
+ "fracao_da_imagem": round(g["peso"] * tot_sat, 4)})
548
+ a_med = float(lab[:, 1].mean())
549
+ b_med = float(lab[:, 2].mean())
550
+ return {
551
+ "dominantes": dominantes,
552
+ "saturadas": destaque,
553
+ "brilho_medio": round(float(np.mean(brilho)), 3),
554
+ "saturacao_media": round(float(np.mean(satur)), 3),
555
+ "quadros_escuros_pct": round(float(np.mean([b < 0.30 for b in brilho])), 3),
556
+ "quadros_claros_pct": round(float(np.mean([b > 0.75 for b in brilho])), 3),
557
+ "temperatura": "quente" if b_med > 6 or a_med > 6 else ("fria" if b_med < -4 else "neutra"),
558
+ }
559
+
560
+
561
+ def cores_do_texto(img, caixa: tuple[int, int, int, int]) -> dict | None:
562
+ """Cor do preenchimento, do contorno e do fundo de uma linha de texto (recorte BGR)."""
563
+ x0, y0, x1, y1 = caixa
564
+ rec = img[y0:y1, x0:x1]
565
+ if rec.size == 0 or rec.shape[0] < 8 or rec.shape[1] < 8:
566
+ return None
567
+ lab = _lab(rec.reshape(-1, 3)).reshape(rec.shape[0], rec.shape[1], 3)
568
+ anel = np.concatenate([lab[:2].reshape(-1, 3), lab[-2:].reshape(-1, 3), lab[:, :2].reshape(-1, 3),
569
+ lab[:, -2:].reshape(-1, 3)])
570
+ fundo = np.median(anel, axis=0)
571
+ fundo_liso = float(anel[:, 0].std()) < 7.0
572
+ todos = lab.reshape(-1, 3)
573
+ dist = np.linalg.norm(todos - fundo, axis=1)
574
+ tinta = todos[dist > 28]
575
+ if len(tinta) < 0.03 * len(todos) or len(tinta) < 30:
576
+ return None
577
+ rot, cen = _kmeans(tinta, 3)
578
+ d_tinta = dist[dist > 28]
579
+ grupos = []
580
+ for i in range(len(cen)):
581
+ sel = rot == i
582
+ fr = float(sel.mean())
583
+ if not sel.any():
584
+ continue
585
+ px, dd = tinta[sel], d_tinta[sel]
586
+ miolo = px[dd >= np.median(dd)].mean(0) # o miolo da letra, sem a borda misturada com o fundo
587
+ grupos.append({"lab": miolo, "fracao": fr, "luz": float(miolo[0]), "croma": float(np.hypot(miolo[1], miolo[2]))})
588
+ escuro_fundo = float(fundo[0]) < 30
589
+ contorno = None
590
+ if not escuro_fundo:
591
+ escuros = [g for g in grupos if g["luz"] < 25 and g["fracao"] >= 0.12]
592
+ if escuros:
593
+ contorno = max(escuros, key=lambda g: g["fracao"])
594
+ enche = [g for g in grupos if g is not contorno and g["fracao"] >= 0.10 and (g["luz"] > 50 or g["croma"] > 35)]
595
+ if not enche:
596
+ enche = [max(grupos, key=lambda g: float(np.linalg.norm(g["lab"] - fundo)))]
597
+ enche.sort(key=lambda g: -g["fracao"])
598
+ duas = len(enche) >= 2 and float(np.linalg.norm(enche[0]["lab"] - enche[1]["lab"])) > 30
599
+ return {
600
+ "preenchimento": [{"lab": g["lab"], "hex": _lab_hex(g["lab"]), "fracao": round(g["fracao"], 3)} for g in enche[:2]],
601
+ "contorno_escuro": contorno is not None,
602
+ "fundo_hex": _lab_hex(fundo),
603
+ "fundo_liso": fundo_liso,
604
+ "duas_cores": duas,
605
+ }
606
+
607
+
608
+ def papeis_das_cores(amostras_cor: list[dict]) -> dict:
609
+ """Separa cor do texto, cor de destaque (palavra falada) e cor apagada (palavra que ainda vem)."""
610
+ itens = [{"lab": p["lab"], "peso": 1.0, "n": 1} for c in amostras_cor for p in c["preenchimento"][:2]]
611
+ grupos = [g for g in _juntar_cores(itens, 16.0) if g["peso"] >= max(2, 0.06 * len(amostras_cor))]
612
+ if not grupos:
613
+ return {}
614
+
615
+ def croma(g):
616
+ return float(np.hypot(g["lab"][1], g["lab"][2]))
617
+ neutros_claros = [g for g in grupos if croma(g) < 22 and g["lab"][0] >= 70]
618
+ coloridos = [g for g in grupos if croma(g) >= 25]
619
+ apagados = [g for g in grupos if croma(g) < 22 and 35 <= g["lab"][0] < 70]
620
+ out: dict = {}
621
+ if neutros_claros:
622
+ texto = max(neutros_claros, key=lambda g: g["peso"])
623
+ destaque = max(coloridos, key=lambda g: g["peso"]) if coloridos else None
624
+ else:
625
+ texto = max(grupos, key=lambda g: g["peso"])
626
+ resto = [g for g in coloridos if float(np.linalg.norm(g["lab"] - texto["lab"])) > 30]
627
+ destaque = max(resto, key=lambda g: g["peso"]) if resto else None
628
+ out["cor_texto"] = _lab_hex(texto["lab"])
629
+ if destaque is not None:
630
+ out["cor_destaque"] = _lab_hex(destaque["lab"])
631
+ out["duas_cores_pct"] = round(min(1.0, destaque["peso"] / len(amostras_cor)), 3)
632
+ if apagados:
633
+ g = max(apagados, key=lambda g: g["peso"])
634
+ if g["peso"] >= 0.1 * len(amostras_cor):
635
+ out["cor_apagada"] = _lab_hex(g["lab"])
636
+ out["cores_lidas"] = [{"hex": _lab_hex(g["lab"]), "linhas": int(g["peso"])} for g in grupos[:5]]
637
+ return out
638
+
639
+
640
+ # ----------------------------------------------------------------------------- 6. texto na tela
641
+
642
+ def ler_texto_na_tela(video: Path, ev: Path, medidas: Path, transcricao: Path | None, fps: float) -> dict | None:
643
+ script = ev / "helpers" / "texto_na_tela.py"
644
+ if not script.exists():
645
+ aviso(f"não achei {script}; sigo sem ler o texto da tela.")
646
+ return None
647
+ if transcricao is None:
648
+ # sem transcrição o helper texto_na_tela.py quebra (usa chaves antigas); uma palavra falsa evita isso
649
+ transcricao = medidas / "_sem_fala.json"
650
+ salvar_json(transcricao, {"words": [{"word": "zzzzzz", "start": -100.0, "end": -99.0}]})
651
+ cmd = python_ev(ev) + [str(script), "--video", str(video), "--edit-dir", str(medidas), "--fps", str(fps),
652
+ "--force", "--transcript", str(transcricao)]
653
+ r = subprocess.run(cmd, capture_output=True, cwd=ev)
654
+ arq = medidas / "texto_na_tela.json"
655
+ if r.returncode != 0 or not arq.exists():
656
+ aviso("a leitura do texto da tela falhou: " + r.stderr.decode("utf-8", "replace").strip()[-300:])
657
+ return None
658
+ return json.loads(arq.read_text(encoding="utf-8"))
659
+
660
+
661
+ def _eh_palavra(txt: str) -> bool:
662
+ letras = sum(ch.isalpha() for ch in txt)
663
+ return letras >= 2 and letras >= 0.5 * max(1, len(txt.replace(" ", "")))
664
+
665
+
666
+ def analisar_textos(doc: dict, palavras: list[dict] | None, W: int, H: int) -> dict:
667
+ amostras = doc.get("amostras", [])
668
+ passo = 1.0 / float(doc.get("fps") or 2.0)
669
+ leu_palavras = doc.get("backend") == "vision"
670
+ # texto que fica: o mesmo texto quase no mesmo lugar em amostras vizinhas (liga em cadeia)
671
+ nos: list[tuple[int, int]] = [(i, j) for i, am in enumerate(amostras) for j in range(len(am["itens"]))]
672
+ pai = {n: n for n in nos}
673
+
674
+ def raiz(n):
675
+ while pai[n] != n:
676
+ pai[n] = pai[pai[n]]
677
+ n = pai[n]
678
+ return n
679
+ chaves = {(i, j): norm_txt(amostras[i]["itens"][j].get("texto", "")) for i, j in nos}
680
+ for i, am in enumerate(amostras):
681
+ for j, it in enumerate(am["itens"]):
682
+ k = chaves[(i, j)]
683
+ if len(k) < 2:
684
+ continue
685
+ for di in (1, 2):
686
+ if i + di >= len(amostras):
687
+ break
688
+ for j2, it2 in enumerate(amostras[i + di]["itens"]):
689
+ if chaves[(i + di, j2)] == k and abs(it2["centro_y"] - it["centro_y"]) < 0.02 \
690
+ and abs(it2["centro_x"] - it["centro_x"]) < 0.08:
691
+ pai[raiz((i + di, j2))] = raiz((i, j))
692
+ faixa_comp: dict = defaultdict(list)
693
+ for n in nos:
694
+ faixa_comp[raiz(n)].append(n[0])
695
+ dur_no = {n: (max(faixa_comp[raiz(n)]) - min(faixa_comp[raiz(n)]) + 1) * passo for n in nos}
696
+ falado = []
697
+ if palavras:
698
+ for w in palavras:
699
+ nt = norm_txt(str(w.get("text", w.get("word", ""))))
700
+ if len(nt) >= 2:
701
+ falado.append((float(w.get("start", 0)), nt))
702
+
703
+ def bate_com_fala(t: float, txt: str) -> bool:
704
+ escritas = {p for p in norm_txt(txt).split() if len(p) >= 2}
705
+ if not escritas:
706
+ return False
707
+ ditas = {p for (ts, p) in falado if t - 1.5 <= ts <= t + 1.5}
708
+ return bool(escritas & ditas)
709
+
710
+ itens: list[dict] = []
711
+ for i, am in enumerate(amostras):
712
+ for j, it in enumerate(am["itens"]):
713
+ txt = str(it.get("texto", "")).strip()
714
+ dur_s = dur_no[(i, j)]
715
+ e = dict(it)
716
+ e["t"] = float(am["t"])
717
+ e["amostra"] = i
718
+ e["dur_s"] = round(dur_s, 2)
719
+ e["fala"] = bate_com_fala(e["t"], txt) if falado else None
720
+ # texto fixo: fica parado e não é a fala escrita (legenda lenta também fica 3 a 6 s no lugar)
721
+ if falado:
722
+ fixo = (dur_s >= 3.0 and not e["fala"]) or dur_s >= 10.0
723
+ else:
724
+ fixo = dur_s >= 6.0
725
+ e["fixo"] = bool(leu_palavras and len(chaves[(i, j)]) >= 2 and fixo)
726
+ e["palavra"] = _eh_palavra(txt) if leu_palavras else True
727
+ itens.append(e)
728
+
729
+ # candidatos a legenda: texto que troca, com letras, mais ou menos no meio da largura
730
+ cand = [e for e in itens if not e["fixo"] and e["palavra"] and 0.2 <= e["centro_x"] <= 0.8]
731
+ usou_fala = False
732
+ if falado:
733
+ cand_fala = [e for e in cand if e["fala"]]
734
+ if len(cand_fala) >= 3:
735
+ cand = cand_fala
736
+ usou_fala = True
737
+ if not usou_fala and leu_palavras:
738
+ cand = [e for e in cand if e["altura"] >= 0.015] # sem a fala, letreiro miúdo (HUD, rótulo) não conta
739
+ res: dict = {"motor": doc.get("backend"), "amostras": len(amostras), "fps_leitura": doc.get("fps"),
740
+ "tem_legenda": False, "achada_pela_fala": usou_fala}
741
+ tela_w, tela_h = tela_padrao(W, H)
742
+ res["tela_padrao"] = f"{tela_w}x{tela_h}"
743
+ # grupos por altura na tela (a legenda pode mudar de lugar em alguns trechos)
744
+ grupos_y: list[dict] = []
745
+
746
+ def troca_bem(bl: list[dict]) -> bool:
747
+ """Sem a fala para comparar, legenda é o texto que troca (letreiro, marca e produto repetem)."""
748
+ if usou_fala or not leu_palavras:
749
+ return True
750
+ ams = sorted({e["amostra"] for e in bl})
751
+ if len(ams) < 4:
752
+ return False
753
+ por = {i: " ".join(sorted(norm_txt(e["texto"]) for e in bl if e["amostra"] == i)) for i in ams}
754
+ ts = [e["t"] for e in bl]
755
+ return len(set(por.values())) / len(ams) >= 0.15 and max(ts) - min(ts) >= 4.0
756
+
757
+ if cand:
758
+ # posição principal: a altura com mais amostras numa janela estreita (a legenda não sai do lugar)
759
+ centros_c = sorted({round(e["centro_y"] / 0.005) * 0.005 for e in cand})
760
+ melhor, melhor_n = None, 0
761
+ for yc in centros_c:
762
+ bl = [e for e in cand if abs(e["centro_y"] - yc) <= 0.012]
763
+ n = len({e["amostra"] for e in bl})
764
+ if n > melhor_n and troca_bem(bl):
765
+ melhor, melhor_n = yc, n
766
+ if melhor is not None:
767
+ principal_bl = [e for e in cand if abs(e["centro_y"] - melhor) <= 0.015]
768
+ h_ref = float(np.median([e["altura"] for e in principal_bl]))
769
+ # linhas vizinhas da mesma legenda (legenda de 2 ou 3 linhas) entram no bloco principal
770
+ ids_p = {id(e) for e in principal_bl}
771
+ por_am: dict[int, list[dict]] = defaultdict(list)
772
+ for e in principal_bl:
773
+ por_am[e["amostra"]].append(e)
774
+ vizinhas = [e for e in cand if id(e) not in ids_p and e["amostra"] in por_am
775
+ and any(0.5 * h_ref < abs(e["centro_y"] - q["centro_y"]) <= 2.2 * h_ref
776
+ for q in por_am[e["amostra"]])]
777
+ principal_bl = principal_bl + vizinhas
778
+ usados = {id(e) for e in principal_bl}
779
+ centros = []
780
+ for i_am in por_am:
781
+ ys_am = [e["centro_y"] for e in principal_bl if e["amostra"] == i_am]
782
+ centros.append((min(ys_am) + max(ys_am)) / 2)
783
+ grupos_y.append({"membros": principal_bl, "amostras": sorted({e["amostra"] for e in principal_bl}),
784
+ "centro_y": float(np.median(centros))})
785
+ # outra posição só vale como bloco contínuo no tempo, com letra do mesmo tamanho da principal
786
+ resto = sorted([e for e in cand if id(e) not in usados], key=lambda e: e["centro_y"])
787
+ blocos: list[list[dict]] = []
788
+ for e in resto:
789
+ if blocos and e["centro_y"] - blocos[-1][-1]["centro_y"] <= 0.015:
790
+ blocos[-1].append(e)
791
+ else:
792
+ blocos.append([e])
793
+ for bl in blocos:
794
+ bl = sorted(bl, key=lambda e: e["t"])
795
+ corridas: list[list[dict]] = [[bl[0]]]
796
+ for e in bl[1:]:
797
+ if e["t"] - corridas[-1][-1]["t"] > 1.5:
798
+ corridas.append([e])
799
+ else:
800
+ corridas[-1].append(e)
801
+ for co in corridas:
802
+ am_ids = sorted({e["amostra"] for e in co})
803
+ dur_co = co[-1]["t"] - co[0]["t"] + passo
804
+ h_co = float(np.median([e["altura"] for e in co]))
805
+ c_co = float(np.median([e["centro_y"] for e in co]))
806
+ if abs(c_co - grupos_y[0]["centro_y"]) <= 1.5 * h_ref:
807
+ # colado na principal: é a mesma legenda (linha de cima ou de baixo)
808
+ grupos_y[0]["membros"] = grupos_y[0]["membros"] + co
809
+ grupos_y[0]["amostras"] = sorted(set(grupos_y[0]["amostras"]) | set(am_ids))
810
+ elif len(am_ids) >= 4 and dur_co >= 2.5 and 0.7 * h_ref <= h_co <= 1.4 * h_ref and troca_bem(co):
811
+ grupos_y.append({"membros": co, "amostras": am_ids, "centro_y": c_co})
812
+ legenda = [e for g in grupos_y for e in g["membros"]]
813
+ amostras_com = sorted({e["amostra"] for e in legenda})
814
+ # legenda cobre boa parte da fala; título de gancho que repete a fala por alguns segundos não conta
815
+ if usou_fala:
816
+ ts_f = [ts for ts, _ in falado]
817
+ base_am = [i for i, am in enumerate(amostras) if any(am["t"] - 0.5 <= x <= am["t"] + 0.5 for x in ts_f)]
818
+ cobertura = len(set(amostras_com) & set(base_am)) / max(1, len(base_am))
819
+ minimo_ok = cobertura >= 0.20
820
+ else:
821
+ minimo_ok = len(amostras_com) >= 0.15 * len(amostras)
822
+ if grupos_y and len(amostras_com) >= 3 and minimo_ok:
823
+ res["tem_legenda"] = True
824
+ faixa_c = grupos_y[0]["centro_y"]
825
+ principal = grupos_y[0]["membros"]
826
+ alturas = np.array([e["altura"] for e in principal])
827
+ n_pal = [len(re.findall(r"\S+", e["texto"])) for e in principal if e.get("texto")]
828
+ linhas: dict[int, int] = {}
829
+ for i_am in {e["amostra"] for e in principal}:
830
+ ys_am = sorted(e["centro_y"] for e in principal if e["amostra"] == i_am)
831
+ n_lin = 1
832
+ for y0, y1 in zip(ys_am, ys_am[1:]):
833
+ if y1 - y0 > 0.5 * float(np.median(alturas)):
834
+ n_lin += 1
835
+ linhas[i_am] = n_lin
836
+ letras = "".join(e.get("texto", "") for e in principal)
837
+ alfa = [c for c in letras if c.isalpha()]
838
+ maius = sum(c.isupper() for c in alfa) / len(alfa) if alfa else None
839
+ # largura de cada letra em relação à altura da linha (fonte estreita x larga)
840
+ prop = []
841
+ for e in principal:
842
+ nch = len(e.get("texto", "").replace(" ", ""))
843
+ if nch >= 3 and e["altura"] > 0:
844
+ prop.append((e["largura"] * W / nch) / (e["altura"] * H))
845
+ seq = []
846
+ for i in range(len(amostras)):
847
+ seq.append(" ".join(sorted(norm_txt(e["texto"]) for e in legenda if e["amostra"] == i)))
848
+ trocas = sum(1 for a0, b0 in zip(seq, seq[1:]) if a0 and b0 and a0 != b0)
849
+ minutos_com = len(amostras_com) * passo / 60.0
850
+ com_fala = None
851
+ if falado:
852
+ ts_fala = [ts for ts, _ in falado]
853
+ am_fala = [i for i, am in enumerate(amostras)
854
+ if any(am["t"] - 0.5 <= ts <= am["t"] + 0.5 for ts in ts_fala)]
855
+ if am_fala:
856
+ com_fala = round(sum(1 for i in am_fala if i in set(amostras_com)) / len(am_fala), 3)
857
+ posicoes = []
858
+ for g in grupos_y:
859
+ ts = [amostras[i]["t"] for i in g["amostras"]]
860
+ posicoes.append({"centro_y": round(g["centro_y"], 4), "centro_y_padrao": int(round(g["centro_y"] * tela_h)),
861
+ "amostras": len(g["amostras"]),
862
+ "parte_da_legenda": round(len(g["amostras"]) / max(1, len(amostras_com)), 3),
863
+ "de": round(min(ts), 2), "ate": round(max(ts) + passo, 2)})
864
+ res.update({
865
+ "centro_y": round(faixa_c, 4),
866
+ "centro_y_px": int(round(faixa_c * H)),
867
+ "centro_y_padrao": int(round(faixa_c * tela_h)),
868
+ "posicoes": posicoes,
869
+ "altura_linha_frac": round(float(np.median(alturas)), 4),
870
+ "altura_linha_px": int(round(float(np.median(alturas)) * H)),
871
+ "altura_linha_padrao": int(round(float(np.median(alturas)) * tela_h)),
872
+ "palavras_por_linha_mediana": float(np.median(n_pal)) if n_pal else None,
873
+ "palavras_por_linha_max": int(max(n_pal)) if n_pal else None,
874
+ "palavras_por_linha_hist": dict(sorted(Counter(n_pal).items())) if n_pal else {},
875
+ "linhas_por_vez": float(np.median(list(linhas.values()))) if linhas else None,
876
+ "linhas_por_vez_pct": {str(k): round(v / len(linhas), 3) for k, v in sorted(Counter(linhas.values()).items())}
877
+ if linhas else {},
878
+ "caixa_alta_pct": round(maius, 3) if maius is not None else None,
879
+ "largura_letra_sobre_altura": round(float(np.median(prop)), 3) if prop else None,
880
+ "tempo_com_legenda_pct": round(len(amostras_com) / max(1, len(amostras)), 3),
881
+ "fala_com_legenda_pct": com_fala,
882
+ "trocas_por_minuto_amostradas": round(trocas / minutos_com, 1) if minutos_com > 0 else None,
883
+ "centro_x_mediano": round(float(np.median([e["centro_x"] for e in principal])), 3),
884
+ "casou_com_fala": bool(falado) and all(e["fala"] for e in legenda),
885
+ })
886
+ # textos fixos (títulos, rótulos, @, marca d'água)
887
+ fixos: dict[tuple, dict] = {}
888
+ for e in itens:
889
+ if not e["fixo"]:
890
+ continue
891
+ k = (norm_txt(e["texto"]), round(e["centro_y"] / 0.04))
892
+ f = fixos.setdefault(k, {"texto": e["texto"], "inicio": e["t"], "fim": e["t"], "centro_y": e["centro_y"],
893
+ "altura_frac": e["altura"], "centro_x": e["centro_x"]})
894
+ f["fim"] = max(f["fim"], e["t"])
895
+ f["inicio"] = min(f["inicio"], e["t"])
896
+ lista_fixos = []
897
+ for f in sorted(fixos.values(), key=lambda f: f["inicio"]):
898
+ t = f["texto"]
899
+ marca = bool(re.search(r"@|www\.|\.com|\.br|https?:", t, re.I))
900
+ lista_fixos.append({**f, "fim": round(f["fim"] + passo, 2), "duracao_s": round(f["fim"] + passo - f["inicio"], 2),
901
+ "altura_px": int(round(f["altura_frac"] * H)),
902
+ "palavras": len(t.split()), "parece_marca_ou_perfil": marca,
903
+ "zona": "topo" if f["centro_y"] < 0.3 else ("baixo" if f["centro_y"] > 0.7 else "meio")})
904
+ res["textos_fixos"] = lista_fixos
905
+ res["com_texto_pct"] = round(sum(1 for am in amostras if am["itens"]) / max(1, len(amostras)), 3)
906
+ res["tela_clara_trechos"] = doc.get("tela_cheia", [])
907
+ # texto no gancho (primeiros 3 s)
908
+ gancho = [e for e in itens if e["t"] < 3.0 and e["palavra"]]
909
+ grandes = [e for e in gancho if res.get("altura_linha_frac") and e["altura"] > 1.3 * res["altura_linha_frac"]]
910
+ res["gancho"] = {
911
+ "tem_texto_nos_3s": bool(gancho),
912
+ "texto_maior_que_legenda": bool(grandes),
913
+ "altura_max_px": int(round(max((e["altura"] for e in gancho), default=0) * H)),
914
+ "zona": (lambda y: "topo" if y < 0.3 else ("baixo" if y > 0.7 else "meio"))(
915
+ float(np.median([e["centro_y"] for e in (grandes or gancho)]))) if gancho else None,
916
+ }
917
+ res["_itens"] = itens
918
+ res["_legenda"] = legenda
919
+ res["_principal"] = grupos_y[0]["membros"] if res.get("tem_legenda") else []
920
+ return res
921
+
922
+
923
+ # ----------------------------------------------------------------------------- 7. áudio
924
+
925
+ def decodificar_audio(video: Path) -> np.ndarray | None:
926
+ r = subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-i", str(video), "-vn", "-ac", "2", "-ar", str(SR),
927
+ "-f", "f32le", "-"], capture_output=True)
928
+ x = np.frombuffer(r.stdout, np.float32)
929
+ if x.size < SR:
930
+ return None
931
+ return x[: x.size // 2 * 2].reshape(-1, 2)
932
+
933
+
934
+ def medir_loudness(video: Path) -> dict:
935
+ r = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-i", str(video), "-vn", "-af",
936
+ "loudnorm=print_format=json", "-f", "null", "-"], capture_output=True)
937
+ txt = r.stderr.decode("utf-8", "replace")
938
+ m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", txt, re.S)
939
+ if not m:
940
+ return {}
941
+ d = json.loads(m.group(0))
942
+
943
+ def f(v):
944
+ try:
945
+ x = float(v)
946
+ return round(x, 1) if math.isfinite(x) else None
947
+ except (TypeError, ValueError):
948
+ return None
949
+ return {"integrado_lufs": f(d.get("input_i")), "pico_real_dbtp": f(d.get("input_tp")),
950
+ "variacao_lu": f(d.get("input_lra"))}
951
+
952
+
953
+ def espectro(sinal: np.ndarray, acumular: np.ndarray | None = None) -> dict:
954
+ n = 1 + max(0, (len(sinal) - JANELA) // PASSO)
955
+ jan = np.hanning(JANELA).astype(np.float32)
956
+ freqs = np.fft.rfftfreq(JANELA, 1.0 / SR)
957
+ sel = {b: (freqs >= a) & (freqs < z) for b, (a, z) in BANDAS.items()}
958
+ E = {b: np.empty(n, np.float32) for b in BANDAS}
959
+ rms = np.empty(n, np.float32)
960
+ fluxo = np.zeros(n, np.float32)
961
+ soma = np.zeros(len(freqs), np.float64)
962
+ n_soma = 0
963
+ ant = None
964
+ for c0 in range(0, n, 2048):
965
+ c1 = min(n, c0 + 2048)
966
+ idx = np.arange(c0, c1)[:, None] * PASSO + np.arange(JANELA)[None, :]
967
+ fr = sinal[idx]
968
+ rms[c0:c1] = np.sqrt(np.mean(fr ** 2, axis=1))
969
+ mag = np.abs(np.fft.rfft(fr * jan, axis=1)).astype(np.float32)
970
+ p = mag ** 2
971
+ for b, s in sel.items():
972
+ E[b][c0:c1] = 10 * np.log10(p[:, s].sum(1) + 1e-10)
973
+ lm = np.log1p(mag)
974
+ prev = np.vstack([(ant if ant is not None else lm[0])[None, :], lm[:-1]])
975
+ fluxo[c0:c1] = np.maximum(0.0, lm - prev).sum(1)
976
+ ant = lm[-1]
977
+ if acumular is not None:
978
+ m = acumular[c0:c1]
979
+ if m.any():
980
+ soma += p[m].sum(0)
981
+ n_soma += int(m.sum())
982
+ return {"n": n, "t": (np.arange(n) * PASSO + JANELA / 2) / SR, "db": 20 * np.log10(rms + 1e-9),
983
+ "bandas": E, "fluxo": fluxo, "freqs": freqs, "espectro_medio": soma / n_soma if n_soma else None}
984
+
985
+
986
+ def mediana_movel(x: np.ndarray, janela: int) -> np.ndarray:
987
+ dec = 4
988
+ xs = x[::dec]
989
+ k = max(1, janela // (2 * dec))
990
+ xp = np.pad(xs, k, mode="edge")
991
+ v = np.lib.stride_tricks.sliding_window_view(xp, 2 * k + 1)
992
+ med = np.median(v, axis=1)
993
+ return np.interp(np.arange(len(x)), np.arange(len(xs)) * dec, med).astype(np.float32)
994
+
995
+
996
+ def picos(x: np.ndarray, limiar: float, dist: int) -> list[int]:
997
+ if len(x) < 3:
998
+ return []
999
+ viz = np.maximum(np.roll(x, 1), np.roll(x, -1))
1000
+ cand = np.where((x >= limiar) & (x >= viz))[0]
1001
+ cand = cand[np.argsort(-x[cand])]
1002
+ livre = np.ones(len(x), bool)
1003
+ escolhidos = []
1004
+ for i in cand:
1005
+ if livre[i]:
1006
+ escolhidos.append(int(i))
1007
+ livre[max(0, i - dist):i + dist + 1] = False
1008
+ return sorted(escolhidos)
1009
+
1010
+
1011
+ def _mascara_fala(tempos: np.ndarray, palavras: list[dict], folga: float = 0.05) -> np.ndarray:
1012
+ m = np.zeros(len(tempos), bool)
1013
+ for w in palavras:
1014
+ a, z = float(w.get("start", 0)) - folga, float(w.get("end", w.get("start", 0))) + folga
1015
+ i0, i1 = np.searchsorted(tempos, [a, z])
1016
+ m[i0:i1] = True
1017
+ return m
1018
+
1019
+
1020
+ def _pausas(palavras: list[dict], dur: float, minimo: float = 0.3) -> list[tuple[float, float]]:
1021
+ ps = sorted(palavras, key=lambda w: float(w.get("start", 0)))
1022
+ out = []
1023
+ for a, b in zip(ps, ps[1:]):
1024
+ g0, g1 = float(a.get("end", a.get("start", 0))), float(b.get("start", 0))
1025
+ if g1 - g0 >= minimo:
1026
+ out.append((g0, g1))
1027
+ return out
1028
+
1029
+
1030
+ def _bpm(env: np.ndarray, fr: float) -> tuple[float | None, float]:
1031
+ if len(env) < fr * 8:
1032
+ return None, 0.0
1033
+ e = (env - env.mean()).astype(np.float64)
1034
+ nfft = 1 << int(math.ceil(math.log2(2 * len(e))))
1035
+ ac = np.fft.irfft(np.abs(np.fft.rfft(e, nfft)) ** 2, nfft)[:len(e)]
1036
+ if ac[0] <= 0:
1037
+ return None, 0.0
1038
+ lo, hi = int(fr * 60 / 180), int(fr * 60 / 70)
1039
+ lag = lo + int(np.argmax(ac[lo:hi]))
1040
+ return round(60.0 * fr / lag, 1), round(float(ac[lag] / ac[0]), 2)
1041
+
1042
+
1043
+ def picos_tonais(esp: np.ndarray | None, freqs: np.ndarray) -> int:
1044
+ if esp is None:
1045
+ return 0
1046
+ sel = (freqs >= 80) & (freqs <= 2500)
1047
+ db = 10 * np.log10(esp[sel] + 1e-12)
1048
+ base = np.array([np.median(db[max(0, i - 12):i + 13]) for i in range(len(db))])
1049
+ exc = db - base
1050
+ return len(picos(exc, 8.0, 3))
1051
+
1052
+
1053
+ def rms_quadros(sinal: np.ndarray) -> np.ndarray:
1054
+ """Nível RMS (dBFS) em janelas de JANELA amostras a cada PASSO, sem FFT."""
1055
+ n = 1 + max(0, (len(sinal) - JANELA) // PASSO)
1056
+ cs = np.concatenate([[0.0], np.cumsum(sinal.astype(np.float64) ** 2)])
1057
+ i0 = np.arange(n) * PASSO
1058
+ energia = (cs[i0 + JANELA] - cs[i0]) / JANELA
1059
+ return (10 * np.log10(np.maximum(energia, 0) + 1e-18)).astype(np.float32)
1060
+
1061
+
1062
+ def analisar_audio(video: Path, dur: float, palavras: list[dict] | None, cortes_t: list[float]) -> dict:
1063
+ x = decodificar_audio(video)
1064
+ if x is None:
1065
+ return {"tem_audio": False}
1066
+ res: dict = {"tem_audio": True}
1067
+ res["loudness"] = medir_loudness(video)
1068
+ L, R = x[:, 0].astype(np.float32), x[:, 1].astype(np.float32)
1069
+ M = (L + R) / 2
1070
+ S = (L - R) / 2
1071
+ rms_m = float(np.sqrt(np.mean(M ** 2)) + 1e-12)
1072
+ rms_s = float(np.sqrt(np.mean(S ** 2)) + 1e-12)
1073
+ lc, rc = L - L.mean(), R - R.mean()
1074
+ den = math.sqrt(float(np.dot(lc, lc)) * float(np.dot(rc, rc)))
1075
+ corr = float(np.dot(lc, rc)) / den if den > 0 else 1.0
1076
+ del lc, rc, x
1077
+ estereo = 20 * math.log10(rms_s / rms_m) > -30 and corr < 0.995
1078
+ res["correlacao_esq_dir"] = round(corr, 4)
1079
+ res["lado_menos_meio_db"] = round(20 * math.log10(rms_s / rms_m), 1)
1080
+ res["estereo"] = bool(estereo)
1081
+ fr = SR / PASSO
1082
+
1083
+ mdb = rms_quadros(M)
1084
+ n_fr = len(mdb)
1085
+ t = (np.arange(n_fr) * PASSO + JANELA / 2) / SR
1086
+ fala = _mascara_fala(t, palavras) if palavras else None
1087
+ pausa = None
1088
+ if palavras:
1089
+ pausa = np.zeros(n_fr, bool)
1090
+ for g0, g1 in _pausas(palavras, dur, 0.3):
1091
+ i0, i1 = np.searchsorted(t, [g0 + 0.12, g1 - 0.12])
1092
+ pausa[i0:i1] = True
1093
+ if pausa.sum() < 0.5 * fr:
1094
+ pausa = None
1095
+ if fala is not None and fala.any():
1096
+ voz_db = float(np.median(mdb[fala]))
1097
+ metodo = "voz pelas palavras da transcrição"
1098
+ else:
1099
+ voz_db = float(np.percentile(mdb, 80))
1100
+ metodo = "voz pelos trechos mais fortes do áudio"
1101
+ piso_confiavel = pausa is not None
1102
+ if pausa is not None:
1103
+ piso_db = float(np.median(mdb[pausa]))
1104
+ quieto = pausa
1105
+ metodo += ", fundo pelas pausas da fala"
1106
+ else:
1107
+ metodo += (", fundo pelos trechos mais baixos (a fala quase não para)" if palavras
1108
+ else ", fundo pelos trechos mais baixos")
1109
+ ativo = mdb > voz_db - 40
1110
+ piso_db = float(np.percentile(mdb[ativo], 10)) if ativo.any() else float(np.percentile(mdb, 10))
1111
+ quieto = ativo & (mdb <= np.percentile(mdb[ativo], 15)) if ativo.any() else None
1112
+ em = espectro(M, acumular=quieto)
1113
+ dif = voz_db - piso_db
1114
+ n_tons = picos_tonais(em["espectro_medio"], em["freqs"])
1115
+ trilha: dict = {"voz_dbfs": round(voz_db, 1), "piso_entre_frases_dbfs": round(piso_db, 1),
1116
+ "diferenca_db": round(dif, 1), "picos_tonais_no_piso": n_tons, "metodo": metodo}
1117
+
1118
+ # canal lateral (esquerdo menos direito): a voz centralizada some ali, a trilha estéreo fica
1119
+ sdb = None
1120
+ es = None
1121
+ voz_centralizada = False
1122
+ if estereo:
1123
+ sdb = rms_quadros(S)
1124
+ if fala is not None and fala.any():
1125
+ voz_centralizada = float(np.median(mdb[fala]) - np.median(sdb[fala])) >= 15.0
1126
+ else:
1127
+ voz_centralizada = res["lado_menos_meio_db"] <= -15.0
1128
+ trilha["lado_mediano_dbfs"] = round(float(np.median(sdb)), 1)
1129
+ if fala is not None and pausa is not None:
1130
+ s_fala = float(np.median(sdb[fala]))
1131
+ s_pausa = float(np.median(sdb[pausa]))
1132
+ trilha["lado_na_fala_dbfs"] = round(s_fala, 1)
1133
+ trilha["lado_nas_pausas_dbfs"] = round(s_pausa, 1)
1134
+ if voz_centralizada:
1135
+ trilha["abaixa_na_fala"] = bool(s_pausa - s_fala > 3.0)
1136
+ es = espectro(S)
1137
+ env = np.maximum(es["fluxo"] - mediana_movel(es["fluxo"], int(fr) | 1), 0)
1138
+ bpm, forca = _bpm(env, fr)
1139
+ trilha["bpm"] = bpm if forca >= 0.25 else None
1140
+ trilha["bpm_forca"] = forca
1141
+ seg = []
1142
+ for s in range(int(dur)):
1143
+ sel = (t >= s) & (t < s + 1)
1144
+ seg.append(float(np.median(sdb[sel])) if sel.any() else -120.0)
1145
+ seg = np.array(seg)
1146
+ if len(seg) >= 6 and voz_centralizada:
1147
+ ref = float(np.median(seg[:-3]))
1148
+ fim_trilha = None
1149
+ for s in range(len(seg) - 1, -1, -1):
1150
+ if seg[s] > ref - 10:
1151
+ fim_trilha = s + 1
1152
+ break
1153
+ if fim_trilha is not None and dur - fim_trilha >= 1.0:
1154
+ trilha["some_antes_do_fim_s"] = round(dur - fim_trilha, 1)
1155
+ trilha["voz_centralizada"] = voz_centralizada
1156
+ lado_continuo = bool(voz_centralizada and sdb is not None and
1157
+ trilha.get("lado_nas_pausas_dbfs", trilha.get("lado_mediano_dbfs", -99)) > -58)
1158
+ if dif >= 35 or piso_db < -60:
1159
+ trilha["veredito"] = "sem trilha"
1160
+ trilha["explicacao"] = "entre as frases fica quase silêncio"
1161
+ elif dif < 8:
1162
+ trilha["veredito"] = "som contínuo alto"
1163
+ trilha["explicacao"] = "o som de fundo fica quase no nível da voz (música alta ou pouca fala)"
1164
+ elif n_tons >= 3 or lado_continuo:
1165
+ trilha["veredito"] = "trilha provável"
1166
+ trilha["explicacao"] = f"tem som musical contínuo uns {br(dif, 0)} dB abaixo da voz"
1167
+ elif not piso_confiavel:
1168
+ trilha["veredito"] = "incerto"
1169
+ trilha["explicacao"] = ("quase não há pausa na fala, então não deu para medir o som de fundo com segurança; "
1170
+ "ouça a referência ou confie no que a pessoa disser")
1171
+ else:
1172
+ trilha["veredito"] = "ruído de fundo provável"
1173
+ trilha["explicacao"] = f"tem som contínuo uns {br(dif, 0)} dB abaixo da voz, mas não parece música"
1174
+ trilha["piso_confiavel"] = piso_confiavel
1175
+ res["trilha"] = trilha
1176
+
1177
+ # ataques de som (inclui sílabas)
1178
+ flx = em["fluxo"]
1179
+ base = mediana_movel(flx, int(fr) | 1)
1180
+ mad = mediana_movel(np.abs(flx - base), int(fr) | 1) + 1e-6
1181
+ at = picos((flx - base) / mad, 4.0, int(0.1 * fr))
1182
+ res["ataques_por_minuto"] = round(len(at) / (dur / 60.0), 1) if dur else None
1183
+
1184
+ # efeitos sonoros prováveis
1185
+ # calibrado em 2 reels com efeitos marcados à mão: o nível do canal lateral subindo 6 dB acima da
1186
+ # mediana dos 4 s em volta acertou 32 de 44 efeitos, com poucos alarmes falsos
1187
+ eventos: list[dict] = []
1188
+ if sdb is not None and voz_centralizada:
1189
+ exc = sdb - mediana_movel(sdb, int(4 * fr) | 1)
1190
+ for i in picos(exc, 6.0, int(0.3 * fr)):
1191
+ eventos.append({"t": float(t[i]), "forca_db": round(float(exc[i]), 1), "fonte": "lado"})
1192
+ else:
1193
+ sub = em["bandas"]["sub"]
1194
+ exs = sub - mediana_movel(sub, int(1.5 * fr) | 1)
1195
+ voz_b = em["bandas"]["voz"]
1196
+ exv = voz_b - mediana_movel(voz_b, int(1.5 * fr) | 1)
1197
+ sc = exs - np.maximum(exv, 0)
1198
+ p95 = float(np.percentile(sub, 95))
1199
+ for i in picos(sc, 18.0, int(0.3 * fr)):
1200
+ if mdb[i] > voz_db - 25 and sub[i] >= p95:
1201
+ eventos.append({"t": float(t[i]), "forca_db": round(float(sc[i]), 1), "fonte": "grave"})
1202
+ if pausa is not None:
1203
+ exc_p = mdb - (piso_db + 10)
1204
+ exc_p[~pausa] = -99
1205
+ for i in picos(exc_p, 0.0, int(0.25 * fr)):
1206
+ eventos.append({"t": float(t[i]), "forca_db": round(float(mdb[i] - piso_db), 1), "fonte": "pausa"})
1207
+ eventos.sort(key=lambda e: e["t"])
1208
+ juntos: list[dict] = []
1209
+ for e in eventos:
1210
+ if juntos and e["t"] - juntos[-1]["t"] < 0.25:
1211
+ if e["forca_db"] > juntos[-1]["forca_db"]:
1212
+ e["fonte"] = juntos[-1]["fonte"] + "+" + e["fonte"]
1213
+ juntos[-1] = e
1214
+ else:
1215
+ juntos[-1]["fonte"] += "+" + e["fonte"]
1216
+ else:
1217
+ juntos.append(e)
1218
+ fonte_banda = es["bandas"] if (es is not None and voz_centralizada) else em["bandas"]
1219
+ for e in juntos:
1220
+ i = min(max(int(np.searchsorted(t, e["t"])), 0), len(t) - 1)
1221
+ rel = {b: float(fonte_banda[b][i] - np.median(fonte_banda[b][max(0, i - int(fr)):i + int(fr)]))
1222
+ for b in BANDAS}
1223
+ baixo = max(rel["sub"], rel["grave"])
1224
+ if baixo >= max(rel["agudo"], rel["corpo"]) and baixo > 6:
1225
+ e["tipo"] = "grave (impacto, batida)"
1226
+ elif rel["agudo"] > rel["corpo"] + 3:
1227
+ e["tipo"] = "agudo (estalo, brilho, clique)"
1228
+ else:
1229
+ e["tipo"] = "cheio (whoosh, transição, pop)"
1230
+ e["t"] = round(e["t"], 2)
1231
+ e["perto_de_corte"] = any(abs(e["t"] - c) <= 0.2 for c in cortes_t)
1232
+ res["efeitos"] = {
1233
+ "provaveis": len(juntos),
1234
+ "por_minuto": round(len(juntos) / (dur / 60.0), 1) if dur else None,
1235
+ "perto_de_corte_pct": round(sum(e["perto_de_corte"] for e in juntos) / len(juntos), 3) if juntos else None,
1236
+ "cortes_com_som_pct": round(sum(1 for c in cortes_t if any(abs(e["t"] - c) <= 0.2 for e in juntos))
1237
+ / len(cortes_t), 3) if cortes_t else None,
1238
+ "lista": juntos,
1239
+ "confianca": "média" if voz_centralizada else "baixa",
1240
+ "metodo": ("picos no canal lateral (esquerdo menos direito, onde a voz centralizada some), "
1241
+ "e sons dentro das pausas da fala" if voz_centralizada else
1242
+ "saltos de grave que a voz não explica e sons dentro das pausas da fala "
1243
+ "(sem canal lateral limpo: menos confiável)"),
1244
+ }
1245
+ por_seg = []
1246
+ for s in range(int(math.ceil(dur))):
1247
+ sel = (t >= s) & (t < s + 1)
1248
+ if not sel.any():
1249
+ por_seg.append(None)
1250
+ continue
1251
+ item = {"db": round(float(np.percentile(mdb[sel], 90)), 1)}
1252
+ if sdb is not None:
1253
+ item["lado_db"] = round(float(np.median(sdb[sel])), 1)
1254
+ por_seg.append(item)
1255
+ res["_por_segundo"] = por_seg
1256
+ return res
1257
+
1258
+
1259
+ # ----------------------------------------------------------------------------- 8. fala
1260
+
1261
+ def transcrever(video: Path, ev: Path, medidas: Path) -> Path | None:
1262
+ script = ev / "helpers" / "transcribe.py"
1263
+ if not script.exists():
1264
+ aviso(f"não achei {script}; sigo sem transcrição.")
1265
+ return None
1266
+ cmd = python_ev(ev) + [str(script), str(video), "--edit-dir", str(medidas), "--language", "auto", "--no-prompt"]
1267
+ r = subprocess.run(cmd, capture_output=True, cwd=ev)
1268
+ arq = medidas / "transcripts" / f"{video.stem}.json"
1269
+ if r.returncode != 0 or not arq.exists():
1270
+ aviso("a transcrição falhou; sigo sem ela. " + r.stderr.decode("utf-8", "replace").strip()[-300:])
1271
+ return None
1272
+ return arq
1273
+
1274
+
1275
+ def ler_palavras(arq: Path | None) -> list[dict] | None:
1276
+ if not arq or not arq.exists():
1277
+ return None
1278
+ d = json.loads(arq.read_text(encoding="utf-8"))
1279
+ ws = d.get("words") if isinstance(d, dict) else d
1280
+ out = []
1281
+ for w in ws or []:
1282
+ txt = str(w.get("text", w.get("word", ""))).strip()
1283
+ if txt and w.get("start") is not None:
1284
+ out.append({"text": txt, "start": float(w["start"]), "end": float(w.get("end", w["start"]))})
1285
+ return sorted(out, key=lambda w: w["start"]) or None
1286
+
1287
+
1288
+ def medir_fala(palavras: list[dict] | None, dur: float, idioma: str | None) -> dict | None:
1289
+ if not palavras:
1290
+ return None
1291
+ ini, fim = palavras[0]["start"], palavras[-1]["end"]
1292
+ span = max(0.1, fim - ini)
1293
+ pausas = _pausas(palavras, dur, 0.3)
1294
+ return {
1295
+ "idioma": idioma,
1296
+ "palavras": len(palavras),
1297
+ "palavras_por_minuto": round(len(palavras) / (span / 60.0), 0),
1298
+ "primeira_palavra_s": round(ini, 2),
1299
+ "ultima_palavra_s": round(fim, 2),
1300
+ "silencio_no_fim_s": round(max(0.0, dur - fim), 2),
1301
+ "pausas_acima_0_3s": len(pausas),
1302
+ "pausas_por_minuto": round(len(pausas) / (span / 60.0), 1),
1303
+ "maior_pausa_s": round(max((b - a for a, b in pausas), default=0.0), 2),
1304
+ "fala_ocupa_pct": round(sum(w["end"] - w["start"] for w in palavras) / dur, 3) if dur else None,
1305
+ }
1306
+
1307
+
1308
+ # ----------------------------------------------------------------------------- 9. imagens de conferência
1309
+
1310
+ def _rotulo(d: ImageDraw.ImageDraw, xy, texto: str, fonte, cor=(255, 230, 0), fundo=(0, 0, 0)):
1311
+ x, y = xy
1312
+ caixa = d.textbbox((x + 4, y + 2), texto, font=fonte)
1313
+ d.rectangle([x, y, caixa[2] + 4, caixa[3] + 3], fill=fundo)
1314
+ d.text((x + 4, y + 2), texto, fill=cor, font=fonte)
1315
+
1316
+
1317
+ def montar_folhas(quadros: list[tuple[float, Path]], cortes: list[dict], sons: list[dict], pasta: Path,
1318
+ W: int, H: int, fonte, fonte_p) -> list[Path]:
1319
+ pasta.mkdir(parents=True, exist_ok=True)
1320
+ vertical = H >= W
1321
+ tw = 160 if vertical else 256
1322
+ th = int(round(tw * H / W))
1323
+ cols, lins = (10, 6) if vertical else (8, 8)
1324
+ por = cols * lins
1325
+ saidas = []
1326
+ total = (len(quadros) + por - 1) // por
1327
+ for f in range(total):
1328
+ parte = quadros[f * por:(f + 1) * por]
1329
+ topo = 34
1330
+ folha = Image.new("RGB", (cols * tw, topo + th * ((len(parte) + cols - 1) // cols)), (18, 18, 18))
1331
+ d = ImageDraw.Draw(folha)
1332
+ d.text((8, 7), f"folha {f + 1} de {total} {mmss_curto(parte[0][0])} a {mmss_curto(parte[-1][0])} "
1333
+ f"borda vermelha = corte, laranja = corte provável, SOM = efeito sonoro provável",
1334
+ fill=(230, 230, 230), font=fonte_p)
1335
+ for j, (t, arq) in enumerate(parte):
1336
+ k = f * por + j
1337
+ t_ant = quadros[k - 1][0] if k > 0 else -1.0
1338
+ im = Image.open(arq).convert("RGB").resize((tw, th))
1339
+ dd = ImageDraw.Draw(im)
1340
+ dentro = [c for c in cortes if t_ant < c["t"] <= t + 1e-6]
1341
+ if dentro:
1342
+ forte = any(c["tipo"] == "corte" for c in dentro)
1343
+ cor = (235, 40, 40) if forte else (255, 150, 0)
1344
+ dd.rectangle([0, 0, tw - 1, th - 1], outline=cor, width=5)
1345
+ _rotulo(dd, (3, th - 24), "CORTE " + ",".join(mmss(c["t"]) if c["t"] >= 60 else mmss(c["t"])[3:]
1346
+ for c in dentro[:2]), fonte_p,
1347
+ cor=(255, 255, 255), fundo=cor)
1348
+ if any(t_ant < s["t"] <= t + 1e-6 for s in sons):
1349
+ _rotulo(dd, (tw - 46, 3), "SOM", fonte_p, cor=(255, 255, 255), fundo=(30, 90, 220))
1350
+ _rotulo(dd, (3, 3), mmss_curto(t) if abs(t - round(t)) < 0.05 else mmss(t)[:-1], fonte)
1351
+ folha.paste(im, ((j % cols) * tw, topo + (j // cols) * th))
1352
+ destino = pasta / f"folha_{f + 1:02d}.jpg"
1353
+ folha.save(destino, quality=88)
1354
+ saidas.append(destino)
1355
+ return saidas
1356
+
1357
+
1358
+ def montar_conferencia(cortes: list[dict], pares: dict[int, Path], saida_base: Path, W: int, H: int,
1359
+ fonte_p) -> list[Path]:
1360
+ if not cortes:
1361
+ return []
1362
+ total_cortes = len(cortes)
1363
+
1364
+ def espalhados(lista: list[dict], n: int) -> list[dict]:
1365
+ if len(lista) <= n:
1366
+ return lista
1367
+ passo = len(lista) / float(n)
1368
+ return [lista[int(i * passo)] for i in range(n)]
1369
+ if total_cortes > 120: # vídeo longo: mostra uma amostra, com prioridade para os prováveis
1370
+ prov = [c for c in cortes if c["tipo"] != "corte"]
1371
+ fortes = [c for c in cortes if c["tipo"] == "corte"]
1372
+ escolha = espalhados(prov, 90)
1373
+ escolha += espalhados(fortes, 120 - len(escolha))
1374
+ cortes = sorted(escolha, key=lambda c: c["t"])
1375
+ vertical = H >= W
1376
+ tw = 140 if vertical else 220
1377
+ th = int(round(tw * H / W))
1378
+ cols = 5 if vertical else 3
1379
+ por = cols * 6
1380
+ saidas = []
1381
+ total = (len(cortes) + por - 1) // por
1382
+ for f in range(total):
1383
+ parte = cortes[f * por:(f + 1) * por]
1384
+ cel_w, cel_h = 2 * tw + 12, th + 26
1385
+ folha = Image.new("RGB", (cols * cel_w, 30 + cel_h * ((len(parte) + cols - 1) // cols)), (18, 18, 18))
1386
+ d = ImageDraw.Draw(folha)
1387
+ cab = ("cada par: último quadro antes do corte | primeiro quadro depois. Confira se é corte de verdade."
1388
+ + (f" Amostra de {len(cortes)} de {total_cortes} cortes." if total_cortes > len(cortes) else ""))
1389
+ d.text((8, 7), cab, fill=(230, 230, 230), font=fonte_p)
1390
+ for j, c in enumerate(parte):
1391
+ x = (j % cols) * cel_w
1392
+ y = 30 + (j // cols) * cel_h
1393
+ for lado, idx in enumerate((c["quadro"] - 1, c["quadro"])):
1394
+ arq = pares.get(idx)
1395
+ if arq and arq.exists():
1396
+ folha.paste(Image.open(arq).convert("RGB").resize((tw, th)), (x + lado * (tw + 4), y))
1397
+ cor = (235, 60, 60) if c["tipo"] == "corte" else (255, 160, 0)
1398
+ nome = "corte" if c["tipo"] == "corte" else "provável"
1399
+ d.text((x + 2, y + th + 4), f"{mmss(c['t'])} {nome} nota {br(c['nota'], 2)}", fill=cor, font=fonte_p)
1400
+ destino = saida_base.parent / (saida_base.stem + (f"_{f + 1:02d}" if total > 1 else "") + ".jpg")
1401
+ folha.save(destino, quality=88)
1402
+ saidas.append(destino)
1403
+ return saidas
1404
+
1405
+
1406
+ def salvar_recortes(textos: dict | None, quadros: list[tuple[float, Path]], pasta: Path) -> list[dict]:
1407
+ """Amostras da legenda e dos textos fixos, cortadas dos quadros de 1 por segundo."""
1408
+ if not textos or cv2 is None:
1409
+ return []
1410
+ pasta.mkdir(parents=True, exist_ok=True)
1411
+ por_t = {round(t, 1): arq for t, arq in quadros}
1412
+
1413
+ def quadro_perto(t: float) -> Path | None:
1414
+ for d in (0.0, 0.1, -0.1):
1415
+ arq = por_t.get(round(t + d, 1))
1416
+ if arq:
1417
+ return arq
1418
+ return None
1419
+
1420
+ feitos = []
1421
+
1422
+ def espalhar(lista: list[dict], n: int) -> list[dict]:
1423
+ vistos, unicos = set(), []
1424
+ for e in lista:
1425
+ k = norm_txt(e["texto"])
1426
+ if k and k not in vistos and quadro_perto(e["t"]):
1427
+ vistos.add(k)
1428
+ unicos.append(e)
1429
+ if len(unicos) > n:
1430
+ passo = len(unicos) / float(n)
1431
+ unicos = [unicos[int(i * passo)] for i in range(n)]
1432
+ return unicos
1433
+ ids_principal = {id(e) for e in textos.get("_principal", [])}
1434
+ escolha = espalhar(textos.get("_principal", []), 4)
1435
+ escolha += espalhar([e for e in textos.get("_legenda", []) if id(e) not in ids_principal], 2)
1436
+ escolha.sort(key=lambda e: e["t"])
1437
+ for n, e in enumerate(escolha, 1):
1438
+ arq = quadro_perto(e["t"])
1439
+ img = cv2.imread(str(arq))
1440
+ h, w = img.shape[:2]
1441
+ cy, ch = e["centro_y"] * h, e["altura"] * h
1442
+ y0, y1 = int(max(0, cy - 1.6 * ch)), int(min(h, cy + 1.6 * ch))
1443
+ destino = pasta / f"legenda_{n:02d}_{mmss_curto(e['t']).replace(':', 'm')}s.jpg"
1444
+ cv2.imwrite(str(destino), img[y0:y1, :])
1445
+ feitos.append({"arquivo": destino.name, "t": e["t"], "tipo": "legenda"})
1446
+ for n, f in enumerate([f for f in textos.get("textos_fixos", []) if f["duracao_s"] >= 1.0][:4], 1):
1447
+ arq = quadro_perto(f["inicio"] + 0.5) or quadro_perto(f["inicio"])
1448
+ if not arq:
1449
+ continue
1450
+ img = cv2.imread(str(arq))
1451
+ h, w = img.shape[:2]
1452
+ cy, ch = f["centro_y"] * h, f["altura_frac"] * h
1453
+ y0, y1 = int(max(0, cy - 2.0 * ch)), int(min(h, cy + 2.0 * ch))
1454
+ destino = pasta / f"texto_fixo_{n:02d}_{mmss_curto(f['inicio']).replace(':', 'm')}s.jpg"
1455
+ cv2.imwrite(str(destino), img[y0:y1, :])
1456
+ feitos.append({"arquivo": destino.name, "t": f["inicio"], "tipo": "texto fixo"})
1457
+ return feitos
1458
+
1459
+
1460
+ # ----------------------------------------------------------------------------- 10. regras da referência e guia
1461
+
1462
+ ESTILOS_EV = {
1463
+ "impacto": "letra alta e estreita, caixa alta, contorno preto grosso",
1464
+ "caixa_viva": "caixa colorida que corre de palavra em palavra",
1465
+ "palavra_gigante": "uma palavra por vez, enorme",
1466
+ "pulso": "letra redonda e grossa, caixa alta, palavra falada cresce",
1467
+ "fita": "frase numa fita escura atrás do texto",
1468
+ "legenda_limpa": "legenda de TV, frase inteira em até 2 linhas com fundo escuro",
1469
+ "papelaria": "minúsculas pesadas com contorno escuro, palavra falada acende",
1470
+ }
1471
+
1472
+
1473
+ def sugerir_estilo_legenda(leg: dict) -> tuple[str, str]:
1474
+ if not leg.get("tem_legenda"):
1475
+ return "impacto", "a referência não tem legenda medida; ficou o padrão do EDIT IA PRO"
1476
+ ppl = leg.get("palavras_por_linha_mediana") or 2
1477
+ alt = (leg.get("altura_linha_frac") or 0.03) * 1920
1478
+ maius = leg.get("caixa_alta_pct")
1479
+ caixa = leg.get("caixa_atras_pct", 0) >= 0.5
1480
+ estreita = (leg.get("largura_letra_sobre_altura") or 0.6) < 0.5
1481
+ if ppl <= 1.0 and alt >= 90:
1482
+ return "palavra_gigante", "uma palavra por vez e letra grande"
1483
+ if caixa and (ppl >= 5 or (leg.get("linhas_por_vez") or 1) >= 2):
1484
+ return "legenda_limpa", "frase comprida com fundo escuro atrás"
1485
+ if caixa:
1486
+ return "fita", "texto com fundo liso atrás"
1487
+ if maius is not None and maius >= 0.7:
1488
+ if estreita or leg.get("contorno_escuro_pct", 0) >= 0.5:
1489
+ return "impacto", "caixa alta com letra estreita ou contorno escuro"
1490
+ return "pulso", "caixa alta com letra larga e sem contorno"
1491
+ if maius is not None and maius <= 0.35:
1492
+ return "papelaria", "letra minúscula pesada"
1493
+ return "impacto", "mistura de maiúsculas e minúsculas; confira nos recortes"
1494
+
1495
+
1496
+ def fontes_parecidas(leg: dict) -> dict:
1497
+ prop = leg.get("largura_letra_sobre_altura")
1498
+ maius = leg.get("caixa_alta_pct") or 0
1499
+ if prop is None:
1500
+ return {"forma": "não medida", "google_fonts": ["Montserrat", "Inter", "Anton"]}
1501
+ # a caixa lida pelo Vision cobre a linha toda; em minúscula a altura inclui hastes e pernas
1502
+ estreita, larga = (0.62, 0.90) if maius >= 0.7 else (0.45, 0.60)
1503
+ if prop < estreita:
1504
+ return {"forma": "estreita (condensada)", "google_fonts": ["Anton", "Bebas Neue", "Oswald"]}
1505
+ if prop > larga:
1506
+ return {"forma": "larga (geométrica)", "google_fonts": ["Montserrat", "Poppins", "Archivo"]}
1507
+ return {"forma": "média", "google_fonts": ["Inter", "Figtree", "Manrope"]}
1508
+
1509
+
1510
+ def montar_regras(ficha: dict, cortes: list[dict], ritmo: dict, ritmo_todos: dict, enq: dict, leg: dict,
1511
+ cores: dict, audio: dict, fala: dict | None, args) -> dict:
1512
+ estilo, motivo = sugerir_estilo_legenda(leg)
1513
+ fontes = fontes_parecidas(leg)
1514
+ destaque = leg.get("cor_destaque") # sem legenda medida, fica vazio: use a cor da marca da pessoa
1515
+ texto_cor = leg.get("cor_texto") or "#FFFFFF"
1516
+ trilha = audio.get("trilha", {}) if audio.get("tem_audio") else {}
1517
+ efeitos = audio.get("efeitos", {}) if audio.get("tem_audio") else {}
1518
+ tem_trilha = trilha.get("veredito") in ("trilha provável", "som contínuo alto")
1519
+ vol_db = None
1520
+ if tem_trilha and trilha.get("diferenca_db") is not None:
1521
+ vol_db = int(round(max(-24, min(-10, -(trilha["diferenca_db"] - 4)))))
1522
+ zoom = enq.get("zoom_nos_cortes") or {}
1523
+ nivel = zoom.get("nivel_sugerido")
1524
+ font_px = leg.get("fonte_estimada_padrao")
1525
+ vertical = ficha["vertical"] and ficha["altura"] / max(1, ficha["largura"]) > 1.1
1526
+ tamanhos_ev = {"impacto": 134, "caixa_viva": 86, "palavra_gigante": 196, "pulso": 78, "fita": 78,
1527
+ "legenda_limpa": 60, "papelaria": 88}
1528
+ escala = None
1529
+ if font_px and vertical:
1530
+ escala = round(max(0.9, min(1.15, font_px / tamanhos_ev.get(estilo, 86))), 2)
1531
+ return {
1532
+ "versao": VERSAO,
1533
+ "gerado_em": time.strftime("%Y-%m-%dT%H:%M:%S"),
1534
+ "gerado_por": "helpers/medir_referencia.py",
1535
+ "referencia": {"arquivo": ficha["arquivo"], "sha256": ficha["sha256"]},
1536
+ "aviso": "Valores de estilo medidos. Não copie fala, promessa, marca, logo, música, pessoa nem números "
1537
+ "de resultado da referência.",
1538
+ "ficha": {k: ficha[k] for k in ("duracao_s", "largura", "altura", "proporcao", "fps", "tem_audio")},
1539
+ "ritmo": {
1540
+ "origem": "medido",
1541
+ "cortes_por_minuto": ritmo["cortes_por_minuto"],
1542
+ "plano_medio_s": ritmo["plano_medio_s"],
1543
+ "plano_mediano_s": ritmo["plano_mediano_s"],
1544
+ "intervalo_medio_entre_cortes_s": ritmo["intervalo_medio_entre_cortes_s"],
1545
+ "plano_mais_curto_s": ritmo["plano_mais_curto_s"],
1546
+ "plano_mais_longo_s": ritmo["plano_mais_longo_s"],
1547
+ "cortes_nos_3_primeiros_s": ritmo["cortes_nos_3_primeiros_s"],
1548
+ "cortes_por_10s": ritmo["cortes_por_10s"],
1549
+ "cortes": [c["t"] for c in cortes if c["tipo"] == "corte"],
1550
+ "cortes_provaveis": [c["t"] for c in cortes if c["tipo"] != "corte"],
1551
+ "com_provaveis": {"cortes_por_minuto": ritmo_todos["cortes_por_minuto"],
1552
+ "plano_medio_s": ritmo_todos["plano_medio_s"]},
1553
+ "limiar_nota_de_cena": args.limiar,
1554
+ },
1555
+ "enquadramento": {"origem": "estimado", **{k: v for k, v in enq.items() if not k.startswith("_")}},
1556
+ "legenda": {
1557
+ "origem": {"posicao_tamanho_palavras": "medido", "cores_fonte_estilo": "estimado"},
1558
+ **{k: v for k, v in leg.items() if not k.startswith("_") and k not in ("textos_fixos", "tela_clara_trechos", "gancho")},
1559
+ "fonte_parecida": fontes,
1560
+ },
1561
+ "textos_fixos": [{k: v for k, v in f.items() if k != "texto"} for f in leg.get("textos_fixos", [])],
1562
+ "gancho_escrito": leg.get("gancho"),
1563
+ "cores": {"origem": "medido nos pixels; papel de cada cor estimado", "texto": texto_cor,
1564
+ "destaque": destaque, **cores},
1565
+ "fala": fala,
1566
+ "audio": {
1567
+ "origem": {"loudness": "medido", "trilha_e_efeitos": "estimado"},
1568
+ "tem_audio": audio.get("tem_audio", False),
1569
+ "loudness": audio.get("loudness"),
1570
+ "estereo": audio.get("estereo"),
1571
+ "correlacao_esq_dir": audio.get("correlacao_esq_dir"),
1572
+ "trilha": trilha,
1573
+ "efeitos": {k: v for k, v in efeitos.items() if k != "lista"} if efeitos else None,
1574
+ "efeitos_tempos": [{"t": e["t"], "tipo": e["tipo"]} for e in efeitos.get("lista", [])] if efeitos else [],
1575
+ "ataques_por_minuto": audio.get("ataques_por_minuto"),
1576
+ },
1577
+ "editor_video": {
1578
+ "nota": "Campos com o mesmo nome do edit_data.json desta skill. Ponto de partida, não ordem.",
1579
+ "accent": destaque,
1580
+ "camera": {"zoom_cuts": bool(zoom.get("parece_zoom")),
1581
+ "zoom_levels": [1.0, nivel] if nivel else [1.0, 1.14],
1582
+ "push_in": False},
1583
+ "captions": {"enabled": bool(leg.get("tem_legenda")), "style": estilo, "motivo_do_estilo": motivo,
1584
+ "center_y": leg.get("centro_y_padrao") if vertical else None, "font_scale": escala,
1585
+ "nota_posicao": None if vertical else
1586
+ "referência não é vertical: converta pela fração da altura (legenda.centro_y) e confira na amostra",
1587
+ "max_words": int(min(3, max(1, round(leg.get("palavras_por_linha_mediana") or 3))))
1588
+ if estilo != "legenda_limpa" else 8},
1589
+ "sfx": {"enabled": (bool(efeitos.get("provaveis")) if efeitos.get("confianca") != "baixa" else None),
1590
+ "volume": 0.6,
1591
+ "eventos_por_minuto_alvo": efeitos.get("por_minuto") if efeitos.get("confianca") != "baixa" else None,
1592
+ "confianca": efeitos.get("confianca"),
1593
+ "som_nos_cortes_pct": efeitos.get("cortes_com_som_pct")},
1594
+ "music": {"enabled": bool(tem_trilha), "volume_db": vol_db if vol_db is not None else -15,
1595
+ "duck": bool(trilha.get("abaixa_na_fala", True)),
1596
+ "fade_out": float(trilha.get("some_antes_do_fim_s") or 1.5) if tem_trilha else 1.5},
1597
+ "loudness": {"i": -14, "tp": -1.0},
1598
+ "ritmo_alvo": {"cortes_por_minuto": ritmo["cortes_por_minuto"], "plano_medio_s": ritmo["plano_medio_s"]},
1599
+ },
1600
+ "hyperframes": {
1601
+ "nota": "Tokens para um frame.md (cores e tipografia). Layout continua livre.",
1602
+ "colors": {"texto": texto_cor, "destaque": destaque,
1603
+ "fundo": (cores.get("dominantes") or [{}])[0].get("hex")},
1604
+ "typography": {"legenda": {"fontFamily": fontes["google_fonts"][0],
1605
+ "weight": 800,
1606
+ "px_na_tela_padrao": font_px,
1607
+ "tela_padrao": leg.get("tela_padrao"),
1608
+ "upper": bool((leg.get("caixa_alta_pct") or 0) >= 0.7)}},
1609
+ "legenda_centro_y_frac": leg.get("centro_y"),
1610
+ },
1611
+ }
1612
+
1613
+
1614
+ def _linha_tab(*cols) -> str:
1615
+ return "| " + " | ".join(str(c) for c in cols) + " |"
1616
+
1617
+
1618
+ def escrever_guia(caminho: Path, ficha: dict, cortes: list[dict], ritmo: dict, ritmo_todos: dict, enq: dict,
1619
+ leg: dict | None, cores: dict, audio: dict, fala: dict | None, regras: dict,
1620
+ arquivos: dict, args, faltas: list[str]) -> None:
1621
+ L: list[str] = []
1622
+ a = L.append
1623
+ nome = ficha["arquivo"]
1624
+ curto = nome if len(nome) <= 60 else nome[:57] + "..."
1625
+ fortes = [c for c in cortes if c["tipo"] == "corte"]
1626
+ prov = [c for c in cortes if c["tipo"] != "corte"]
1627
+ leg = leg or {}
1628
+ ev = regras["editor_video"]
1629
+ a(f"# Guia de estilo medido: {curto}")
1630
+ a("")
1631
+ a(f"Gerado em {time.strftime('%d/%m/%Y %H:%M')} pelo `medir_referencia.py` (editor-video).")
1632
+ a("Serve para editar OUTRO vídeo com a mesma linguagem de edição. Não serve para copiar fala, promessa,")
1633
+ a("marca, logo, música, pessoa ou números de resultado da referência.")
1634
+ a("")
1635
+ a("Como ler:")
1636
+ a("- **MEDIDO**: saiu de uma conta feita no vídeo. Pode confiar, dentro da precisão indicada.")
1637
+ a("- **ESTIMADO**: dedução a partir das medidas. Pode errar. Confira na folha de quadros e nos recortes.")
1638
+ a("- **NÃO DÁ PRA SABER**: está no fim. Não invente esses itens.")
1639
+ a("")
1640
+
1641
+ # resumo
1642
+ a("## Resumo")
1643
+ a("")
1644
+ if ritmo["cortes"]:
1645
+ a(f"- Ritmo: {ritmo['cortes']} cortes em {br(ficha['duracao_s'])} s, {br(ritmo['cortes_por_minuto'])} por minuto, "
1646
+ f"um plano novo a cada {br(ritmo['plano_medio_s'])} s em média. **MEDIDO**")
1647
+ else:
1648
+ a(f"- Ritmo: nenhum corte seco em {br(ficha['duracao_s'])} s (plano único ou só transições suaves). **MEDIDO**")
1649
+ if prov:
1650
+ a(f"- Mais {len(prov)} corte(s) fraco(s) para conferir; com eles seriam {br(ritmo_todos['cortes_por_minuto'])} "
1651
+ f"por minuto. **ESTIMADO**")
1652
+ if enq.get("rosto_pct") is not None:
1653
+ a(f"- Rosto na tela em {pct(enq['rosto_pct'])} dos segundos; tamanho típico: {enq.get('tipo_de_plano', '?')}. **ESTIMADO**")
1654
+ if leg.get("tem_legenda"):
1655
+ a(f"- Legenda com {br(leg.get('palavras_por_linha_mediana'), 0)} palavra(s) por linha, centro em "
1656
+ f"{pct(leg.get('centro_y'))} da altura, {pct(leg.get('caixa_alta_pct'))} em caixa alta. **MEDIDO**")
1657
+ elif leg:
1658
+ a("- Não achei legenda da fala escrita na tela. **MEDIDO** (leitura de texto)")
1659
+ if audio.get("tem_audio"):
1660
+ tr = audio["trilha"]
1661
+ a(f"- Áudio: {br(audio.get('loudness', {}).get('integrado_lufs'))} LUFS; {tr['veredito']}"
1662
+ f" ({tr['explicacao']}). **ESTIMADO**")
1663
+ ef = audio["efeitos"]
1664
+ a(f"- Efeitos sonoros prováveis: {ef['provaveis']} ({br(ef['por_minuto'])} por minuto). **ESTIMADO**")
1665
+ if fala:
1666
+ a(f"- Fala: {br(fala['palavras_por_minuto'], 0)} palavras por minuto, primeira palavra em "
1667
+ f"{br(fala['primeira_palavra_s'], 2)} s. **MEDIDO**")
1668
+ a("")
1669
+
1670
+ a("## 1. Ficha técnica (MEDIDO)")
1671
+ a("")
1672
+ a(_linha_tab("Item", "Valor"))
1673
+ a(_linha_tab("---", "---"))
1674
+ a(_linha_tab("Arquivo", f"`{curto}`"))
1675
+ a(_linha_tab("Duração", f"{br(ficha['duracao_s'], 2)} s"))
1676
+ a(_linha_tab("Resolução", f"{ficha['largura']} x {ficha['altura']} ({ficha['proporcao']})"
1677
+ + (f", girado {ficha['rotacao']} graus" if ficha["rotacao"] else "")))
1678
+ a(_linha_tab("Quadros por segundo", br(ficha["fps"], 2) + (" (variável)" if ficha["fps_variavel"] else "")))
1679
+ a(_linha_tab("Áudio", f"{ficha['codec_audio']} {ficha['canais_audio']} canal(is) {ficha['taxa_audio_hz']} Hz"
1680
+ if ficha["tem_audio"] else "sem áudio"))
1681
+ a(_linha_tab("Impressão digital (sha256)", f"`{ficha['sha256'][:16]}...`"))
1682
+ a("")
1683
+
1684
+ a("## 2. Ritmo e cortes")
1685
+ a("")
1686
+ a(f"Detector de cena do ffmpeg quadro a quadro. Corte = nota de cena maior ou igual a {br(args.limiar, 2)}. "
1687
+ f"Corte provável = pico fraco, pelo menos {br(RAZAO_PROVAVEL, 0)} vezes maior que a vizinhança.")
1688
+ a("")
1689
+ a(_linha_tab("Medida", "Valor", "Status"))
1690
+ a(_linha_tab("---", "---", "---"))
1691
+ a(_linha_tab("Cortes secos", ritmo["cortes"], "MEDIDO"))
1692
+ a(_linha_tab("Cortes por minuto", br(ritmo["cortes_por_minuto"]), "MEDIDO"))
1693
+ a(_linha_tab("Duração média de cada plano", f"{br(ritmo['plano_medio_s'], 2)} s (mediana {br(ritmo['plano_mediano_s'], 2)} s)", "MEDIDO"))
1694
+ a(_linha_tab("Tempo médio entre cortes", f"{br(ritmo['intervalo_medio_entre_cortes_s'], 2)} s"
1695
+ if ritmo["intervalo_medio_entre_cortes_s"] else "menos de 2 cortes", "MEDIDO"))
1696
+ if ritmo["plano_mais_curto_s"] is not None:
1697
+ a(_linha_tab("Plano mais curto / mais longo", f"{br(ritmo['plano_mais_curto_s'], 2)} s / {br(ritmo['plano_mais_longo_s'], 2)} s", "MEDIDO"))
1698
+ if ritmo["maior_trecho_sem_corte"]:
1699
+ m = ritmo["maior_trecho_sem_corte"]
1700
+ a(_linha_tab("Maior trecho sem corte", f"{mmss(m['inicio'])} a {mmss(m['fim'])}", "MEDIDO"))
1701
+ a(_linha_tab("Planos com menos de 1,5 s", pct(ritmo["planos_curtos_pct"]), "MEDIDO"))
1702
+ a(_linha_tab("Cortes nos 3 primeiros segundos", ritmo["cortes_nos_3_primeiros_s"], "MEDIDO"))
1703
+ a(_linha_tab("Cortes prováveis (fracos)", len(prov), "ESTIMADO, confira em `cortes-para-conferir.jpg`"))
1704
+ a("")
1705
+ if ritmo["cortes_por_10s"]:
1706
+ a("Cortes a cada 10 s: " + ", ".join(f"{i * 10}-{i * 10 + 10}s: {n}" for i, n in enumerate(ritmo["cortes_por_10s"])) + ".")
1707
+ a("")
1708
+ if cortes:
1709
+ a("Lista: " + ", ".join(f"{mmss(c['t'])}" + ("" if c["tipo"] == "corte" else " (provável)")
1710
+ + (" (vários quadros)" if c.get("obs") else "") for c in cortes) + ".")
1711
+ a("")
1712
+
1713
+ a("## 3. Enquadramento e zoom (ESTIMADO, detector de rosto)")
1714
+ a("")
1715
+ if enq.get("rosto_pct") is None:
1716
+ a("Detector de rosto indisponível. Olhe a folha de quadros.")
1717
+ else:
1718
+ a(_linha_tab("Medida", "Valor"))
1719
+ a(_linha_tab("---", "---"))
1720
+ a(_linha_tab("Segundos com rosto", pct(enq["rosto_pct"])))
1721
+ if enq.get("rosto_altura_mediana"):
1722
+ a(_linha_tab("Altura do rosto", f"{pct(enq['rosto_altura_mediana'])} da altura do quadro ({enq['tipo_de_plano']})"))
1723
+ a(_linha_tab("Altura dos olhos", f"{pct(enq['olhos_y_mediana'])} a partir do topo"))
1724
+ a(_linha_tab("Posição do rosto", enq.get("posicao_horizontal", "?")))
1725
+ if enq.get("planos_sem_rosto") is not None:
1726
+ a(_linha_tab("Planos sem rosto (imagem de apoio, tela, produto)",
1727
+ f"{enq['planos_sem_rosto']} de {enq['planos_medidos']}"))
1728
+ z = enq.get("zoom_nos_cortes") or {}
1729
+ if z.get("pares"):
1730
+ a(_linha_tab("Mudança de tamanho do rosto nos cortes",
1731
+ f"mediana {pct(z['mudanca_mediana'])} em {z['pares']} cortes na mesma cena com rosto dos dois lados; "
1732
+ f"{z['com_zoom']} mudaram 6% ou mais"))
1733
+ a(_linha_tab("Zoom nos cortes", ("parece que sim, nível sugerido " + br(z["nivel_sugerido"], 2))
1734
+ if z.get("parece_zoom") else "não parece"))
1735
+ a("")
1736
+
1737
+ a("## 4. Legenda e texto na tela")
1738
+ a("")
1739
+ if not leg:
1740
+ a("Leitura de texto desligada ou indisponível. Olhe a folha de quadros e os recortes.")
1741
+ else:
1742
+ motor = "Vision da Apple (lê as palavras)" if leg.get("motor") == "vision" else "manchas de texto (sem ler as palavras)"
1743
+ a(f"Lido com {motor}, {br(leg.get('fps_leitura'), 0)} amostras por segundo. Tamanhos em px do vídeo original "
1744
+ f"e convertidos para a tela padrão {leg.get('tela_padrao', '1080x1920')}.")
1745
+ a("")
1746
+ if leg.get("tem_legenda"):
1747
+ a(_linha_tab("Medida", "Valor", "Status"))
1748
+ a(_linha_tab("---", "---", "---"))
1749
+ a(_linha_tab("Tempo com legenda", pct(leg["tempo_com_legenda_pct"]) + " das amostras"
1750
+ + (f", {pct(leg['fala_com_legenda_pct'])} dos trechos com fala" if leg.get("fala_com_legenda_pct") is not None else ""), "MEDIDO"))
1751
+ a(_linha_tab("A legenda escreve a fala?", "sim, bate com a transcrição" if leg.get("casou_com_fala")
1752
+ else "não conferido (sem transcrição)", "MEDIDO" if leg.get("casou_com_fala") else "ESTIMADO"))
1753
+ a(_linha_tab("Posição principal (centro da linha)", f"{pct(leg['centro_y'], 1)} da altura, y = {leg['centro_y_px']} px "
1754
+ f"(na tela padrão: {leg['centro_y_padrao']}), {pct(leg['posicoes'][0]['parte_da_legenda'])} do tempo com legenda", "MEDIDO"))
1755
+ for pos in leg.get("posicoes", [])[1:4]:
1756
+ a(_linha_tab("Outra posição", f"{pct(pos['centro_y'], 1)} da altura (na tela padrão: {pos['centro_y_padrao']}), "
1757
+ f"{pct(pos['parte_da_legenda'])} do tempo com legenda, entre {mmss(pos['de'])} e {mmss(pos['ate'])}", "MEDIDO"))
1758
+ a(_linha_tab("Altura da linha lida", f"{leg['altura_linha_px']} px (na tela padrão: {leg['altura_linha_padrao']})", "MEDIDO"))
1759
+ a(_linha_tab("Palavras por linha (posição principal)", f"mediana {br(leg['palavras_por_linha_mediana'], 0)}, máximo {leg['palavras_por_linha_max']}", "MEDIDO"))
1760
+ a(_linha_tab("Linhas ao mesmo tempo", ", ".join(f"{k} linha(s) em {pct(v)}" for k, v in
1761
+ (leg.get("linhas_por_vez_pct") or {}).items()) or "?", "MEDIDO"))
1762
+ a(_linha_tab("Caixa alta", pct(leg["caixa_alta_pct"]) + " das letras", "MEDIDO"))
1763
+ a(_linha_tab("Trocas de texto por minuto", br(leg["trocas_por_minuto_amostradas"], 0)
1764
+ + " (amostrado; troca mais rápida que 0,5 s não aparece)", "MEDIDO"))
1765
+ a(_linha_tab("Tamanho da fonte", f"perto de {leg.get('fonte_estimada_padrao', '?')} px na tela padrão", "ESTIMADO"))
1766
+ a(_linha_tab("Forma da letra", regras["legenda"]["fonte_parecida"]["forma"], "ESTIMADO"))
1767
+ a(_linha_tab("Cor do texto (posição principal)", leg.get("cor_texto") or "?", "ESTIMADO"))
1768
+ a(_linha_tab("Cor de destaque (palavra falada)", (leg.get("cor_destaque") or "não achei")
1769
+ + (f", em {pct(leg.get('duas_cores_pct'))} das linhas" if leg.get("cor_destaque") else ""), "ESTIMADO"))
1770
+ if leg.get("cor_apagada"):
1771
+ a(_linha_tab("Cor apagada (palavra que ainda vem)", leg["cor_apagada"], "ESTIMADO"))
1772
+ a(_linha_tab("Contorno ou sombra escura", "provável" if (leg.get("contorno_escuro_pct") or 0) >= 0.4 else "não parece", "ESTIMADO"))
1773
+ a(_linha_tab("Fundo liso atrás do texto (caixa, tarja)", "provável" if (leg.get("caixa_atras_pct") or 0) >= 0.5 else "não parece", "ESTIMADO"))
1774
+ a(_linha_tab("Estilo mais parecido no EDIT IA PRO", f"`{ev['captions']['style']}` ({ev['captions']['motivo_do_estilo']})", "ESTIMADO"))
1775
+ a(_linha_tab("Fontes livres parecidas (Google Fonts)", ", ".join(regras["legenda"]["fonte_parecida"]["google_fonts"]), "ESTIMADO"))
1776
+ else:
1777
+ a("Não achei legenda da fala na tela. **MEDIDO**")
1778
+ a("")
1779
+ g = leg.get("gancho") or {}
1780
+ if g.get("tem_texto_nos_3s"):
1781
+ a(f"Texto nos 3 primeiros segundos: sim, na zona {g.get('zona')}, até {g.get('altura_max_px')} px de altura"
1782
+ + (", maior que a legenda (título de gancho)." if g.get("texto_maior_que_legenda") else ".") + " **MEDIDO**")
1783
+ a("")
1784
+ fx = leg.get("textos_fixos") or []
1785
+ if fx:
1786
+ a(f"Textos que ficam parados 3 s ou mais (títulos, rótulos, marca): {len(fx)}. O texto de cada um está no "
1787
+ f"`trechos-medidos.csv`; aqui fica só a forma. **MEDIDO**")
1788
+ a("")
1789
+ a(_linha_tab("De", "Até", "Zona", "Altura", "Palavras", "Obs."))
1790
+ a(_linha_tab("---", "---", "---", "---", "---", "---"))
1791
+ for f in fx[:15]:
1792
+ a(_linha_tab(mmss(f["inicio"]), mmss(f["fim"]), f["zona"], f"{f['altura_px']} px", f["palavras"],
1793
+ "marca ou perfil, não copiar" if f["parece_marca_ou_perfil"] else ""))
1794
+ if len(fx) > 15:
1795
+ a(f"(mais {len(fx) - 15} no trechos-medidos.csv)")
1796
+ a("")
1797
+ if leg.get("tela_clara_trechos"):
1798
+ a("Trechos com a tela quase toda clara (gráfico de fundo claro): "
1799
+ + ", ".join(f"{mmss(x0)} a {mmss(x1)}" for x0, x1 in leg["tela_clara_trechos"]) + ". **MEDIDO**")
1800
+ a("")
1801
+
1802
+ a("## 5. Cores")
1803
+ a("")
1804
+ if cores:
1805
+ a("Medidas nos pixels de 1 quadro por segundo (**MEDIDO**). O papel de cada cor é **ESTIMADO**: cor de pele, parede")
1806
+ a("e roupa entram na conta. A cor de destaque dos textos vem da seção 4.")
1807
+ a("")
1808
+ a(_linha_tab("Cor", "Parte da imagem", "Parece"))
1809
+ a(_linha_tab("---", "---", "---"))
1810
+ for c in cores.get("dominantes", []):
1811
+ a(_linha_tab(f"`{c['hex']}`", pct(c["fracao"]), c["nome"]))
1812
+ a("")
1813
+ if cores.get("saturadas"):
1814
+ a("Cores fortes que aparecem (candidatas a destaque): " + ", ".join(
1815
+ f"`{c['hex']}` {c['nome']}" for c in cores["saturadas"]) + ".")
1816
+ a("")
1817
+ a(f"Tom geral: brilho médio {pct(cores['brilho_medio'])}, saturação média {pct(cores['saturacao_media'])}, "
1818
+ f"{pct(cores['quadros_escuros_pct'])} dos quadros escuros, {pct(cores['quadros_claros_pct'])} claros, "
1819
+ f"temperatura {cores['temperatura']}.")
1820
+ a("")
1821
+
1822
+ a("## 6. Áudio")
1823
+ a("")
1824
+ if not audio.get("tem_audio"):
1825
+ a("Sem áudio. **MEDIDO**")
1826
+ else:
1827
+ lo = audio.get("loudness", {})
1828
+ tr = audio["trilha"]
1829
+ ef = audio["efeitos"]
1830
+ a("Nada foi ouvido. Tudo vem de medida de sinal.")
1831
+ a("")
1832
+ a(_linha_tab("Medida", "Valor", "Status"))
1833
+ a(_linha_tab("---", "---", "---"))
1834
+ a(_linha_tab("Volume percebido (loudness integrado)", f"{br(lo.get('integrado_lufs'))} LUFS", "MEDIDO"))
1835
+ a(_linha_tab("Pico real", f"{br(lo.get('pico_real_dbtp'))} dBTP", "MEDIDO"))
1836
+ a(_linha_tab("Variação de volume", f"{br(lo.get('variacao_lu'))} LU", "MEDIDO"))
1837
+ a(_linha_tab("Estéreo", ("sim" if audio["estereo"] else "não, praticamente mono")
1838
+ + f" (correlação esquerda/direita {br(audio['correlacao_esq_dir'], 3)})", "MEDIDO"))
1839
+ a(_linha_tab("Nível da voz / do fundo", f"{br(tr['voz_dbfs'])} / {br(tr['piso_entre_frases_dbfs'])} dBFS "
1840
+ f"(diferença {br(tr['diferenca_db'])} dB; {tr['metodo']})",
1841
+ "MEDIDO" if tr.get("piso_confiavel") else "ESTIMADO"))
1842
+ a(_linha_tab("Trilha", f"{tr['veredito']}: {tr['explicacao']}", "ESTIMADO"))
1843
+ if "abaixa_na_fala" in tr:
1844
+ a(_linha_tab("Trilha abaixa quando fala (ducking)", "parece que sim" if tr["abaixa_na_fala"] else "não parece", "ESTIMADO"))
1845
+ if tr.get("bpm"):
1846
+ a(_linha_tab("Batida", f"perto de {br(tr['bpm'], 0)} BPM (força {br(tr['bpm_forca'], 2)})", "ESTIMADO"))
1847
+ if tr.get("some_antes_do_fim_s"):
1848
+ a(_linha_tab("Trilha some antes do fim", f"uns {br(tr['some_antes_do_fim_s'])} s antes", "ESTIMADO"))
1849
+ a(_linha_tab("Efeitos sonoros prováveis", f"{ef['provaveis']} ({br(ef['por_minuto'])} por minuto), confiança {ef['confianca']}", "ESTIMADO"))
1850
+ if ef.get("cortes_com_som_pct") is not None:
1851
+ a(_linha_tab("Cortes com som junto (até 0,2 s)", pct(ef["cortes_com_som_pct"]), "ESTIMADO"))
1852
+ a(_linha_tab("Ataques de som por minuto (inclui sílabas da fala)", br(audio.get("ataques_por_minuto"), 0), "MEDIDO"))
1853
+ a("")
1854
+ if ef["lista"]:
1855
+ a("Efeitos prováveis (tempo e família pelo espectro): " + "; ".join(
1856
+ f"{mmss(e['t'])} {e['tipo'].split(' ')[0]}" for e in ef["lista"][:40]) + (" ..." if len(ef["lista"]) > 40 else "") + ".")
1857
+ a("")
1858
+ a(f"Como os efeitos foram achados: {ef['metodo']}. Batida de música e respiração forte também podem aparecer.")
1859
+ a("")
1860
+
1861
+ a("## 7. Fala")
1862
+ a("")
1863
+ if fala:
1864
+ a("Transcrição local (Whisper desta skill). Só os números entram aqui. **MEDIDO**")
1865
+ a("")
1866
+ a(_linha_tab("Medida", "Valor"))
1867
+ a(_linha_tab("---", "---"))
1868
+ a(_linha_tab("Idioma", fala.get("idioma") or "?"))
1869
+ a(_linha_tab("Palavras por minuto", br(fala["palavras_por_minuto"], 0)))
1870
+ a(_linha_tab("Primeira palavra", f"{br(fala['primeira_palavra_s'], 2)} s"))
1871
+ a(_linha_tab("Pausas acima de 0,3 s", f"{fala['pausas_acima_0_3s']} ({br(fala['pausas_por_minuto'])} por minuto)"))
1872
+ a(_linha_tab("Maior pausa", f"{br(fala['maior_pausa_s'], 2)} s"))
1873
+ a(_linha_tab("Tempo depois da última palavra", f"{br(fala['silencio_no_fim_s'], 2)} s"))
1874
+ else:
1875
+ a("Sem transcrição (desligada ou falhou). Ritmo de fala não medido.")
1876
+ a("")
1877
+
1878
+ a("## 8. Leitura da IA (preencher olhando a folha de quadros)")
1879
+ a("")
1880
+ a("Abra `folha-de-quadros/` e `cortes-para-conferir*.jpg` e complete. Escreva o que viu, sem copiar texto da referência.")
1881
+ a("")
1882
+ for p in ("Tipo de vídeo (gente falando, tela, produto, tutorial, mistura):",
1883
+ "Os cortes marcados batem com a folha? Algum provável é corte de verdade?",
1884
+ "Elementos além da legenda (título de gancho, setas, prints, emoji, barra de progresso, moldura):",
1885
+ "Transições (corte seco, zoom, chicote, flash, deslize):",
1886
+ "Imagens de apoio (B-roll): quando entram e quanto tempo ficam:",
1887
+ "Como abre (primeiros 3 s) e como fecha (chamada):",
1888
+ "O que combina com o vídeo do usuário e o que não combina:"):
1889
+ a(f"- {p}")
1890
+ a("")
1891
+
1892
+ a("## 9. O que NÃO DÁ PRA SABER só medindo")
1893
+ a("")
1894
+ fixos = [
1895
+ "Nome exato da fonte. Só a forma (estreita, média, larga) e o peso aparente.",
1896
+ "Curva exata das animações (entrada da palavra, pulinho, deslize). Para ver de perto, gere uma tira com "
1897
+ "`tira_de_quadros.py` a 10 ou 30 quadros por segundo no trecho.",
1898
+ "Fusões e cortes suaves (dissolve) e zoom digital lento dentro do plano. O detector só pega troca brusca.",
1899
+ "Se cada efeito é whoosh, pop ou estalo. Só a posição e a faixa de frequência (nada foi ouvido).",
1900
+ "Nome, autor e licença da música. Nunca use a música da referência.",
1901
+ "Programa ou IA usada na edição, e se algo é 3D de verdade.",
1902
+ "Resultado de venda ou de alcance da referência.",
1903
+ ]
1904
+ for f in fixos + faltas:
1905
+ a(f"- {f}")
1906
+ a("")
1907
+
1908
+ a("## 10. Arquivos desta medida")
1909
+ a("")
1910
+ for k, v in arquivos.items():
1911
+ a(f"- `{k}`: {v}")
1912
+ a("")
1913
+ texto = "\n".join(L)
1914
+ texto = texto.replace("\u2014", ",").replace("\u2013", "-")
1915
+ caminho.write_text(texto, encoding="utf-8")
1916
+
1917
+
1918
+ def escrever_trechos_medidos(caminho: Path, dur: float, quadros: list[tuple[float, Path]],
1919
+ rostos_1s: dict[int, dict | None], cortes: list[dict], planos_bordas: list[float],
1920
+ textos: dict | None, audio: dict, palavras: list[dict] | None,
1921
+ notas: tuple[np.ndarray, np.ndarray], brilho_1s: dict[int, float],
1922
+ papel_1s: dict[int, float]) -> None:
1923
+ n_seg = int(math.ceil(dur))
1924
+ itens = (textos or {}).get("_itens", [])
1925
+ leg_ids = {id(e) for e in (textos or {}).get("_legenda", [])}
1926
+ sons = (audio.get("efeitos") or {}).get("lista", []) if audio.get("tem_audio") else []
1927
+ por_seg = audio.get("_por_segundo", []) if audio.get("tem_audio") else []
1928
+ tn, sn = notas
1929
+ with open(caminho, "w", newline="", encoding="utf-8") as fh:
1930
+ w = csv.writer(fh)
1931
+ w.writerow(["segundo", "tempo", "plano", "corte", "o_que_aparece", "escrito_na_tela", "som", "observacao"])
1932
+ for s in range(n_seg):
1933
+ plano = int(np.searchsorted(planos_bordas, s + 0.5, side="right"))
1934
+ cs = [c for c in cortes if s <= c["t"] < s + 1]
1935
+ corte_txt = " ".join(mmss(c["t"]) + ("" if c["tipo"] == "corte" else "(provável)") for c in cs)
1936
+ partes = []
1937
+ r = rostos_1s.get(s)
1938
+ if r:
1939
+ lado = "esquerda" if r["cx"] < 0.4 else ("direita" if r["cx"] > 0.6 else "centro")
1940
+ partes.append(f"{tipo_de_plano(r['altura'])} ({pct(r['altura'])} da altura, {lado})")
1941
+ if r.get("rostos", 1) > 1:
1942
+ partes.append(f"{r['rostos']} rostos")
1943
+ elif s in rostos_1s:
1944
+ partes.append("sem rosto")
1945
+ b = brilho_1s.get(s)
1946
+ if b is not None:
1947
+ partes.append("imagem escura" if b < 0.3 else ("imagem clara" if b > 0.75 else ""))
1948
+ if papel_1s.get(s, 0) >= 0.72:
1949
+ partes.append("tela clara (gráfico ou print)")
1950
+ sel = (tn >= s) & (tn < s + 1)
1951
+ if sel.any():
1952
+ mov = float(np.mean(sn[sel]))
1953
+ if mov > 0.05:
1954
+ partes.append("muito movimento")
1955
+ txts = []
1956
+ vistos = set()
1957
+ for e in itens:
1958
+ if s <= e["t"] < s + 1 and e.get("texto"):
1959
+ k = norm_txt(e["texto"])
1960
+ if k in vistos:
1961
+ continue
1962
+ vistos.add(k)
1963
+ tag = "legenda" if id(e) in leg_ids else ("fixo" if e["fixo"] else "texto")
1964
+ zona = "topo" if e["centro_y"] < 0.3 else ("baixo" if e["centro_y"] > 0.7 else "meio")
1965
+ txts.append(f"[{tag}, {zona}] {e['texto']}")
1966
+ som = []
1967
+ if palavras is not None:
1968
+ n_pal = sum(1 for p in palavras if s <= p["start"] < s + 1)
1969
+ som.append(f"fala: {n_pal} palavra(s)" if n_pal else "sem fala")
1970
+ if s < len(por_seg) and por_seg[s]:
1971
+ som.append(f"nível {br(por_seg[s]['db'])} dBFS")
1972
+ if por_seg[s].get("lado_db") is not None:
1973
+ som.append(f"estéreo {br(por_seg[s]['lado_db'])} dBFS")
1974
+ for e in sons:
1975
+ if s <= e["t"] < s + 1:
1976
+ som.append(f"efeito provável {mmss(e['t'])} {e['tipo'].split(' ')[0]}")
1977
+ w.writerow([s, mmss_curto(s), plano, corte_txt, "; ".join(p for p in partes if p),
1978
+ " | ".join(txts), "; ".join(som), ""])
1979
+
1980
+
1981
+ # ----------------------------------------------------------------------------- principal
1982
+
1983
+ def main(argv: list[str] | None = None) -> int:
1984
+ utf8_console()
1985
+ ap = argparse.ArgumentParser(description="Mede a linguagem de edição de um vídeo de referência (sem copiar o conteúdo).")
1986
+ ap.add_argument("video", type=Path, help="vídeo de referência (reel, anúncio, VSL)")
1987
+ ap.add_argument("--saida", type=Path, required=True, help="pasta onde ficam o guia, os trechos medidos, as regras e as imagens")
1988
+ ap.add_argument("--limiar", type=float, default=0.20,
1989
+ help="nota de cena do ffmpeg (0 a 1) para contar corte seco (padrão 0.20)")
1990
+ ap.add_argument("--sem-fala", action="store_true", help="não transcreve (mais rápido; sem ritmo de fala)")
1991
+ ap.add_argument("--sem-texto", action="store_true", help="não lê o texto da tela")
1992
+ ap.add_argument("--transcricao", type=Path, help="JSON de palavras já pronto (formato do transcribe.py)")
1993
+ ap.add_argument("--editor-video", type=Path, default=EV_PADRAO,
1994
+ help="outra pasta da skill editor-video (padrão: esta skill, ou EDITOR_VIDEO_DIR)")
1995
+ ap.add_argument("--fps-texto", type=float, default=2.0, help="amostras por segundo na leitura de texto (padrão 2)")
1996
+ args = ap.parse_args(argv)
1997
+
1998
+ video = args.video.expanduser().absolute()
1999
+ if not video.is_file():
2000
+ print(f"erro: não achei o vídeo {video}", file=sys.stderr)
2001
+ return 2
2002
+ saida = args.saida.expanduser().absolute()
2003
+ if saida == video.parent:
2004
+ print("erro: escolha uma pasta de saída só para a medida (não a pasta do vídeo).", file=sys.stderr)
2005
+ return 2
2006
+ ev = args.editor_video.expanduser().absolute()
2007
+ for b in ("ffmpeg", "ffprobe"):
2008
+ if shutil.which(b) is None:
2009
+ print(f"erro: falta o {b}. No Mac: brew install ffmpeg", file=sys.stderr)
2010
+ return 2
2011
+ if cv2 is None:
2012
+ print('erro: falta o opencv. Rode com o Python da skill (uv run --directory "<SKILL>" ...).', file=sys.stderr)
2013
+ return 2
2014
+ saida.mkdir(parents=True, exist_ok=True)
2015
+ medidas = saida / "medidas"
2016
+ medidas.mkdir(exist_ok=True)
2017
+ # rodar de novo na mesma pasta: tira as imagens da rodada anterior (só as que este script gera)
2018
+ for padrao in ("folha-de-quadros/folha_*.jpg", "recortes/legenda_*.jpg", "recortes/texto_fixo_*.jpg",
2019
+ "cortes-para-conferir*.jpg"):
2020
+ for velho_arq in saida.glob(padrao):
2021
+ velho_arq.unlink()
2022
+ faltas: list[str] = []
2023
+ inicio = time.time()
2024
+ fonte = carregar_fonte(ev, 18)
2025
+ fonte_p = carregar_fonte(ev, 14)
2026
+
2027
+ try:
2028
+ falar("1/8 ficha técnica...")
2029
+ ficha = ficha_tecnica(video)
2030
+ dur, W, H = ficha["duracao_s"], ficha["largura"], ficha["altura"]
2031
+ salvar_json(medidas / "ficha.json", ficha)
2032
+
2033
+ falar("2/8 procurando cortes (nota de cena quadro a quadro)...")
2034
+ with tempfile.TemporaryDirectory(prefix="sc-medir-") as tmp_s:
2035
+ tmp = Path(tmp_s)
2036
+ tempos_q, notas_q = pontuar_cenas(video, tmp)
2037
+ with open(medidas / "nota_de_cena.csv", "w", newline="") as fh:
2038
+ wr = csv.writer(fh)
2039
+ wr.writerow(["quadro", "t", "nota"])
2040
+ for i, (tq, sq) in enumerate(zip(tempos_q, notas_q)):
2041
+ wr.writerow([i, round(float(tq), 4), round(float(sq), 4)])
2042
+ cortes = achar_cortes(tempos_q, notas_q, dur, args.limiar)
2043
+ fortes_t = [c["t"] for c in cortes if c["tipo"] == "corte"]
2044
+ ritmo = medir_ritmo(fortes_t, dur)
2045
+ ritmo_todos = medir_ritmo([c["t"] for c in cortes], dur)
2046
+ salvar_json(medidas / "cortes.json", {"limiar": args.limiar, "cortes": cortes})
2047
+
2048
+ falar("3/8 tirando 1 quadro por segundo...")
2049
+ qw, qh = tamanho_saida(W, H, 1280)
2050
+ quadros = quadros_por_segundo(video, tmp / "q1s", qw, qh)
2051
+
2052
+ falar("4/8 rosto, enquadramento e cores...")
2053
+ rostos = Rostos(ev)
2054
+ if not rostos.ok:
2055
+ faltas.append("Enquadramento: o detector de rosto (YuNet desta skill) não carregou.")
2056
+ rostos_1s: dict[int, dict | None] = {}
2057
+ brilho_1s: dict[int, float] = {}
2058
+ for t, arq in quadros:
2059
+ img = cv2.imread(str(arq))
2060
+ s = int(math.floor(t + 1e-6))
2061
+ rostos_1s[s] = rostos.medir(img) if rostos.ok else None
2062
+ if img is not None:
2063
+ brilho_1s[s] = float(cv2.cvtColor(img, cv2.COLOR_BGR2HSV)[..., 2].mean()) / 255.0
2064
+ # quadros antes e depois de cada corte, e o meio de cada plano
2065
+ bordas = [0.0] + [c["t"] for c in cortes] + [dur]
2066
+ fps = ficha["fps"] or 30.0
2067
+ meios = []
2068
+ for a0, a1 in zip(bordas, bordas[1:]):
2069
+ meios.append(int(min(len(tempos_q) - 1, np.searchsorted(tempos_q, (a0 + a1) / 2))))
2070
+ pw, ph = tamanho_saida(W, H, 640)
2071
+ idx_pedidos = [c["quadro"] - 1 for c in cortes] + [c["quadro"] for c in cortes] + meios
2072
+ extra = quadros_por_indice(video, tempos_q, idx_pedidos, tmp / "pares", pw, ph)
2073
+ enq: dict = {}
2074
+ if rostos.ok:
2075
+ vals = [r for r in rostos_1s.values() if r]
2076
+ enq["rosto_pct"] = round(len(vals) / max(1, len(rostos_1s)), 3)
2077
+ if vals:
2078
+ alt = float(np.median([r["altura"] for r in vals]))
2079
+ cx = float(np.median([r["cx"] for r in vals]))
2080
+ enq.update({"rosto_altura_mediana": round(alt, 3), "tipo_de_plano": tipo_de_plano(alt),
2081
+ "olhos_y_mediana": round(float(np.median([r["olhos_y"] for r in vals])), 3),
2082
+ "rosto_cx_mediana": round(cx, 3),
2083
+ "posicao_horizontal": "esquerda" if cx < 0.4 else ("direita" if cx > 0.6 else "centro"),
2084
+ "rostos_multiplos_pct": round(sum(1 for r in vals if r["rostos"] > 1) / len(vals), 3)})
2085
+ sem = 0
2086
+ for i in meios:
2087
+ arq = extra.get(i)
2088
+ if arq is not None and rostos.medir(cv2.imread(str(arq))) is None:
2089
+ sem += 1
2090
+ enq["planos_medidos"] = len(meios)
2091
+ enq["planos_sem_rosto"] = sem
2092
+ mud = []
2093
+ for c in cortes:
2094
+ a_arq, d_arq = extra.get(c["quadro"] - 1), extra.get(c["quadro"])
2095
+ if not a_arq or not d_arq:
2096
+ continue
2097
+ ia, idp = cv2.imread(str(a_arq)), cv2.imread(str(d_arq))
2098
+ ra, rd = rostos.medir(ia), rostos.medir(idp)
2099
+ if ra and rd and abs(ra["cx"] - rd["cx"]) < 0.2 and mesma_cena(ia, idp):
2100
+ r = rd["altura"] / ra["altura"]
2101
+ if 1 / 1.8 < r < 1.8:
2102
+ mud.append(max(r, 1 / r))
2103
+ if mud:
2104
+ grandes = [m for m in mud if m >= 1.06]
2105
+ nivel = round(float(np.median(grandes)), 2) if grandes else None
2106
+ enq["zoom_nos_cortes"] = {
2107
+ "pares": len(mud), "mudanca_mediana": round(float(np.median(mud)) - 1, 3),
2108
+ "com_zoom": len(grandes),
2109
+ "parece_zoom": len(mud) >= 3 and len(grandes) >= max(2, 0.4 * len(mud)),
2110
+ "nivel_sugerido": round(min(1.25, max(1.04, nivel)), 2) if nivel else None,
2111
+ }
2112
+ cores = paleta_da_imagem(quadros)
2113
+
2114
+ falar("5/8 fala (transcrição local)...")
2115
+ arq_tr = None
2116
+ idioma = None
2117
+ if args.transcricao:
2118
+ arq_tr = args.transcricao.expanduser().absolute()
2119
+ elif not args.sem_fala and ficha["tem_audio"]:
2120
+ arq_tr = transcrever(video, ev, medidas)
2121
+ if arq_tr is None:
2122
+ faltas.append("Fala: a transcrição falhou, então ritmo de fala e 'a legenda escreve a fala' ficaram sem medida.")
2123
+ palavras = ler_palavras(arq_tr)
2124
+ if arq_tr and arq_tr.exists():
2125
+ try:
2126
+ idioma = json.loads(arq_tr.read_text(encoding="utf-8")).get("language")
2127
+ except Exception:
2128
+ idioma = None
2129
+ fala = medir_fala(palavras, dur, idioma)
2130
+
2131
+ falar("6/8 texto na tela...")
2132
+ textos = None
2133
+ if not args.sem_texto:
2134
+ doc = ler_texto_na_tela(video, ev, medidas, arq_tr if palavras else None, args.fps_texto)
2135
+ if doc:
2136
+ textos = analisar_textos(doc, palavras, W, H)
2137
+ if textos.get("motor") != "vision":
2138
+ faltas.append("Texto: sem a Vision da Apple a leitura só acha manchas de texto; palavras por "
2139
+ "linha e caixa alta não foram medidas.")
2140
+ # cores do texto da legenda
2141
+ if textos.get("tem_legenda"):
2142
+ por_t = {round(t, 1): arq for t, arq in quadros}
2143
+ amostras_cor = []
2144
+ for e in textos["_principal"]:
2145
+ arq = por_t.get(round(e["t"], 1))
2146
+ if not arq:
2147
+ continue
2148
+ img = cv2.imread(str(arq))
2149
+ h, w = img.shape[:2]
2150
+ cy, ch = e["centro_y"] * h, e["altura"] * h
2151
+ cx, cw = e["centro_x"] * w, e["largura"] * w
2152
+ caixa = (int(max(0, cx - cw / 2 - 0.25 * ch)), int(max(0, cy - 0.75 * ch)),
2153
+ int(min(w, cx + cw / 2 + 0.25 * ch)), int(min(h, cy + 0.75 * ch)))
2154
+ c = cores_do_texto(img, caixa)
2155
+ if c:
2156
+ amostras_cor.append(c)
2157
+ if amostras_cor:
2158
+ papeis = papeis_das_cores(amostras_cor)
2159
+ textos.update(papeis)
2160
+ textos["contorno_escuro_pct"] = round(sum(c["contorno_escuro"] for c in amostras_cor) / len(amostras_cor), 3)
2161
+ textos["caixa_atras_pct"] = round(sum(c["fundo_liso"] for c in amostras_cor) / len(amostras_cor), 3)
2162
+ textos["amostras_de_cor"] = len(amostras_cor)
2163
+ alt1920 = textos.get("altura_linha_padrao")
2164
+ if alt1920:
2165
+ textos["fonte_estimada_padrao"] = int(round(alt1920 * 1.05))
2166
+ else:
2167
+ faltas.append("Texto: leitura de texto desligada (--sem-texto).")
2168
+ if textos is None and not args.sem_texto:
2169
+ faltas.append("Texto: a leitura de texto não rodou; legenda sem medida.")
2170
+
2171
+ falar("7/8 áudio (volume, trilha, efeitos)...")
2172
+ audio = analisar_audio(video, dur, palavras, [c["t"] for c in cortes]) if ficha["tem_audio"] else {"tem_audio": False}
2173
+ if audio.get("tem_audio"):
2174
+ salvar_json(medidas / "audio.json", {k: v for k, v in audio.items() if k != "_por_segundo"})
2175
+ if not audio.get("estereo"):
2176
+ faltas.append("Áudio mono: sem o canal lateral, trilha e efeitos ficam menos confiáveis.")
2177
+ if palavras is None:
2178
+ faltas.append("Áudio: sem transcrição, o nível entre as frases foi estimado pelos trechos mais baixos.")
2179
+
2180
+ falar("8/8 folhas de quadros, recortes, trechos medidos, regras e guia...")
2181
+ sons = (audio.get("efeitos") or {}).get("lista", []) if audio.get("tem_audio") else []
2182
+ folhas = montar_folhas(quadros, cortes, sons, saida / "folha-de-quadros", W, H, fonte, fonte_p)
2183
+ conf = montar_conferencia(cortes, extra, saida / "cortes-para-conferir.jpg", W, H, fonte_p)
2184
+ recortes = salvar_recortes(textos, quadros, saida / "recortes")
2185
+ papel_1s: dict[int, float] = {}
2186
+ if textos:
2187
+ for e in (json.loads((medidas / "texto_na_tela.json").read_text(encoding="utf-8")).get("amostras", [])):
2188
+ s = int(math.floor(e["t"]))
2189
+ papel_1s[s] = max(papel_1s.get(s, 0.0), float(e.get("papel", 0)))
2190
+ planos_bordas = [c["t"] for c in cortes]
2191
+ escrever_trechos_medidos(saida / ARQ_TRECHOS, dur, quadros, rostos_1s, cortes, planos_bordas, textos,
2192
+ audio, palavras, (tempos_q, notas_q), brilho_1s, papel_1s)
2193
+ regras = montar_regras(ficha, cortes, ritmo, ritmo_todos, enq, textos or {}, cores, audio, fala, args)
2194
+ salvar_json(saida / ARQ_REGRAS, regras)
2195
+ antigos = [n for n in NOMES_ANTIGOS.values() if (saida / n).exists()]
2196
+ if antigos:
2197
+ falar(f"Aviso: sobrou {' e '.join(antigos)} de uma medida antiga nesta pasta; os de agora são "
2198
+ f"{ARQ_TRECHOS} e {ARQ_REGRAS}.")
2199
+ arquivos = {
2200
+ "guia-de-estilo.md": "este guia",
2201
+ ARQ_REGRAS: "os mesmos números em formato de máquina, com sugestões para o edit_data.json e o HyperFrames",
2202
+ ARQ_TRECHOS: "um segundo por linha: plano, corte, o que aparece, o que está escrito na tela, som",
2203
+ "folha-de-quadros/": f"{len(folhas)} folha(s), 1 quadro por segundo, cortes e efeitos marcados",
2204
+ }
2205
+ if conf:
2206
+ arquivos["cortes-para-conferir*.jpg"] = "quadro antes e depois de cada corte achado"
2207
+ if recortes:
2208
+ arquivos["recortes/"] = f"{len(recortes)} recorte(s) de legenda e texto fixo em tamanho de trabalho"
2209
+ arquivos["medidas/"] = "dados brutos: nota de cena por quadro, cortes, áudio, texto lido, transcrição"
2210
+ escrever_guia(saida / "guia-de-estilo.md", ficha, cortes, ritmo, ritmo_todos, enq, textos, cores, audio,
2211
+ fala, regras, arquivos, args, faltas)
2212
+ except ErroMedida as exc:
2213
+ print(f"erro: {exc}", file=sys.stderr)
2214
+ return 1
2215
+
2216
+ seg = time.time() - inicio
2217
+ falar("")
2218
+ falar(f"Pronto em {br(seg, 0)} s. Pasta: {saida}")
2219
+ falar(f"Cortes: {ritmo['cortes']} ({br(ritmo['cortes_por_minuto'])} por minuto, plano médio {br(ritmo['plano_medio_s'], 2)} s)"
2220
+ + (f"; mais {len(cortes) - ritmo['cortes']} provável(is) para conferir" if len(cortes) > ritmo["cortes"] else ""))
2221
+ if textos and textos.get("tem_legenda"):
2222
+ falar(f"Legenda: centro em {pct(textos['centro_y'])} da altura, {br(textos['palavras_por_linha_mediana'], 0)} palavra(s) por linha, "
2223
+ f"linha de {textos['altura_linha_padrao']} px na tela padrão {textos['tela_padrao']}")
2224
+ if audio.get("tem_audio"):
2225
+ falar(f"Áudio: {br(audio['loudness'].get('integrado_lufs'))} LUFS; {audio['trilha']['veredito']}; "
2226
+ f"{audio['efeitos']['provaveis']} efeito(s) provável(is)")
2227
+ falar("Agora olhe a folha de quadros e os cortes para conferir, e complete a seção 8 do guia.")
2228
+ return 0
2229
+
2230
+
2231
+ if __name__ == "__main__":
2232
+ sys.exit(main())