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.
- 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 +11 -2
- 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 +36 -8
- package/lib/catalogo.js +4 -0
- 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.js +45 -4
- package/lib/estudio/index.js +8 -5
- 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 +61 -7
- 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 +60 -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/local/cerebro.json +192 -6
- package/lib/local/motor.js +5 -2
- package/lib/nuvem.js +11 -1
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +19 -11
- package/lib/protecao.js +134 -0
- package/lib/titulo-conversa.js +46 -0
- package/lib/tools.js +3 -0
- package/lib/ui.js +1 -1
- 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/app.py +43 -4
- package/voz/primo_voz/chat.html +17 -6
- 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())
|