primocode 10.15.2 → 10.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/edit-ia-pro.txt +32 -0
- package/LICENSES/fontes-OFL.txt +476 -0
- package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
- package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
- package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
- package/LICENSES/three-MIT.txt +23 -0
- package/LICENSES/video-use-MIT.txt +24 -0
- package/LICENSES/woven-sfx-CC0.txt +16 -0
- package/LICENSES/yunet-MIT.txt +21 -0
- package/README.md +2 -2
- package/bin/primocode.js +74 -9
- package/editor/LICENSE.md +11 -0
- package/editor/README.md +321 -0
- package/editor/SKILL.md +307 -0
- package/editor/THIRD_PARTY_NOTICES.md +228 -0
- package/editor/assets/baixar.json +591 -0
- package/editor/assets/fonts/anton/OFL.txt +93 -0
- package/editor/assets/fonts/caveat/OFL.txt +93 -0
- package/editor/assets/fonts/figtree/OFL.txt +93 -0
- package/editor/assets/fonts/fonts.json +10 -0
- package/editor/assets/fonts/montserrat/OFL.txt +93 -0
- package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
- package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
- package/editor/assets/models/YUNET_LICENSE.txt +21 -0
- package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
- package/editor/assets/sfx/licencas/cash.txt +76 -0
- package/editor/assets/sfx/licencas/charge.txt +31 -0
- package/editor/assets/sfx/licencas/click.txt +46 -0
- package/editor/assets/sfx/licencas/coin.txt +50 -0
- package/editor/assets/sfx/licencas/count.txt +47 -0
- package/editor/assets/sfx/licencas/data.txt +31 -0
- package/editor/assets/sfx/licencas/error.txt +32 -0
- package/editor/assets/sfx/licencas/flash.txt +31 -0
- package/editor/assets/sfx/licencas/gears.txt +82 -0
- package/editor/assets/sfx/licencas/keyboard.txt +48 -0
- package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
- package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
- package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
- package/editor/assets/sfx/licencas/notification.txt +54 -0
- package/editor/assets/sfx/licencas/pop.txt +38 -0
- package/editor/assets/sfx/licencas/rewind.txt +32 -0
- package/editor/assets/sfx/licencas/rise.txt +46 -0
- package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
- package/editor/assets/sfx/licencas/snap.txt +50 -0
- package/editor/assets/sfx/licencas/success.txt +40 -0
- package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
- package/editor/assets/sfx/licencas/tick.txt +14 -0
- package/editor/assets/sfx/licencas/whoosh.txt +47 -0
- package/editor/assets/sfx/licencas/wind.txt +31 -0
- package/editor/assets/sfx/sfx.json +832 -0
- package/editor/assets/sfx/sfx_sinteticos.json +317 -0
- package/editor/assets/sfx/sfx_woven.json +387 -0
- package/editor/assets/sfx/woven/LICENCA.md +16 -0
- package/editor/fichas/briefing-do-video.md +45 -0
- package/editor/fichas/conferencia-da-amostra.md +56 -0
- package/editor/fichas/matriz-de-ganchos.csv +3 -0
- package/editor/fichas/padrao.json +16 -0
- package/editor/fichas/planilha-de-cenas.csv +4 -0
- package/editor/fichas/planilha-de-versoes.csv +4 -0
- package/editor/fichas/plano-em-blocos.md +32 -0
- package/editor/fichas/registro-de-materiais.json +27 -0
- package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
- package/editor/helpers/bordas_precisas.py +272 -0
- package/editor/helpers/camera_viva.py +426 -0
- package/editor/helpers/captions.py +601 -0
- package/editor/helpers/common.py +308 -0
- package/editor/helpers/compose_final.py +3555 -0
- package/editor/helpers/conferir_corte.py +234 -0
- package/editor/helpers/conferir_dados.py +405 -0
- package/editor/helpers/contact_sheet.py +282 -0
- package/editor/helpers/corte_por_onda.py +433 -0
- package/editor/helpers/corte_seco.py +595 -0
- package/editor/helpers/denoise.py +971 -0
- package/editor/helpers/efeitos.py +408 -0
- package/editor/helpers/efeitos_gerar.py +159 -0
- package/editor/helpers/elevenlabs_api.py +442 -0
- package/editor/helpers/face_layout.py +570 -0
- package/editor/helpers/fetch_images.py +423 -0
- package/editor/helpers/ganho_por_trecho.py +214 -0
- package/editor/helpers/gen_image.py +312 -0
- package/editor/helpers/gen_music.py +316 -0
- package/editor/helpers/gen_sfx.py +148 -0
- package/editor/helpers/grade.py +307 -0
- package/editor/helpers/icloud.py +119 -0
- package/editor/helpers/make_sfx.py +940 -0
- package/editor/helpers/matte.py +1342 -0
- package/editor/helpers/medir_referencia.py +2232 -0
- package/editor/helpers/mixar_trilha.py +354 -0
- package/editor/helpers/montar_corte.py +295 -0
- package/editor/helpers/motion/__init__.py +13 -0
- package/editor/helpers/motion/engine.py +1915 -0
- package/editor/helpers/motion/registry.py +510 -0
- package/editor/helpers/motion/render.py +665 -0
- package/editor/helpers/motion/templates/barra_progresso.py +370 -0
- package/editor/helpers/motion/templates/carimbo.py +132 -0
- package/editor/helpers/motion/templates/checklist.py +271 -0
- package/editor/helpers/motion/templates/comentario_cta.py +380 -0
- package/editor/helpers/motion/templates/contador.py +222 -0
- package/editor/helpers/motion/templates/destaque.py +263 -0
- package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
- package/editor/helpers/motion/templates/grafico_barras.py +510 -0
- package/editor/helpers/motion/templates/grafico_linha.py +462 -0
- package/editor/helpers/motion/templates/notificacao.py +266 -0
- package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
- package/editor/helpers/motion/templates/passos.py +321 -0
- package/editor/helpers/motion/templates/riscado.py +298 -0
- package/editor/helpers/motion/templates/selo_garantia.py +150 -0
- package/editor/helpers/motion_render.py +241 -0
- package/editor/helpers/motion_sugerir.py +961 -0
- package/editor/helpers/noise_check.py +134 -0
- package/editor/helpers/pack_transcripts.py +223 -0
- package/editor/helpers/palavras_na_onda.py +176 -0
- package/editor/helpers/plano_sfx.py +839 -0
- package/editor/helpers/portao.py +427 -0
- package/editor/helpers/preview_server.py +1177 -0
- package/editor/helpers/rastrear_rosto.py +298 -0
- package/editor/helpers/render_cut.py +1256 -0
- package/editor/helpers/respiros.py +89 -0
- package/editor/helpers/sincronizar_cameras.py +571 -0
- package/editor/helpers/speech_regions.py +423 -0
- package/editor/helpers/style_catalog.json +231 -0
- package/editor/helpers/style_samples.py +246 -0
- package/editor/helpers/tela_verde.py +2113 -0
- package/editor/helpers/text_render.py +2059 -0
- package/editor/helpers/texto_na_tela.py +366 -0
- package/editor/helpers/timeline_view.py +292 -0
- package/editor/helpers/tira_de_quadros.py +113 -0
- package/editor/helpers/transcribe.py +734 -0
- package/editor/helpers/transcribe_batch.py +129 -0
- package/editor/helpers/trilhas.py +1146 -0
- package/editor/helpers/trilhas_humores.json +66 -0
- package/editor/helpers/valores.py +187 -0
- package/editor/helpers/verify_cut.py +820 -0
- package/editor/helpers/voice_levels.py +233 -0
- package/editor/helpers/voz_check.py +386 -0
- package/editor/helpers/voz_limpa.py +81 -0
- package/editor/helpers/watch_preview.py +372 -0
- package/editor/modelo-projeto/LEIA.md +402 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
- package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
- package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
- package/editor/modelo-projeto/assets/three/LICENSE +21 -0
- package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
- package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
- package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
- package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
- package/editor/modelo-projeto/build.py +4828 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
- package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
- package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
- package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
- package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
- package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
- package/editor/modelo-projeto/hyperframes.json +12 -0
- package/editor/modelo-projeto/package.json +11 -0
- package/editor/modelo-projeto/projeto.json +16 -0
- package/editor/preview/app.css +1009 -0
- package/editor/preview/app.js +2122 -0
- package/editor/preview/index.html +307 -0
- package/editor/pyproject.toml +23 -0
- package/editor/references/3d.md +236 -0
- package/editor/references/animacao-catalogo.md +672 -0
- package/editor/references/animacao.md +498 -0
- package/editor/references/comandos.md +92 -0
- package/editor/references/corte-seco-como-funciona.md +50 -0
- package/editor/references/corte-seco.md +88 -0
- package/editor/references/corte.md +290 -0
- package/editor/references/direcao-por-blocos.md +218 -0
- package/editor/references/enquadramento.md +654 -0
- package/editor/references/estilos-prontos.md +355 -0
- package/editor/references/estudio.md +100 -0
- package/editor/references/exemplo-eventos.json +67 -0
- package/editor/references/lettering.md +252 -0
- package/editor/references/modelos.md +494 -0
- package/editor/references/phase2.md +1007 -0
- package/editor/references/referencia-aplicar.md +203 -0
- package/editor/references/referencia-medir.md +375 -0
- package/editor/references/revisao.md +335 -0
- package/editor/references/som-e-trilha.md +146 -0
- package/editor/references/tabela-de-sons.md +130 -0
- package/editor/references/variacoes.md +234 -0
- package/editor/uv.lock +1141 -0
- package/lib/act.js +38 -8
- package/lib/app-chat.js +97 -0
- package/lib/catalogo.js +7 -1
- package/lib/chamada-torta.js +81 -0
- package/lib/editor-pro.js +786 -0
- package/lib/entrega.js +58 -0
- package/lib/estudio/conferencia-visual.js +76 -0
- package/lib/estudio/diretor.js +6 -1
- package/lib/estudio/exportar.js +3 -1
- package/lib/estudio/gosto/tela.html +1349 -0
- package/lib/estudio/gosto.js +305 -0
- package/lib/estudio/index.js +12 -1
- package/lib/estudio/kit-ae/base.jsx +119 -13
- package/lib/estudio/kit-ae/cenas.jsx +47 -15
- package/lib/estudio/kit-ae/guarda.jsx +274 -0
- package/lib/estudio/kit-ae/primitivas.jsx +180 -24
- package/lib/estudio/maquina-diretor.js +70 -12
- package/lib/estudio/maquina-foto.js +177 -0
- package/lib/estudio/maquina-guarda.js +207 -0
- package/lib/estudio/maquina-layout.js +22 -1
- package/lib/estudio/maquina.js +64 -11
- package/lib/estudio/referencias.js +1 -1
- package/lib/estudio/visao/visao.swift +97 -0
- package/lib/estudio/visao.js +60 -0
- package/lib/headless.js +15 -3
- package/lib/local/cerebro.json +285 -7
- package/lib/local/motor.js +5 -2
- package/lib/memoria-usuario.js +164 -0
- package/lib/nuvem.js +33 -2
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +19 -11
- package/lib/protecao.js +134 -0
- package/lib/repeticao.js +1 -1
- package/lib/sandbox.js +57 -9
- package/lib/titulo-conversa.js +46 -0
- package/lib/tools.js +23 -4
- package/lib/ui.js +2 -2
- package/package.json +3 -2
- package/skills/editar-meu-video/SKILL.md +6 -2
- package/skills/editar-video/SKILL.md +113 -0
- package/studio/pessoa.py +52 -0
- package/voz/primo_voz/__main__.py +55 -40
- package/voz/primo_voz/app.py +500 -4
- package/voz/primo_voz/chat.html +1773 -0
- package/voz/primo_voz/conversa.py +0 -26
- package/voz/primo_voz/hud.html +8 -2
- package/voz/primo_voz/hud.py +129 -5
- package/voz/primo_voz/identidade.py +1 -1
- 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())
|