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,234 @@
1
+ """Every check of a rendered cut at once: speech, breaths, edges/levels and the junction sheet.
2
+
3
+ Run one after the other these took ~32 s per round on a 2:20 cut (transcription 11 s, verify
4
+ 8 s, junction sheet 13 s). They do not depend on each other, so they run side by side and the
5
+ round costs about as much as the slowest one:
6
+
7
+ A. transcribe.py cut.mp4 (the speech of the cut, printed trecho by trecho next to its quote)
8
+ B. respiros.py cut.mp4 (every silence of 0.25 s or more still in the cut: the André rule is none)
9
+ C. verify_cut.py --sem-aprovacao (edges in speech, clicks, repeats, levels)
10
+ D. contact_sheet.py --junctions
11
+
12
+ Output: each check's own report, in that order, and then the speech of the cut: for every trecho of
13
+ edl_manual.json (consecutive ranges with the same beat and quote), what was written in `quote` and what the
14
+ cut says, in short phrases with the time each starts in cut.mp4. A trecho whose words differ from its quote is
15
+ marked "≠ confira" with the words missing and the words in excess. The same speech goes to
16
+ <edit>/fala_do_corte.md.
17
+
18
+ A breath found by B is not an error of the tools: split the range there in edl_manual.json or rerun
19
+ corte_por_onda.py with a higher --limiar (references/corte.md). A silence inside a range kept whole on purpose
20
+ ("dry": false) is listed apart and does not count.
21
+
22
+ Usage:
23
+ uv run --directory '<SKILL>' python helpers/conferir_corte.py '<EDIT>' [--prompt 'Hubla, Kiwify'] [--min-silence 0.7]
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import difflib
30
+ import json
31
+ import re
32
+ import subprocess
33
+ import sys
34
+ import time
35
+ import unicodedata
36
+ from concurrent.futures import ThreadPoolExecutor
37
+ from pathlib import Path
38
+
39
+ HELPERS = Path(__file__).resolve().parent
40
+ SKILL = HELPERS.parent
41
+ sys.path.insert(0, str(HELPERS))
42
+ from common import fmt_time, load_json # noqa: E402
43
+ from pack_transcripts import cut_phrases, group_into_phrases, render_cut_markdown, split_long # noqa: E402
44
+
45
+
46
+ def run(cmd: list[str]) -> tuple[int, str, float]:
47
+ t0 = time.monotonic()
48
+ proc = subprocess.run([sys.executable, *cmd], capture_output=True, text=True, cwd=HELPERS.parent)
49
+ return proc.returncode, (proc.stdout + proc.stderr).strip(), time.monotonic() - t0
50
+
51
+
52
+ def cmd(helper: str, *args: str) -> str:
53
+ """A command the agent can paste from any folder (the skill's own Python, by --directory)."""
54
+ return f"uv run --directory '{SKILL}' python helpers/{helper} " + " ".join(args)
55
+
56
+
57
+ # ------------------------------------------------------------------ speech of the cut, trecho by trecho
58
+
59
+ def _norm_words(text: str) -> list[str]:
60
+ t = unicodedata.normalize("NFKD", (text or "").lower())
61
+ t = "".join(c for c in t if not unicodedata.combining(c))
62
+ return re.findall(r"[a-z0-9]+", t)
63
+
64
+
65
+ def trechos(timeline: dict, edl: dict) -> list[dict]:
66
+ """Consecutive segments with the same beat and quote are one trecho of edl_manual.json (corte_por_onda
67
+ splits a range at its breaths and copies beat and quote to every piece)."""
68
+ dry = {r.get("id"): not r.get("dry", True) for r in (edl or {}).get("ranges", [])}
69
+ out: list[dict] = []
70
+ for s in timeline.get("segments", []):
71
+ key = (s.get("beat") or "", s.get("quote") or "")
72
+ a0 = float(s.get("audio_out_start", s["out_start"]))
73
+ if out and out[-1]["key"] == key and not dry.get(s.get("id")) and not out[-1]["dry"]:
74
+ out[-1]["fim"] = float(s["out_end"])
75
+ out[-1]["ids"].append(s.get("id"))
76
+ continue
77
+ out.append({"key": key, "beat": key[0], "quote": key[1], "inicio": float(s["out_start"]), "audio": a0,
78
+ "fim": float(s["out_end"]), "ids": [s.get("id")], "dry": bool(dry.get(s.get("id")))})
79
+ return out
80
+
81
+
82
+ def palavras_por_trecho(words: list[dict], grupos: list[dict]) -> list[list[dict]]:
83
+ """A word goes to the trecho whose audio has started when the word starts (the J-cut lead starts the audio of
84
+ a trecho a few frames before its picture)."""
85
+ starts = [g["audio"] for g in grupos]
86
+ out: list[list[dict]] = [[] for _ in grupos]
87
+ for w in words:
88
+ k = 0
89
+ for i, a in enumerate(starts):
90
+ if float(w["start"]) >= a - 0.02:
91
+ k = i
92
+ out[k].append(w)
93
+ return out
94
+
95
+
96
+ def diferenca(quote: str, ouvido: str) -> tuple[list[str], list[str]]:
97
+ """(words of the quote missing in the cut, words of the cut not in the quote), accents and case ignored."""
98
+ a, b = _norm_words(quote), _norm_words(ouvido)
99
+ falta, sobra = [], []
100
+ for op, i1, i2, j1, j2 in difflib.SequenceMatcher(a=a, b=b, autojunk=False).get_opcodes():
101
+ if op in ("delete", "replace"):
102
+ falta += a[i1:i2]
103
+ if op in ("insert", "replace"):
104
+ sobra += b[j1:j2]
105
+ return falta, sobra
106
+
107
+
108
+ def fala_do_corte(edit: Path, video: Path) -> list[str]:
109
+ data = load_json(edit / "transcripts" / f"{video.stem}.json")
110
+ if not isinstance(data, dict):
111
+ return ["(sem a transcrição do corte: veja o erro do transcribe acima)"]
112
+ words = sorted(data.get("words", []), key=lambda w: w["start"])
113
+ try:
114
+ (edit / "fala_do_corte.md").write_text(render_cut_markdown(data, cut_phrases(data)), encoding="utf-8")
115
+ except OSError:
116
+ pass
117
+ timeline = load_json(edit / f"{video.stem}_timeline.json") or {}
118
+ grupos = trechos(timeline, load_json(edit / "edl.json") or {}) if timeline.get("segments") else []
119
+ if not grupos or not any(g["quote"] for g in grupos):
120
+ linhas = ["(sem o quote dos trechos: só a fala, com o tempo de começo de cada frase)"]
121
+ return linhas + [f" {fmt_time(p['start'])} {p['text']}" for p in cut_phrases(data)]
122
+ linhas, n_dif = [], 0
123
+ for g, ws in zip(grupos, palavras_por_trecho(words, grupos)):
124
+ ouvido = " ".join(w["text"] for w in ws)
125
+ marca = ""
126
+ if g["dry"]:
127
+ marca = " (dry: false, sem comparar)"
128
+ elif g["quote"]:
129
+ falta, sobra = diferenca(g["quote"], ouvido)
130
+ if falta or sobra:
131
+ n_dif += 1
132
+ partes = ([f'falta "{" ".join(falta)}"'] if falta else []) + \
133
+ ([f'a mais "{" ".join(sobra)}"'] if sobra else [])
134
+ marca = " ≠ confira: " + ", ".join(partes)
135
+ titulo = f"{g['beat']} · " if g["beat"] else ""
136
+ linhas.append(f"{fmt_time(g['inicio'])} {titulo}escrito: \"{g['quote']}\"{marca}")
137
+ corte = [f["words"] for f in split_long(group_into_phrases(ws, 0.3))] if ws else []
138
+ if not corte:
139
+ linhas.append(" (nada falado)")
140
+ for c in corte:
141
+ linhas.append(f" {fmt_time(float(c[0]['start']))} {' '.join(w['text'] for w in c)}")
142
+ resumo = (f"{n_dif} trecho(s) com palavras diferentes do quote: ouça esses pontos (borda mordendo palavra, "
143
+ f"resto de tomada, ou só o quote escrito de outro jeito)." if n_dif else
144
+ "A fala bate com o quote de todos os trechos (acento e pontuação à parte).")
145
+ return linhas + ["", resumo]
146
+
147
+
148
+ # ------------------------------------------------------------------ breaths, minus the ranges kept whole
149
+
150
+ def respiros_do_corte(out: str, edit: Path, video: Path) -> tuple[list[str], int]:
151
+ """Report of respiros.py --json with the silences inside a "dry": false range listed apart."""
152
+ try:
153
+ rep = json.loads(out.strip().splitlines()[-1])
154
+ except (json.JSONDecodeError, IndexError):
155
+ return [out], -1
156
+ edl = load_json(edit / "edl.json") or {}
157
+ dry_ids = {r.get("id") for r in edl.get("ranges", []) if not r.get("dry", True)}
158
+ segs = [s for s in (load_json(edit / f"{video.stem}_timeline.json") or {}).get("segments", [])
159
+ if s.get("id") in dry_ids]
160
+ conta, fica = [], []
161
+ for r in rep.get("respiros", []):
162
+ dentro = next((s for s in segs if r["de"] >= float(s["out_start"]) - 0.05
163
+ and r["ate"] <= float(s["out_end"]) + 0.05), None)
164
+ (fica if dentro else conta).append((r, dentro))
165
+ th, mn = rep.get("limiar_db"), rep.get("min_s", 0.25)
166
+ linhas = []
167
+ if not conta:
168
+ linhas.append(f"respiros: nenhum silêncio de {mn:.2f} s ou mais (abaixo de {th:.0f} dB)")
169
+ else:
170
+ linhas.append(f"respiros: {len(conta)} silêncio(s) de {mn:.2f} s ou mais (abaixo de {th:.0f} dB):")
171
+ for r, _ in conta:
172
+ linhas.append(f" {fmt_time(r['de'])} a {fmt_time(r['ate'])} ({r['dur']:.2f} s)")
173
+ linhas.append(" cada um é respiro que sobrou: parta o trecho ali no edl_manual.json ou rode o corte pela onda "
174
+ "com --limiar uns 3 dB mais alto que o da saída dele (references/corte.md)")
175
+ for r, s in fica:
176
+ linhas.append(f" fica: {fmt_time(r['de'])} a {fmt_time(r['ate'])} ({r['dur']:.2f} s) dentro do trecho "
177
+ f"{s.get('id')} com \"dry\": false (\"{(s.get('quote') or '')[:40]}\")")
178
+ return linhas, len(conta)
179
+
180
+
181
+ def main() -> int:
182
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
183
+ ap.add_argument("edit_dir", type=Path)
184
+ ap.add_argument("--video", default="cut.mp4", help="nome do corte dentro da pasta (padrão cut.mp4)")
185
+ ap.add_argument("--prompt", default=None, help="nomes que aparecem na fala, como no transcribe.py")
186
+ ap.add_argument("--min-silence", type=float, default=None, help="repassado ao verify_cut.py")
187
+ args = ap.parse_args()
188
+
189
+ edit = args.edit_dir.resolve()
190
+ video, edl = edit / args.video, edit / "edl.json"
191
+ for p in (video, edl):
192
+ if not p.exists():
193
+ print(f"erro: não achei {p}", file=sys.stderr)
194
+ return 2
195
+
196
+ h = lambda name: str(HELPERS / name) # noqa: E731
197
+ tr = [h("transcribe.py"), str(video), "--edit-dir", str(edit)]
198
+ if args.prompt:
199
+ tr += ["--prompt", args.prompt]
200
+ vf = [h("verify_cut.py"), str(edl), str(video), "--sem-aprovacao"]
201
+ if args.min_silence is not None:
202
+ vf += ["--min-silence", str(args.min_silence)]
203
+
204
+ t0 = time.monotonic()
205
+ with ThreadPoolExecutor(max_workers=4) as pool:
206
+ jobs = {"Transcrição do corte (transcribe)": pool.submit(run, tr),
207
+ "Respiros que sobraram (respiros)": pool.submit(run, [h("respiros.py"), str(video), "--json"]),
208
+ "Bordas, volume e silêncio (verify_cut)": pool.submit(run, vf),
209
+ "Folha das junções (contact_sheet)": pool.submit(run, [h("contact_sheet.py"), str(video), "--junctions"])}
210
+ failed, breaths = False, 0
211
+ for title, fut in jobs.items():
212
+ code, out, dt = fut.result()
213
+ if title.startswith("Respiros") and code in (0, 1):
214
+ linhas, breaths = respiros_do_corte(out, edit, video)
215
+ failed |= breaths < 0
216
+ breaths = max(0, breaths)
217
+ out = "\n".join(linhas)
218
+ code = 0
219
+ else:
220
+ failed |= code != 0
221
+ print(f"=== {title} · {dt:.0f} s{'' if code == 0 else f' · ERRO (saída {code})'}\n{out}\n")
222
+ print("=== Fala do corte, trecho a trecho (escrito = quote do edl; embaixo, o que o corte diz, com o tempo)")
223
+ print("\n".join(fala_do_corte(edit, video)) + "\n")
224
+ passos = ["leia a fala do corte acima contra o quote de cada trecho"]
225
+ if breaths:
226
+ passos.append("tire os respiros que sobraram (" + cmd("corte_por_onda.py", f"'{edit / 'edl_manual.json'}'",
227
+ "-o", f"'{edl}'", "--limiar <3 dB acima>") + ")")
228
+ passos.append("depois meça o rosto e mostre o corte no estúdio (references/corte.md, passo 7)")
229
+ print(f"Tudo conferido em {time.monotonic() - t0:.0f} s. Agora: " + "; ".join(passos) + ".")
230
+ return 1 if failed or breaths else 0
231
+
232
+
233
+ if __name__ == "__main__":
234
+ sys.exit(main())
@@ -0,0 +1,405 @@
1
+ """Personal data still readable in an exported video: every frame (or 4 per second) read by OCR.
2
+
3
+ A blur in the project is a layer on top: a frame without it (the first frame, one frame before the blur comes
4
+ in, a menu that opens for 0.15 s, a line that moved) shows the data, and a contact sheet with one frame per
5
+ second does not catch it. Seen on 29/09/2026: 4 leaks in a draft that the contact sheet did not show.
6
+
7
+ This helper reads the video frame by frame and looks for:
8
+
9
+ termo the words given with --termos / --termos-arquivo (names of people, of chats, of projects, a
10
+ company, an address), accents and case ignored, tolerant to one OCR slip in long words;
11
+ e-mail anything@anything.tld
12
+ cpf / cnpj formatted, or 11 digits with valid check digits
13
+ telefone (11) 91234-5678, 11 91234 5678, +55 ...
14
+ cartao 4 groups of 4 digits that pass the card check (Luhn)
15
+ perfil @handle (Instagram, TikTok)
16
+ nome? a common Brazilian first name followed by a capitalized word ("Maria Souza"): a guess, confira
17
+
18
+ Every frame is read by default up to 3 minutes of video (a leak can last one frame); longer videos are
19
+ sampled at 4 frames per second unless --todos is given. It says, for each finding, the text read, the time and the frame (from and to) and where it is in the frame
20
+ (x, y, width, height in pixels of the video), so the blur can be placed or its window widened.
21
+
22
+ Reader: Apple Vision on macOS (the same compiled reader as texto_na_tela.py); Tesseract on other systems when
23
+ it is installed (`tesseract` in the PATH, with the Portuguese language). Without a reader it stops and says
24
+ how to check by eye.
25
+
26
+ Usage:
27
+ uv run --directory '<SKILL>' python helpers/conferir_dados.py '<VIDEO>' [--termos 'Maria Souza, Loja X']
28
+ [--termos-arquivo termos.txt] [--ignorar 'André, @andre'] [--todos | --fps 4] [--de S --ate S]
29
+ [--sem-padroes] [--json relatorio.json]
30
+ Exit code 0 when nothing was found, 1 when something was found, 2 on error.
31
+ A clean result is not proof: OCR misses tiny, blurred or rotated text. Look at what it cannot read.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import difflib
38
+ import json
39
+ import math
40
+ import re
41
+ import shutil
42
+ import subprocess
43
+ import sys
44
+ import tempfile
45
+ import time
46
+ import unicodedata
47
+ from pathlib import Path
48
+
49
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
50
+ from common import fmt_time, probe_video, require_binary # noqa: E402
51
+ import texto_na_tela as tnt # noqa: E402
52
+
53
+ FPS_PADRAO = 4.0 # frames per second read when the video is long (or with --fps)
54
+ TODOS_ATE_S = 180.0 # up to this length every frame is read by default (a leak can last 1 frame)
55
+ LOTE = 200 # frames read per batch (progress and temp disk)
56
+ LADO_MAX = 1920 # OCR on the video size, capped on the long side
57
+ CONF_MIN = 0.30
58
+ FUZZY = 0.86 # similarity for a term of 6+ letters read with a slip
59
+
60
+ # common Brazilian first names (IBGE 2010 top names and frequent ones); a name + a capitalized word is flagged
61
+ NOMES = """
62
+ ana maria jose joao antonio francisco carlos paulo pedro lucas luiz marcos luis gabriel rafael daniel marcelo
63
+ bruno eduardo felipe raimundo rodrigo manoel mateus andre fernando fabio leonardo gustavo guilherme leandro
64
+ tiago thiago anderson ricardo jorge alexandre roberto edson diego vitor victor sergio claudio matheus thiago
65
+ julio juliana adriana marcia fernanda patricia aline sandra camila amanda bruna jessica leticia julia luciana
66
+ vanessa mariana gabriela vera vitoria larissa claudia beatriz rita luana sonia renata eliane josefa simone
67
+ natalia cristiane carla debora rosangela jaqueline rosa daniela aparecida marlene terezinha raimunda andreia
68
+ fabiana lucia raquel angela rafaela joana luzia isabel cleide tatiane priscila carolina vivian viviane helena
69
+ alice laura manuela valentina sophia sofia heloisa isadora lorena livia cecilia elisa yasmin bianca giovanna
70
+ enzo arthur davi heitor bernardo samuel murilo miguel henrique caio igor otavio vinicius kaique joaquim renan
71
+ """.split()
72
+ NOMES_SET = set(NOMES)
73
+
74
+ # "a@b.com" or, as the OCR sometimes spaces it, "a @ b.com" (never "segue a @perfil"); a real ending (.com, .br)
75
+ RE_EMAIL = re.compile(r"[\w.+-]{2,}(?:@| @ )[\w-]+(?:\.[\w-]+)*\.[a-zA-Z]{2,6}(?![\w])")
76
+ RE_CPF_FMT = re.compile(r"(?<!\d)\d{3}\.\s?\d{3}\.\s?\d{3}\s?-\s?\d{2}(?!\d)")
77
+ RE_ONZE = re.compile(r"(?<![\d.])\d{11}(?![\d.])")
78
+ RE_CNPJ = re.compile(r"(?<!\d)\d{2}\.?\d{3}\.?\d{3}\s?/\s?\d{4}\s?-?\s?\d{2}(?!\d)")
79
+ RE_FONE = re.compile(r"(?<![\d/.])(?:\+?\s?55\s?)?(?:\(\s?\d{2}\s?\)|\d{2})\s?9?\s?\d{4}\s?[-.\s]\s?\d{4}(?![\d/])")
80
+ RE_CARTAO = re.compile(r"(?<!\d)(?:\d{4}[ -]){3}\d{4}(?!\d)")
81
+ RE_PERFIL = re.compile(r"(?<![\w@.])@[A-Za-z0-9_.]{3,30}")
82
+ RE_NOME = re.compile(r"\b([A-ZÁÉÍÓÚÂÊÔÃÕÇ][a-záéíóúâêôãõç]+)\s+(?:(?:d[aeo]s?)\s+)?([A-ZÁÉÍÓÚÂÊÔÃÕÇ][a-záéíóúâêôãõç]+)")
83
+
84
+
85
+ def norm(texto: str) -> str:
86
+ t = unicodedata.normalize("NFKD", (texto or "").lower())
87
+ t = "".join(c for c in t if not unicodedata.combining(c))
88
+ return re.sub(r"\s+", " ", re.sub(r"[^\w@.\- ]+", " ", t)).strip()
89
+
90
+
91
+ def cpf_valido(d: str) -> bool:
92
+ if len(d) != 11 or d == d[0] * 11:
93
+ return False
94
+ for n in (9, 10):
95
+ s = sum(int(d[i]) * (n + 1 - i) for i in range(n))
96
+ if (s * 10 % 11) % 10 != int(d[n]):
97
+ return False
98
+ return True
99
+
100
+
101
+ def luhn(d: str) -> bool:
102
+ tot, dobra = 0, False
103
+ for c in reversed(d):
104
+ v = int(c)
105
+ if dobra:
106
+ v = v * 2 - 9 if v * 2 > 9 else v * 2
107
+ tot += v
108
+ dobra = not dobra
109
+ return tot % 10 == 0
110
+
111
+
112
+ def termos_da_lista(texto: str) -> list[str]:
113
+ partes = re.split(r"[,;\n]", texto or "")
114
+ return [p.strip() for p in partes if p.strip()]
115
+
116
+
117
+ def achar_termo(linha_norm: str, termo_norm: str) -> bool:
118
+ if not termo_norm:
119
+ return False
120
+ if re.search(r"(?<![a-z0-9])" + re.escape(termo_norm) + r"(?![a-z0-9])", linha_norm):
121
+ return True
122
+ if len(termo_norm.replace(" ", "")) < 6:
123
+ return False
124
+ toks, n = linha_norm.split(), len(termo_norm.split())
125
+ for i in range(0, max(1, len(toks) - n + 1)):
126
+ jan = " ".join(toks[i:i + n])
127
+ if difflib.SequenceMatcher(a=jan, b=termo_norm).ratio() >= FUZZY:
128
+ return True
129
+ return False
130
+
131
+
132
+ def achados_na_linha(texto: str, termos: list[str], padroes: bool = True,
133
+ ignorar: list[str] | None = None) -> list[tuple[str, str]]:
134
+ """[(categoria, texto achado)] in one line read by the OCR."""
135
+ out: list[tuple[str, str]] = []
136
+ ln = norm(texto)
137
+ for t in termos:
138
+ if achar_termo(ln, norm(t)):
139
+ out.append(("termo", t))
140
+ if padroes:
141
+ for m in RE_EMAIL.finditer(texto):
142
+ out.append(("e-mail", re.sub(r"\s", "", m.group(0))))
143
+ for m in RE_CPF_FMT.finditer(texto):
144
+ out.append(("cpf", m.group(0)))
145
+ for m in RE_ONZE.finditer(texto):
146
+ if cpf_valido(m.group(0)):
147
+ out.append(("cpf", m.group(0)))
148
+ for m in RE_CNPJ.finditer(texto):
149
+ if "/" in m.group(0) or "." in m.group(0):
150
+ out.append(("cnpj", m.group(0)))
151
+ for m in RE_CARTAO.finditer(texto):
152
+ if luhn(re.sub(r"\D", "", m.group(0))):
153
+ out.append(("cartao", m.group(0)))
154
+ for m in RE_FONE.finditer(texto):
155
+ dig = re.sub(r"\D", "", m.group(0))
156
+ if 10 <= len(dig) <= 13 and not any(c == "cartao" and dig in re.sub(r"\D", "", x) for c, x in out):
157
+ out.append(("telefone", m.group(0).strip()))
158
+ emails = " ".join(x for c, x in out if c == "e-mail")
159
+ for m in RE_PERFIL.finditer(texto):
160
+ if m.group(0)[1:] not in emails:
161
+ out.append(("perfil", m.group(0)))
162
+ for m in RE_NOME.finditer(texto):
163
+ if norm(m.group(1)) in NOMES_SET:
164
+ out.append(("nome?", f"{m.group(1)} {m.group(2)}"))
165
+ if ignorar:
166
+ ig = [norm(x) for x in ignorar if x.strip()]
167
+ out = [(c, x) for c, x in out if not any(i and (i in norm(x) or norm(x) in i) for i in ig)]
168
+ vistos, uniq = set(), []
169
+ for c, x in out:
170
+ if (c, norm(x)) not in vistos:
171
+ vistos.add((c, norm(x)))
172
+ uniq.append((c, x))
173
+ return uniq
174
+
175
+
176
+ # ------------------------------------------------------------------ reading the frames
177
+
178
+ def leitor() -> tuple[str, object] | None:
179
+ """("vision", binary) on macOS, ("tesseract", path) elsewhere, None without any reader."""
180
+ b = tnt.compilar_vision()
181
+ if b:
182
+ return "vision", b
183
+ t = shutil.which("tesseract")
184
+ if t:
185
+ return "tesseract", t
186
+ return None
187
+
188
+
189
+ def parse_tesseract_tsv(tsv: str, largura: int, altura: int) -> list[dict]:
190
+ """Lines of a `tesseract ... tsv` output, in the same shape as the Vision reader (fractions, y from the bottom)."""
191
+ linhas: dict[tuple, dict] = {}
192
+ for row in tsv.splitlines()[1:]:
193
+ c = row.split("\t")
194
+ if len(c) < 12 or c[0] != "5" or not c[11].strip():
195
+ continue
196
+ key = (c[2], c[3], c[4])
197
+ x, y, w, h, conf = int(c[6]), int(c[7]), int(c[8]), int(c[9]), float(c[10])
198
+ ln = linhas.setdefault(key, {"t": [], "c": [], "x0": x, "y0": y, "x1": x + w, "y1": y + h})
199
+ ln["t"].append(c[11])
200
+ ln["c"].append(conf)
201
+ ln["x0"], ln["y0"] = min(ln["x0"], x), min(ln["y0"], y)
202
+ ln["x1"], ln["y1"] = max(ln["x1"], x + w), max(ln["y1"], y + h)
203
+ out = []
204
+ for ln in linhas.values():
205
+ out.append({"t": " ".join(ln["t"]), "c": max(0.0, min(ln["c"])) / 100.0,
206
+ "x": ln["x0"] / largura, "w": (ln["x1"] - ln["x0"]) / largura,
207
+ "y": 1 - ln["y1"] / altura, "h": (ln["y1"] - ln["y0"]) / altura})
208
+ return out
209
+
210
+
211
+ def ler_pasta(motor: tuple[str, object], pasta: Path) -> dict[str, list[dict]]:
212
+ if motor[0] == "vision":
213
+ saida = pasta / "ocr.json"
214
+ subprocess.run([str(motor[1]), str(pasta), str(saida)], capture_output=True, text=True, check=True)
215
+ return json.loads(saida.read_text())
216
+ out: dict[str, list[dict]] = {}
217
+ from PIL import Image # noqa: PLC0415
218
+ for img in sorted(pasta.glob("f_*.jpg")):
219
+ w, h = Image.open(img).size
220
+ tsv = ""
221
+ for lang in ("por", "eng"):
222
+ r = subprocess.run([str(motor[1]), str(img), "-", "-l", lang, "tsv"], capture_output=True, text=True)
223
+ if r.returncode == 0:
224
+ tsv = r.stdout
225
+ break
226
+ out[img.name] = parse_tesseract_tsv(tsv, w, h)
227
+ return out
228
+
229
+
230
+ def extrair(video: Path, pasta: Path, primeiro: int, n: int, rate: float, fps_video: float, amostra: bool,
231
+ escala: str) -> int:
232
+ """n frames as f_00001.jpg..., starting at index `primeiro` of the grid of `rate` per second (every frame of the
233
+ video when not `amostra`). Counted in frames, not in seconds: a batch cut by time repeated or skipped the frame
234
+ on its border and the frame numbers came out one off."""
235
+ t0 = max(0.0, primeiro / rate - 0.5 / fps_video) # half a frame early: the frame on the grid is the first
236
+ vf = ([f"fps={rate}"] if amostra else []) + ([escala] if escala else [])
237
+ cmd = ["ffmpeg", "-v", "error", "-y", "-ss", f"{t0:.4f}", "-i", str(video), "-frames:v", str(n), "-an"]
238
+ if vf:
239
+ cmd += ["-vf", ",".join(vf)]
240
+ if not amostra:
241
+ cmd += ["-fps_mode", "passthrough"]
242
+ cmd += ["-q:v", "2", str(pasta / "f_%05d.jpg")]
243
+ subprocess.run(cmd, capture_output=True, text=True, check=True)
244
+ return len(list(pasta.glob("f_*.jpg")))
245
+
246
+
247
+ def conferir(video: Path, termos: list[str], *, fps: float | None = FPS_PADRAO, de: float = 0.0,
248
+ ate: float | None = None, padroes: bool = True, ignorar: list[str] | None = None,
249
+ motor: tuple[str, object] | None = None, progresso=print) -> dict:
250
+ require_binary("ffmpeg")
251
+ info = probe_video(video)
252
+ W, H = int(info.get("display_width") or info["width"]), int(info.get("display_height") or info["height"])
253
+ fps_video = float(info.get("fps") or 30.0)
254
+ dur_total = float(info["duration"])
255
+ fim = min(dur_total, ate) if ate is not None else dur_total
256
+ motor = motor or leitor()
257
+ if motor is None:
258
+ raise RuntimeError("sem leitor de texto neste computador")
259
+ rate = fps or fps_video
260
+ escala = f"scale='if(gt(iw,ih),min(iw,{LADO_MAX}),-2)':'if(gt(iw,ih),-2,min(ih,{LADO_MAX}))'" \
261
+ if max(W, H) > LADO_MAX else ""
262
+ ocorr: list[dict] = []
263
+ idx = int(math.ceil(max(0.0, de) * rate - 1e-6))
264
+ ultimo = int(math.floor(fim * rate - 1e-6)) # the last frame starts before the end
265
+ total = max(0, ultimo - idx + 1)
266
+ lidos = 0
267
+ t_ini = time.monotonic()
268
+ while idx <= ultimo:
269
+ n_pedido = min(LOTE, ultimo - idx + 1)
270
+ with tempfile.TemporaryDirectory(prefix="conferir-dados-") as tmp:
271
+ pasta = Path(tmp)
272
+ n = extrair(video, pasta, idx, n_pedido, rate, fps_video, bool(fps), escala)
273
+ bruto = ler_pasta(motor, pasta) if n else {}
274
+ for k in range(n):
275
+ tq = (idx + k) / rate
276
+ for it in bruto.get(f"f_{k + 1:05d}.jpg", []):
277
+ if float(it.get("c", 1)) < CONF_MIN:
278
+ continue
279
+ for cat, achado in achados_na_linha(str(it.get("t", "")), termos, padroes, ignorar):
280
+ x, y, w, h = (float(it[c]) for c in ("x", "y", "w", "h"))
281
+ ocorr.append({"categoria": cat, "texto": achado, "linha": it.get("t", ""), "t": round(tq, 3),
282
+ "quadro": int(round(tq * fps_video)),
283
+ "caixa": [round(x * W), round((1 - y - h) * H), round(w * W), round(h * H)]})
284
+ lidos += n
285
+ progresso(f" lidos {lidos} de {total} quadros ({time.monotonic() - t_ini:.0f} s)")
286
+ idx += n_pedido
287
+ if n == 0:
288
+ break
289
+ return {"video": str(video), "largura": W, "altura": H, "fps_video": fps_video,
290
+ "amostragem": "todos os quadros" if not fps else f"{fps:g} por segundo", "leitor": motor[0],
291
+ "de": round(max(0.0, de), 3), "ate": round(fim, 3), "quadros_lidos": lidos,
292
+ "termos": termos, "padroes": padroes, "achados": juntar(ocorr, 1.0 / rate)}
293
+
294
+
295
+ def juntar(ocorr: list[dict], passo: float) -> list[dict]:
296
+ """Occurrences of the same text in consecutive frames become one finding with from/to."""
297
+ grupos: dict[tuple, list[dict]] = {}
298
+ for o in sorted(ocorr, key=lambda o: o["t"]):
299
+ key = (o["categoria"], norm(o["texto"]))
300
+ if key not in grupos: # the same text read with a slip on one frame ("(17)" for "(11)") joins it
301
+ key = next((k for k in grupos if k[0] == key[0]
302
+ and difflib.SequenceMatcher(a=k[1], b=key[1]).ratio() >= FUZZY), key)
303
+ grupos.setdefault(key, []).append(o)
304
+ out = []
305
+ for (cat, _), oc in grupos.items():
306
+ janelas: list[list[dict]] = []
307
+ for o in oc:
308
+ if janelas and o["t"] - janelas[-1][-1]["t"] <= passo * 1.6:
309
+ janelas[-1].append(o)
310
+ else:
311
+ janelas.append([o])
312
+ for j in janelas:
313
+ xs = [o["caixa"] for o in j]
314
+ x0, y0 = min(c[0] for c in xs), min(c[1] for c in xs)
315
+ x1, y1 = max(c[0] + c[2] for c in xs), max(c[1] + c[3] for c in xs)
316
+ out.append({"categoria": cat, "texto": j[0]["texto"], "linha": j[0]["linha"],
317
+ "de": j[0]["t"], "ate": j[-1]["t"], "quadro_de": j[0]["quadro"], "quadro_ate": j[-1]["quadro"],
318
+ "vezes": len(j), "caixa": [x0, y0, x1 - x0, y1 - y0]})
319
+ return sorted(out, key=lambda a: (a["de"], a["categoria"]))
320
+
321
+
322
+ NOME_CAT = {"termo": "termo", "e-mail": "e-mail", "cpf": "CPF", "cnpj": "CNPJ", "telefone": "telefone",
323
+ "cartao": "cartão", "perfil": "perfil (@)", "nome?": "possível nome"}
324
+
325
+
326
+ def relatorio(rep: dict) -> list[str]:
327
+ ach = rep["achados"]
328
+ linhas = [f"conferir_dados: {rep['quadros_lidos']} quadros lidos ({rep['amostragem']}) de "
329
+ f"{Path(rep['video']).name}, {fmt_time(rep['de'])} a {fmt_time(rep['ate'])}, leitor {rep['leitor']} · "
330
+ f"{len(ach)} achado(s)"]
331
+ for a in ach:
332
+ quando = (f"{fmt_time(a['de'])} (quadro {a['quadro_de']})" if a["quadro_de"] == a["quadro_ate"] else
333
+ f"{fmt_time(a['de'])} a {fmt_time(a['ate'])} (quadros {a['quadro_de']} a {a['quadro_ate']})")
334
+ x, y, w, h = a["caixa"]
335
+ extra = " confira se pode aparecer" if a["categoria"] == "nome?" else ""
336
+ linhas.append(f" {NOME_CAT.get(a['categoria'], a['categoria'])} \"{a['texto']}\": {quando}, "
337
+ f"caixa x {x} y {y} largura {w} altura {h}{extra}")
338
+ if norm(a["linha"]) != norm(a["texto"]):
339
+ linhas.append(f" linha lida: \"{a['linha'][:90]}\"")
340
+ if ach and rep["amostragem"] != "todos os quadros":
341
+ linhas.append(" Achou com amostra: rode com --todos em volta de cada achado (--de/--ate) para achar o "
342
+ "primeiro e o último quadro antes de alargar o borrão.")
343
+ linhas.append(" Nada achado não prova que não tem dado: o leitor erra letra pequena, borrada, girada ou em "
344
+ "movimento. Olhe também o primeiro e o último quadro e cada troca de tela.")
345
+ return linhas
346
+
347
+
348
+ def main(argv: list[str] | None = None) -> int:
349
+ tnt.utf8_console()
350
+ ap = argparse.ArgumentParser(description="Procura dado pessoal (nomes, e-mail, CPF, telefone e termos dados) "
351
+ "em todos os quadros de um vídeo exportado.")
352
+ ap.add_argument("video", type=Path)
353
+ ap.add_argument("--termos", default="", help="nomes e palavras que não podem aparecer, separados por vírgula")
354
+ ap.add_argument("--termos-arquivo", type=Path, help="arquivo com um termo por linha")
355
+ ap.add_argument("--ignorar", default="", help="termos que podem aparecer (o nome da própria pessoa, a marca)")
356
+ ap.add_argument("--fps", type=float, default=None,
357
+ help=f"lê só N quadros por segundo (padrão: todos até {TODOS_ATE_S / 60:.0f} min de vídeo, "
358
+ f"{FPS_PADRAO:g} por segundo nos mais longos)")
359
+ ap.add_argument("--todos", action="store_true", help="lê todos os quadros mesmo num vídeo longo")
360
+ ap.add_argument("--de", type=float, default=0.0, help="começa neste segundo")
361
+ ap.add_argument("--ate", type=float, help="termina neste segundo")
362
+ ap.add_argument("--sem-padroes", action="store_true", help="só os termos dados (sem e-mail, CPF, telefone, nomes...)")
363
+ ap.add_argument("--json", type=Path, help="grava o relatório completo neste arquivo")
364
+ a = ap.parse_args(argv)
365
+ video = a.video.expanduser().absolute()
366
+ if not video.is_file():
367
+ print(f"ERRO: não achei {video}", file=sys.stderr)
368
+ return 2
369
+ termos = termos_da_lista(a.termos)
370
+ if a.termos_arquivo:
371
+ if not a.termos_arquivo.is_file():
372
+ print(f"ERRO: não achei {a.termos_arquivo}", file=sys.stderr)
373
+ return 2
374
+ termos += termos_da_lista(a.termos_arquivo.read_text(encoding="utf-8"))
375
+ if a.sem_padroes and not termos:
376
+ print("ERRO: com --sem-padroes, passe os --termos a procurar", file=sys.stderr)
377
+ return 2
378
+ motor = leitor()
379
+ if motor is None:
380
+ print("ERRO: sem leitor de texto neste computador. No Mac, instale as ferramentas da Apple "
381
+ "(xcode-select --install, traz o swiftc) e rode de novo. No Windows ou Linux, instale o Tesseract com o "
382
+ "idioma português (tesseract no PATH). Sem leitor, confira olhando: tira de quadros a 4 por segundo com "
383
+ "helpers/tira_de_quadros.py e cada troca de tela quadro a quadro.", file=sys.stderr)
384
+ return 2
385
+ fps = a.fps
386
+ if fps is None and not a.todos:
387
+ trecho = (a.ate if a.ate is not None else float(probe_video(video)["duration"])) - a.de
388
+ if trecho > TODOS_ATE_S:
389
+ fps = FPS_PADRAO
390
+ print(f"vídeo de {trecho / 60:.1f} min: lendo {FPS_PADRAO:g} quadros por segundo (--todos lê todos)")
391
+ try:
392
+ rep = conferir(video, termos, fps=fps, de=a.de, ate=a.ate, padroes=not a.sem_padroes,
393
+ ignorar=termos_da_lista(a.ignorar), motor=motor, progresso=lambda m: print(m, flush=True))
394
+ except (subprocess.CalledProcessError, RuntimeError, KeyError, ValueError) as exc:
395
+ print(f"ERRO: não consegui ler {video.name}: {exc}", file=sys.stderr)
396
+ return 2
397
+ print("\n".join(relatorio(rep)))
398
+ if a.json:
399
+ a.json.write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
400
+ print(f"relatório: {a.json}")
401
+ return 1 if rep["achados"] else 0
402
+
403
+
404
+ if __name__ == "__main__":
405
+ raise SystemExit(main())