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,366 @@
1
+ """O que já está escrito ou desenhado na tela, para a legenda não repetir nem cobrir.
2
+
3
+ Lê o vídeo de amostra em amostra (2 por segundo por padrão) e responde três coisas:
4
+
5
+ 1. onde há texto/desenho na tela, por faixa de altura (para escolher onde a legenda cabe);
6
+ 2. em que trechos já existe legenda gravada no vídeo (para não legendar duas vezes);
7
+ 3. em que trechos a tela inteira é um gráfico claro (papel), onde a legenda branca sumiria.
8
+
9
+ Dois motores de leitura:
10
+
11
+ vision macOS com as ferramentas de linha de comando da Apple (`swiftc`): lê as palavras de
12
+ verdade (Vision). É o único que sabe dizer "isso aqui já é a legenda", porque compara o
13
+ que está escrito com o que está sendo falado.
14
+ heuristico qualquer sistema, só com OpenCV: acha as manchas de texto sem ler. Serve para desviar,
15
+ e marca "já tem legenda" quando a mancha fica no lugar de legenda e troca junto com a
16
+ fala (uma placa de motion fica parada; legenda muda a cada segundo).
17
+
18
+ Usage:
19
+ uv run python helpers/texto_na_tela.py --video cut.mp4 [--edit-dir DIR] [--transcript W.json]
20
+ [--fps 2] [--backend auto|vision|heuristico] [--force] [--json]
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import re
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import tempfile
32
+ import unicodedata
33
+ from pathlib import Path
34
+
35
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
36
+ from common import (edit_dir_for, file_signature, load_json, now_iso, probe_video, # noqa: E402
37
+ require_binary, run, save_json)
38
+
39
+ VERSION = 1
40
+ # Faixas candidatas, em fração da altura (centro da legenda no meio da faixa).
41
+ BANDAS: list[tuple[str, float, float]] = [
42
+ ("legenda", 0.655, 0.760), # padrão: altura do peito, abaixo do rosto
43
+ ("alta", 0.525, 0.630),
44
+ ("baixa", 0.780, 0.885),
45
+ ("rodape", 0.865, 0.960),
46
+ ]
47
+ PAPEL_MIN = 0.72 # fração de pixel claro sem cor para a tela contar como gráfico claro
48
+ TINTA_LIVRE = 0.02 # tinta máxima numa faixa de papel para caber legenda escura
49
+ CONF_MIN = 0.45
50
+ JANELA_FALA = 1.5 # segundos de folga ao comparar o escrito com o falado
51
+
52
+
53
+ def utf8_console() -> None:
54
+ for stream in (sys.stdout, sys.stderr):
55
+ try:
56
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
57
+ except (AttributeError, ValueError, OSError):
58
+ pass
59
+
60
+
61
+ def _norm(texto: str) -> str:
62
+ return unicodedata.normalize("NFD", texto.lower()).encode("ascii", "ignore").decode()
63
+
64
+
65
+ def _bin_dir() -> Path:
66
+ d = Path.home() / ".editor-video" / "bin"
67
+ d.mkdir(parents=True, exist_ok=True)
68
+ return d
69
+
70
+
71
+ def vision_disponivel() -> bool:
72
+ return sys.platform == "darwin" and shutil.which("swiftc") is not None
73
+
74
+
75
+ def compilar_vision(fonte: Path | None = None) -> Path | None:
76
+ """Compila (uma vez) o leitor da Apple e devolve o caminho do binário."""
77
+ if not vision_disponivel():
78
+ return None
79
+ fonte = fonte or Path(__file__).resolve().parent / "assets_ocr" / "ocr_vision.swift"
80
+ if not fonte.exists():
81
+ return None
82
+ alvo = _bin_dir() / "ocr_vision"
83
+ if alvo.exists() and alvo.stat().st_mtime >= fonte.stat().st_mtime:
84
+ return alvo
85
+ try:
86
+ run(["swiftc", "-O", str(fonte), "-o", str(alvo)])
87
+ except (subprocess.CalledProcessError, OSError):
88
+ return None
89
+ return alvo if alvo.exists() else None
90
+
91
+
92
+ def extrair_quadros(video: Path, destino: Path, fps: float, largura: int = 608) -> list[float]:
93
+ require_binary("ffmpeg")
94
+ run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(video),
95
+ "-vf", f"fps={fps},scale={largura}:-2", "-q:v", "3", str(destino / "f_%05d.jpg")])
96
+ nomes = sorted(destino.glob("f_*.jpg"))
97
+ return [round(i / fps, 3) for i in range(len(nomes))]
98
+
99
+
100
+ def _ler_vision(binario: Path, pasta: Path) -> dict[str, list[dict]]:
101
+ saida = pasta / "ocr.json"
102
+ run([str(binario), str(pasta), str(saida)])
103
+ return json.loads(saida.read_text())
104
+
105
+
106
+ def _ler_heuristico(pasta: Path) -> dict[str, list[dict]]:
107
+ import cv2 # noqa: PLC0415 — só quando este motor é usado
108
+ import numpy as np # noqa: PLC0415
109
+
110
+ out: dict[str, list[dict]] = {}
111
+ for arq in sorted(pasta.glob("f_*.jpg")):
112
+ img = cv2.imread(str(arq), cv2.IMREAD_GRAYSCALE)
113
+ if img is None:
114
+ continue
115
+ h, w = img.shape
116
+ grad = cv2.morphologyEx(img, cv2.MORPH_GRADIENT, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)))
117
+ _, bw = cv2.threshold(grad, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
118
+ linha = cv2.morphologyEx(bw, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_RECT, (21, 3)))
119
+ cont, _ = cv2.findContours(linha, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
120
+ itens: list[dict] = []
121
+ for c in cont:
122
+ x, y, cw, ch = cv2.boundingRect(c)
123
+ if ch < h * 0.012 or ch > h * 0.12 or cw < ch * 1.2 or cw > w * 0.96:
124
+ continue
125
+ densidade = float(np.count_nonzero(bw[y:y + ch, x:x + cw])) / max(1, cw * ch)
126
+ if not 0.08 < densidade < 0.62:
127
+ continue
128
+ itens.append({"t": "", "c": 0.5, "x": x / w, "y": 1 - (y + ch) / h, "w": cw / w, "h": ch / h})
129
+ out[arq.name] = itens
130
+ return out
131
+
132
+
133
+ def _papel_e_tinta(pasta: Path, nomes: list[str]) -> tuple[list[float], list[dict[str, float]]]:
134
+ from PIL import Image # noqa: PLC0415
135
+ import numpy as np # noqa: PLC0415
136
+
137
+ papel: list[float] = []
138
+ tinta: list[dict[str, float]] = []
139
+ for nome in nomes:
140
+ im = np.asarray(Image.open(pasta / nome).convert("RGB")).astype(int)
141
+ sat = im.max(2) - im.min(2)
142
+ claro = im.min(2)
143
+ papel.append(round(float(((claro > 205) & (sat < 40)).mean()), 3))
144
+ h = im.shape[0]
145
+ faixa: dict[str, float] = {}
146
+ for nome_faixa, a, b in BANDAS:
147
+ bloco = im[int(a * h):int(b * h)]
148
+ s = bloco.max(2) - bloco.min(2)
149
+ c = bloco.min(2)
150
+ faixa[nome_faixa] = round(float(((c < 200) | (s > 45)).mean()), 3)
151
+ tinta.append(faixa)
152
+ return papel, tinta
153
+
154
+
155
+ def _intervalos(marcas: list[tuple[float, bool]], minimo: float = 0.6,
156
+ juntar: float = 2.5, dilatar: int = 1) -> list[list[float]]:
157
+ if dilatar:
158
+ vivos = [i for i, (_, v) in enumerate(marcas) if v]
159
+ marcas = [(t, any(abs(i - j) <= dilatar for j in vivos)) for i, (t, v) in enumerate(marcas)] \
160
+ if vivos else marcas
161
+ faixas: list[list[float]] = []
162
+ aberto: list[float] | None = None
163
+ for t, v in marcas:
164
+ if v and aberto is None:
165
+ aberto = [t, t]
166
+ elif v:
167
+ aberto[1] = t
168
+ elif aberto is not None:
169
+ faixas.append(aberto)
170
+ aberto = None
171
+ if aberto is not None:
172
+ faixas.append(aberto)
173
+ unidas: list[list[float]] = []
174
+ for f in faixas:
175
+ if unidas and f[0] - unidas[-1][1] <= juntar:
176
+ unidas[-1][1] = f[1]
177
+ else:
178
+ unidas.append(f)
179
+ return [f for f in unidas if f[1] - f[0] >= minimo]
180
+
181
+
182
+ def _legenda_por_palavra(amostras: list[dict], palavras: list[dict]) -> list[tuple[float, bool]]:
183
+ """Já tem legenda quando o que está escrito bate com o que está sendo falado."""
184
+ marcas: list[tuple[float, bool]] = []
185
+ for am in amostras:
186
+ t = am["t"]
187
+ ditas = {_norm(re.sub(r"\W", "", w.get("word", w.get("text", ""))))
188
+ for w in palavras
189
+ if t - JANELA_FALA <= float(w.get("start", w.get("s", -9))) <= t + JANELA_FALA}
190
+ ditas = {p for p in ditas if len(p) >= 3}
191
+ achou = False
192
+ for it in am["itens"]:
193
+ escritas = {_norm(x) for x in re.findall(r"\w{3,}", it.get("texto", ""))}
194
+ if escritas & ditas:
195
+ achou = True
196
+ break
197
+ marcas.append((t, achou))
198
+ return marcas
199
+
200
+
201
+ def _legenda_por_movimento(amostras: list[dict]) -> list[tuple[float, bool]]:
202
+ """Sem a fala para comparar: mancha no lugar de legenda que troca de tamanho a cada amostra.
203
+
204
+ Recebe as amostras já convertidas por `mapear` (centro_y medido do topo, centro_x, largura, altura), nos dois
205
+ motores. Antes lia as chaves cruas do leitor (x, y, w, h) e quebrava com KeyError 'y' (visto em 29/09/2026)."""
206
+ marcas: list[tuple[float, bool]] = []
207
+ anterior: tuple[float, float] | None = None
208
+ for am in amostras:
209
+ alvo = None
210
+ for it in am["itens"]:
211
+ if 0.55 <= it["centro_y"] <= 0.92 and 0.25 <= it["centro_x"] <= 0.75:
212
+ if alvo is None or it["largura"] > alvo[0]:
213
+ alvo = (it["largura"], it["altura"])
214
+ troca = bool(alvo and anterior and abs(alvo[0] - anterior[0]) > 0.02)
215
+ marcas.append((am["t"], troca))
216
+ anterior = alvo
217
+ return marcas
218
+
219
+
220
+ def mapear(video: Path, *, fps: float = 2.0, backend: str = "auto",
221
+ palavras: list[dict] | None = None) -> dict:
222
+ info = probe_video(video)
223
+ largura, altura = int(info["width"]), int(info["height"])
224
+ with tempfile.TemporaryDirectory(prefix="texto-na-tela-") as tmp:
225
+ pasta = Path(tmp)
226
+ tempos = extrair_quadros(video, pasta, fps)
227
+ nomes = [f"f_{i + 1:05d}.jpg" for i in range(len(tempos))]
228
+ binario = compilar_vision() if backend in ("auto", "vision") else None
229
+ if backend == "vision" and binario is None:
230
+ raise RuntimeError("motor vision indisponível (precisa de macOS com swiftc)")
231
+ usado = "vision" if binario else "heuristico"
232
+ bruto = _ler_vision(binario, pasta) if binario else _ler_heuristico(pasta)
233
+ papel, tinta = _papel_e_tinta(pasta, nomes)
234
+ amostras: list[dict] = []
235
+ for i, (t, nome) in enumerate(zip(tempos, nomes)):
236
+ itens = []
237
+ for it in bruto.get(nome, []):
238
+ if float(it.get("c", 1)) < CONF_MIN:
239
+ continue
240
+ texto = str(it.get("t", "")).strip()
241
+ centro = 1 - (it["y"] + it["h"] / 2) # 0 no topo, 1 embaixo
242
+ itens.append({"texto": texto, "centro_y": round(centro, 4),
243
+ "altura": round(it["h"], 4), "centro_x": round(it["x"] + it["w"] / 2, 4),
244
+ "largura": round(it["w"], 4)})
245
+ amostras.append({"t": t, "itens": itens, "papel": papel[i], "tinta": tinta[i]})
246
+ if palavras:
247
+ marcas = _legenda_por_palavra(amostras, palavras)
248
+ else:
249
+ marcas = _legenda_por_movimento(amostras)
250
+ doc = {
251
+ "version": VERSION, "created_at": now_iso(), "video": video.name,
252
+ "video_sig": file_signature(video), "backend": usado, "fps": fps,
253
+ "width": largura, "height": altura, "duration": round(float(info["duration"]), 3),
254
+ "bandas": [{"nome": n, "a": a, "b": b} for n, a, b in BANDAS],
255
+ "amostras": amostras,
256
+ "legenda_existente": _intervalos(marcas),
257
+ "tela_cheia": _intervalos([(a["t"], a["papel"] >= PAPEL_MIN) for a in amostras], minimo=1.0),
258
+ }
259
+ doc["resumo"] = resumo(doc)
260
+ return doc
261
+
262
+
263
+ def _amostras_entre(doc: dict, a: float, z: float) -> list[dict]:
264
+ return [s for s in doc["amostras"] if a - 0.3 <= s["t"] <= z + 0.3] or \
265
+ [min(doc["amostras"], key=lambda s: abs(s["t"] - (a + z) / 2))] if doc["amostras"] else []
266
+
267
+
268
+ def cobre(faixas: list[list[float]], a: float, z: float, minimo: float = 0.45) -> bool:
269
+ if z <= a:
270
+ return False
271
+ sobra = sum(max(0.0, min(z, f[1]) - max(a, f[0])) for f in faixas)
272
+ return sobra > minimo * (z - a)
273
+
274
+
275
+ def escolher_faixa(doc: dict, a: float, z: float, *, preferida: str | None = None,
276
+ margem: float = 0.05) -> dict | None:
277
+ """Faixa livre para pôr a legenda entre `a` e `z` (None = não cabe em nenhuma)."""
278
+ ordem = [n for n, _, _ in BANDAS]
279
+ if preferida and preferida in ordem:
280
+ ordem = [ordem[0]] + ([preferida] if preferida != ordem[0] else []) + \
281
+ [n for n in ordem[1:] if n != preferida]
282
+ amostras = _amostras_entre(doc, a, z)
283
+ papel = bool(amostras) and all(s["papel"] >= PAPEL_MIN for s in amostras)
284
+ for nome in ordem:
285
+ faixa = next(f for f in BANDAS if f[0] == nome)
286
+ lo, hi = faixa[1] - margem, faixa[2] + margem
287
+ livre = True
288
+ for s in amostras:
289
+ if papel:
290
+ if s["tinta"].get(nome, 1.0) > TINTA_LIVRE:
291
+ livre = False
292
+ break
293
+ else:
294
+ for it in s["itens"]:
295
+ if it["centro_y"] + it["altura"] / 2 > lo and it["centro_y"] - it["altura"] / 2 < hi:
296
+ livre = False
297
+ break
298
+ if not livre:
299
+ break
300
+ if livre:
301
+ return {"faixa": nome, "centro_y": round((faixa[1] + faixa[2]) / 2, 4),
302
+ "papel": papel, "cor": "escura" if papel else "clara"}
303
+ return None
304
+
305
+
306
+ def resumo(doc: dict) -> str:
307
+ n_leg = len(doc["legenda_existente"])
308
+ n_cheia = len(doc["tela_cheia"])
309
+ seg_leg = sum(b - a for a, b in doc["legenda_existente"])
310
+ seg_cheia = sum(b - a for a, b in doc["tela_cheia"])
311
+ motor = "leitura de texto (Vision)" if doc["backend"] == "vision" else "manchas de texto (OpenCV)"
312
+ partes = [f"{motor}",
313
+ f"{n_leg} trecho(s) já com legenda na tela ({seg_leg:.0f}s)",
314
+ f"{n_cheia} trecho(s) de gráfico em tela cheia ({seg_cheia:.0f}s)"]
315
+ if doc["backend"] != "vision":
316
+ partes.append("sem leitura de palavras: confira os trechos marcados antes de pular a legenda")
317
+ return "; ".join(partes) + "."
318
+
319
+
320
+ def main(argv: list[str] | None = None) -> int:
321
+ utf8_console()
322
+ ap = argparse.ArgumentParser(description="O que já está escrito na tela (para a legenda desviar)")
323
+ ap.add_argument("--video", type=Path, required=True)
324
+ ap.add_argument("--edit-dir", type=Path)
325
+ ap.add_argument("--transcript", type=Path, help="JSON com as palavras faladas (start/word)")
326
+ ap.add_argument("--fps", type=float, default=2.0)
327
+ ap.add_argument("--backend", choices=["auto", "vision", "heuristico"], default="auto")
328
+ ap.add_argument("--force", action="store_true")
329
+ ap.add_argument("--json", action="store_true")
330
+ args = ap.parse_args(argv)
331
+ video = args.video.expanduser().absolute()
332
+ if not video.exists():
333
+ print(f"erro: não encontrei {video}", file=sys.stderr)
334
+ return 2
335
+ edit_dir = (args.edit_dir.expanduser().absolute() if args.edit_dir else edit_dir_for(video))
336
+ destino = edit_dir / "texto_na_tela.json"
337
+ palavras: list[dict] | None = None
338
+ if args.transcript and args.transcript.exists():
339
+ dados = load_json(args.transcript)
340
+ palavras = dados.get("words") if isinstance(dados, dict) else dados
341
+ if destino.exists() and not args.force:
342
+ antigo = load_json(destino)
343
+ if (antigo.get("video_sig") == file_signature(video) and antigo.get("version") == VERSION
344
+ and abs(float(antigo.get("fps", 0)) - args.fps) < 1e-6
345
+ and (palavras is None or antigo.get("backend") == "vision")):
346
+ print(antigo.get("resumo", "já estava pronto"))
347
+ if args.json:
348
+ print(json.dumps(antigo, ensure_ascii=False, indent=2))
349
+ return 0
350
+ try:
351
+ doc = mapear(video, fps=args.fps, backend=args.backend, palavras=palavras)
352
+ except RuntimeError as exc:
353
+ print(f"erro: {exc}", file=sys.stderr)
354
+ return 2
355
+ try:
356
+ save_json(destino, doc)
357
+ except OSError:
358
+ pass
359
+ print(doc["resumo"])
360
+ if args.json:
361
+ print(json.dumps(doc, ensure_ascii=False, indent=2))
362
+ return 0
363
+
364
+
365
+ if __name__ == "__main__":
366
+ sys.exit(main())
@@ -0,0 +1,292 @@
1
+ # Adapted from browser-use/video-use (MIT, (c) 2026 Browser Use). See THIRD_PARTY_NOTICES.md.
2
+ """Filmstrip + waveform + words for one time range of a video, as a PNG.
3
+
4
+ Usage:
5
+ uv run python helpers/timeline_view.py <video> <start> <end> [-o PNG] [--transcript JSON] [--frames N]
6
+
7
+ - Waveform: peak envelope (light) and RMS (strong) of the audio in the range.
8
+ - Silence bands (blue): from edit/regions/<stem>.json when present, else measured here
9
+ (RMS under -35 dBFS for at least 0.25 s).
10
+ - Words: from --transcript, else edit/transcripts/<stem>.json (cut.mp4 -> transcripts/cut.json).
11
+ - On a cut with a timeline next to it (cut_timeline.json): segment boundaries (yellow lines with
12
+ ids) and J-cut audio overlaps (orange bands, where the incoming audio starts early).
13
+ Default output: edit/verify/timeline_<stem>_<start>-<end>.png
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+ from PIL import Image, ImageDraw
25
+
26
+ from common import file_signature, fmt_time, load_json, probe_video
27
+ from contact_sheet import (ACCENT, BG, DIM, FG, PANEL, abspath, default_verify_dir, extract_frames, find_timeline,
28
+ parse_time, ui_font)
29
+
30
+ WAVE_PEAK = (96, 128, 190)
31
+ WAVE_RMS = (150, 190, 255)
32
+ SILENCE = (40, 90, 170, 70)
33
+ JCUT = (255, 140, 40, 90)
34
+ ANALYSIS_SR = 16000
35
+
36
+
37
+ def load_audio(video: Path, start: float, end: float) -> np.ndarray:
38
+ proc = subprocess.run(
39
+ ["ffmpeg", "-v", "error", "-nostdin", "-ss", f"{start:.4f}", "-i", str(video), "-t", f"{end - start:.4f}",
40
+ "-vn", "-ac", "1", "-ar", str(ANALYSIS_SR), "-f", "f32le", "-"], capture_output=True)
41
+ if proc.returncode != 0:
42
+ return np.zeros(0, dtype=np.float32)
43
+ return np.frombuffer(proc.stdout, dtype=np.float32)
44
+
45
+
46
+ def measured_silences(pcm: np.ndarray, start: float, noise_db: float = -35.0, min_len: float = 0.25) -> list:
47
+ win = int(0.02 * ANALYSIS_SR)
48
+ if pcm.size < win:
49
+ return []
50
+ n = pcm.size // win
51
+ rms = np.sqrt(np.mean(pcm[: n * win].reshape(n, win) ** 2, axis=1))
52
+ quiet = 20 * np.log10(np.maximum(rms, 1e-9)) < noise_db
53
+ out, i = [], 0
54
+ while i < n:
55
+ if quiet[i]:
56
+ j = i
57
+ while j < n and quiet[j]:
58
+ j += 1
59
+ if (j - i) * 0.02 >= min_len:
60
+ out.append([start + i * 0.02, start + j * 0.02])
61
+ i = j
62
+ else:
63
+ i += 1
64
+ return out
65
+
66
+
67
+ def resolve_transcript(video: Path, explicit: Path | None) -> Path | None:
68
+ if explicit:
69
+ return explicit if explicit.exists() else None
70
+ cands = []
71
+ if video.parent.name == "edit" or (video.parent / "transcripts").is_dir():
72
+ cands.append(video.parent / "transcripts" / f"{video.stem}.json")
73
+ cands.append(video.parent / "edit" / "transcripts" / f"{video.stem}.json")
74
+ return next((c for c in cands if c.exists()), None)
75
+
76
+
77
+ def resolve_regions(video: Path) -> dict | None:
78
+ for c in (video.parent / "edit" / "regions" / f"{video.stem}.json", video.parent / "regions" / f"{video.stem}.json"):
79
+ data = load_json(c)
80
+ if isinstance(data, dict) and isinstance(data.get("silences"), list):
81
+ return data
82
+ return None
83
+
84
+
85
+ def matches_video(data: dict, video: Path, duration: float) -> bool:
86
+ """A regions/transcript JSON describes this exact file (a re-rendered cut.mp4 keeps its name)."""
87
+ sig = data.get("source_sig")
88
+ if sig:
89
+ try:
90
+ return sig == file_signature(video)
91
+ except OSError:
92
+ return False
93
+ if data.get("duration") is not None and duration:
94
+ return abs(float(data["duration"]) - duration) <= 0.05
95
+ return True
96
+
97
+
98
+ def nice_step(span: float, target_ticks: int = 10) -> float:
99
+ for step in (0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300):
100
+ if span / step <= target_ticks:
101
+ return step
102
+ return 600
103
+
104
+
105
+ def render(video: Path, start: float, end: float, out: Path, transcript: Path | None, n_frames: int) -> Path:
106
+ info = probe_video(video)
107
+ dur = info.get("duration") or end
108
+ end = min(end, dur)
109
+ span = end - start
110
+ W = 1800
111
+ pad = 40
112
+ x0, x1 = pad, W - pad
113
+ inner = x1 - x0
114
+
115
+ def tx(t: float) -> float:
116
+ return x0 + (t - start) / span * inner
117
+
118
+ vertical = (info.get("display_height") or 0) > (info.get("display_width") or 0)
119
+ if n_frames <= 0:
120
+ n_frames = 12 if vertical else 7
121
+ gap = 6
122
+ tile_w = int((inner - gap * (n_frames - 1)) / n_frames)
123
+ tile_w -= tile_w % 2
124
+ times = [start + (i + 0.5) * span / n_frames for i in range(n_frames)]
125
+ frames = extract_frames(video, times, width=tile_w) if info.get("has_video") else []
126
+ tile_h = frames[0].size[1] if frames else 0
127
+
128
+ head, strip_y = 64, 64
129
+ wave_y = strip_y + tile_h + (34 if frames else 0) + 44
130
+ wave_h = 260
131
+ words_y = wave_y + wave_h + 14
132
+ ruler_y = words_y + 62
133
+ H = ruler_y + 70
134
+
135
+ canvas = Image.new("RGB", (W, H), BG)
136
+ d = ImageDraw.Draw(canvas, "RGBA")
137
+ f_head, f_lab, f_word, f_small = ui_font(26, bold=True), ui_font(15), ui_font(17), ui_font(14)
138
+ d.text((pad, 18), f"{video.name} · {fmt_time(start)} a {fmt_time(end)} ({span:.2f}s)", fill=FG, font=f_head)
139
+
140
+ for i, (img, t) in enumerate(zip(frames, times)):
141
+ x = x0 + i * (tile_w + gap)
142
+ canvas.paste(img, (x, strip_y))
143
+ d.rectangle((x, strip_y + tile_h, x + tile_w - 1, strip_y + tile_h + 28), fill=PANEL)
144
+ d.text((x + 6, strip_y + tile_h + 6), fmt_time(t), fill=DIM, font=f_small)
145
+
146
+ # waveform panel
147
+ d.rectangle((x0, wave_y, x1, wave_y + wave_h), fill=(28, 28, 34))
148
+ pcm = load_audio(video, start, end) if info.get("has_audio") else np.zeros(0, dtype=np.float32)
149
+
150
+ regions = resolve_regions(video)
151
+ stale_notes = []
152
+ if regions and not matches_video(regions, video, dur):
153
+ regions = None
154
+ stale_notes.append("regions desatualizado (o vídeo mudou)")
155
+ if regions:
156
+ silences = [[float(a), float(b)] for a, b in regions["silences"] if float(b) > start and float(a) < end]
157
+ sil_note = "silêncio medido (regions)"
158
+ else:
159
+ silences = measured_silences(pcm, start)
160
+ sil_note = "silêncio medido aqui (-35 dB, 0,25 s)"
161
+ for a, b in silences:
162
+ d.rectangle((tx(max(a, start)), wave_y, tx(min(b, end)), wave_y + wave_h), fill=SILENCE)
163
+
164
+ timeline = None
165
+ has_overlap = False
166
+ tl_path = find_timeline(video)
167
+ if tl_path and (video.parent / tl_path.name).exists():
168
+ timeline = load_json(tl_path)
169
+ if timeline:
170
+ for s in timeline.get("segments") or []:
171
+ a0, a1 = float(s.get("audio_out_start", s["out_start"])), float(s["out_start"])
172
+ if a1 - a0 > 1e-6 and a1 > start and a0 < end:
173
+ has_overlap = True
174
+ d.rectangle((tx(max(a0, start)), wave_y, tx(min(a1, end)), wave_y + wave_h), fill=JCUT)
175
+
176
+ mid = wave_y + wave_h / 2
177
+ amp = wave_h / 2 - 10
178
+ if pcm.size:
179
+ cols = inner
180
+ per = max(1, pcm.size // cols)
181
+ usable = (pcm.size // per) * per
182
+ blocks = pcm[:usable].reshape(-1, per)
183
+ peak = np.abs(blocks).max(axis=1)
184
+ rms = np.sqrt((blocks ** 2).mean(axis=1))
185
+ scale = max(float(peak.max()), 1e-4)
186
+ scale = max(scale, 0.25) # keep quiet audio looking quiet
187
+ for i in range(blocks.shape[0]):
188
+ x = x0 + i * inner / blocks.shape[0]
189
+ hp = peak[i] / scale * amp
190
+ hr = rms[i] / scale * amp
191
+ d.line((x, mid - hp, x, mid + hp), fill=WAVE_PEAK)
192
+ d.line((x, mid - hr, x, mid + hr), fill=WAVE_RMS)
193
+ d.line((x0, mid, x1, mid), fill=(70, 70, 80))
194
+
195
+ if timeline:
196
+ for s in timeline.get("segments") or []:
197
+ t = float(s["out_start"])
198
+ if start <= t <= end:
199
+ d.line((tx(t), strip_y - 6, tx(t), words_y + 44), fill=ACCENT, width=2)
200
+ d.text((tx(t) + 5, wave_y + 6), f"{s['id']} {s.get('beat') or ''}".strip(), fill=ACCENT, font=f_lab)
201
+
202
+ # words: two alternating rows, skip labels that would collide
203
+ tr_path = resolve_transcript(video, transcript)
204
+ words = []
205
+ if tr_path:
206
+ data = load_json(tr_path) or {}
207
+ # An explicit --transcript of another file is trusted; one made from this path must match the file.
208
+ own = transcript is None or str(data.get("source") or "") == str(video)
209
+ if own and not matches_video(data, video, dur):
210
+ stale_notes.append("transcrição desatualizada (o vídeo mudou): transcreva de novo")
211
+ data = {}
212
+ words = [w for w in data.get("words", []) if w.get("end", 0) > start and w.get("start", 0) < end]
213
+ last_x = [-1e9, -1e9]
214
+ for w in words:
215
+ ws, we = max(float(w["start"]), start), min(float(w["end"]), end)
216
+ text = str(w.get("text", "")).strip()
217
+ if not text:
218
+ continue
219
+ xa, xb = tx(ws), tx(we)
220
+ d.line((xa, wave_y + wave_h, xa, wave_y + wave_h + 8), fill=DIM)
221
+ d.rectangle((xa, wave_y + wave_h - 4, max(xa + 1, xb), wave_y + wave_h - 1), fill=(200, 200, 210, 120))
222
+ tw = d.textlength(text, font=f_word)
223
+ row = 0 if xa > last_x[0] + 6 else 1 if xa > last_x[1] + 6 else None
224
+ if row is None:
225
+ continue
226
+ d.text((xa, words_y + row * 24), text, fill=FG, font=f_word)
227
+ last_x[row] = xa + tw
228
+
229
+ step = nice_step(span)
230
+ t = (int(start / step) + (1 if start % step > 1e-6 else 0)) * step
231
+ while t <= end + 1e-6:
232
+ x = tx(t)
233
+ d.line((x, ruler_y, x, ruler_y + 8), fill=DIM)
234
+ label = fmt_time(t)
235
+ lx = min(max(x - d.textlength(label, font=f_small) / 2, 4), W - 4 - d.textlength(label, font=f_small))
236
+ d.text((lx, ruler_y + 12), label, fill=DIM, font=f_small)
237
+ t += step
238
+ legend = f"azul = {sil_note}"
239
+ if timeline:
240
+ legend += " · amarelo = início de trecho"
241
+ if has_overlap:
242
+ legend += " · laranja = áudio do próximo trecho entrando antes (J-cut)"
243
+ if not words and not stale_notes:
244
+ legend += " · sem transcrição"
245
+ for note in stale_notes:
246
+ legend += f" · {note}"
247
+ d.text((pad, ruler_y + 40), legend, fill=DIM, font=f_small)
248
+ for note in stale_notes:
249
+ print(f"aviso: {note}", file=sys.stderr)
250
+
251
+ out.parent.mkdir(parents=True, exist_ok=True)
252
+ canvas.save(out, "PNG", optimize=True)
253
+ return out
254
+
255
+
256
+ def main() -> int:
257
+ ap = argparse.ArgumentParser(description="Tira de quadros + forma de onda + palavras de um trecho do vídeo (PNG).")
258
+ ap.add_argument("video", type=Path)
259
+ ap.add_argument("start", type=parse_time, help="início (segundos ou mm:ss.cc)")
260
+ ap.add_argument("end", type=parse_time, help="fim (segundos ou mm:ss.cc)")
261
+ ap.add_argument("-o", "--output", type=Path, help="PNG de saída (padrão: edit/verify/timeline_<nome>_<ini>-<fim>.png)")
262
+ ap.add_argument("--transcript", type=Path, help="transcrição JSON (padrão: edit/transcripts/<nome>.json)")
263
+ ap.add_argument("--frames", type=int, default=0, help="quantos quadros na tira (padrão automático)")
264
+ args = ap.parse_args()
265
+
266
+ video = abspath(args.video)
267
+ if not video.exists():
268
+ print(f"erro: vídeo não encontrado: {video}", file=sys.stderr)
269
+ return 2
270
+ if args.end <= args.start:
271
+ print("erro: o fim precisa ser maior que o início", file=sys.stderr)
272
+ return 2
273
+ try:
274
+ info = probe_video(video)
275
+ except Exception as exc: # noqa: BLE001
276
+ print(f"erro: não consegui ler {video.name}: {exc}", file=sys.stderr)
277
+ return 2
278
+ if args.start >= (info.get("duration") or 0):
279
+ print(f"erro: início {args.start:.2f}s passa do fim do vídeo ({info.get('duration', 0):.2f}s)", file=sys.stderr)
280
+ return 2
281
+ out = args.output or default_verify_dir(video) / f"timeline_{video.stem}_{args.start:.2f}-{args.end:.2f}.png"
282
+ try:
283
+ path = render(video, args.start, args.end, abspath(out), args.transcript, args.frames)
284
+ except RuntimeError as exc:
285
+ print(f"erro: {exc}", file=sys.stderr)
286
+ return 1
287
+ print(f"imagem salva: {path}")
288
+ return 0
289
+
290
+
291
+ if __name__ == "__main__":
292
+ sys.exit(main())