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,89 @@
|
|
|
1
|
+
"""Every silence that is still in a rendered video: the André rule is no breath of 0.25 s or more.
|
|
2
|
+
|
|
3
|
+
Measures the mono mix in 10 ms windows (the same measure as corte_por_onda.py) and lists every run below the
|
|
4
|
+
pause threshold that lasts MIN_S or more, with its time in the video. The threshold is the one corte_por_onda
|
|
5
|
+
uses (floor + 14 dB, never lower than 28 dB under the loud speech); `--limiar` forces it.
|
|
6
|
+
|
|
7
|
+
The first and the last silence count: a cut that starts 0.5 s before the first word, or ends 0.5 s after the
|
|
8
|
+
last one, feels slow. On a final video with a still end on purpose (`segura_fim` of the project), pass
|
|
9
|
+
`--fim-parado`: the silence that reaches the last frame is listed apart and does not count. Ranges that were kept
|
|
10
|
+
whole on purpose ("dry": false: a video playing on screen) show up as well: skip them by their time.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
uv run python helpers/respiros.py <video> [--min 0.25] [--limiar -36] [--fim-parado] [--json]
|
|
14
|
+
Exit code 0 when nothing is found, 1 when there is a breath to look at, 2 on error.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
25
|
+
import corte_por_onda as onda # noqa: E402
|
|
26
|
+
from common import fmt_time # noqa: E402
|
|
27
|
+
|
|
28
|
+
MIN_S = 0.25
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find(db, th: float, min_s: float) -> list[tuple[float, float]]:
|
|
32
|
+
out, run = [], None
|
|
33
|
+
for i, v in enumerate(db):
|
|
34
|
+
quiet = v < th
|
|
35
|
+
if quiet and run is None:
|
|
36
|
+
run = i
|
|
37
|
+
if not quiet and run is not None:
|
|
38
|
+
if (i - run) * onda.HOP_S >= min_s:
|
|
39
|
+
out.append((run * onda.HOP_S, i * onda.HOP_S))
|
|
40
|
+
run = None
|
|
41
|
+
if run is not None and (len(db) - run) * onda.HOP_S >= min_s:
|
|
42
|
+
out.append((run * onda.HOP_S, len(db) * onda.HOP_S))
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main(argv: list[str] | None = None) -> int:
|
|
47
|
+
ap = argparse.ArgumentParser(description="Silêncios que sobraram num vídeo pronto (regra: nenhum de 0,25 s ou mais).")
|
|
48
|
+
ap.add_argument("video", type=Path)
|
|
49
|
+
ap.add_argument("--min", type=float, default=MIN_S, help="duração mínima do silêncio em segundos (0,25)")
|
|
50
|
+
ap.add_argument("--limiar", type=float, help="dB abaixo do qual é silêncio (padrão: medido)")
|
|
51
|
+
ap.add_argument("--fim-parado", action="store_true",
|
|
52
|
+
help="vídeo final com fim parado de propósito (segura_fim): o silêncio do fim não conta")
|
|
53
|
+
ap.add_argument("--json", action="store_true")
|
|
54
|
+
a = ap.parse_args(argv)
|
|
55
|
+
if not a.video.is_file():
|
|
56
|
+
print(f"ERRO: não achei {a.video}", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
db = onda.envelope(a.video, None)
|
|
59
|
+
th = a.limiar if a.limiar is not None else onda.threshold(db)
|
|
60
|
+
found = find(db, th, a.min)
|
|
61
|
+
end = len(db) * onda.HOP_S
|
|
62
|
+
tail = None
|
|
63
|
+
if a.fim_parado and found and found[-1][1] >= end - 0.05: # silence up to the last frame
|
|
64
|
+
tail, found = found[-1], found[:-1]
|
|
65
|
+
if a.json:
|
|
66
|
+
print(json.dumps({"limiar_db": th, "min_s": a.min,
|
|
67
|
+
"respiros": [{"de": round(x, 2), "ate": round(y, 2), "dur": round(y - x, 2)} for x, y in found],
|
|
68
|
+
"fim_parado": ({"de": round(tail[0], 2), "dur": round(tail[1] - tail[0], 2)} if tail else None)},
|
|
69
|
+
ensure_ascii=False))
|
|
70
|
+
return 1 if found else 0
|
|
71
|
+
tail_note = (f" fim parado: {fmt_time(tail[0])} até o fim ({tail[1] - tail[0]:.2f} s), não conta "
|
|
72
|
+
"(confira se bate com o segura_fim do projeto)") if tail else ""
|
|
73
|
+
if not found:
|
|
74
|
+
print(f"respiros: nenhum silêncio de {a.min:.2f} s ou mais (abaixo de {th:.0f} dB)")
|
|
75
|
+
if tail_note:
|
|
76
|
+
print(tail_note)
|
|
77
|
+
return 0
|
|
78
|
+
print(f"respiros: {len(found)} silêncio(s) de {a.min:.2f} s ou mais (abaixo de {th:.0f} dB):")
|
|
79
|
+
for x, y in found:
|
|
80
|
+
print(f" {fmt_time(x)} a {fmt_time(y)} ({y - x:.2f} s)")
|
|
81
|
+
if tail_note:
|
|
82
|
+
print(tail_note)
|
|
83
|
+
print(" cada um é respiro que sobrou: parta o trecho ali no edl_manual.json ou rode o corte pela onda com "
|
|
84
|
+
"--limiar uns 3 dB mais alto, ou no limiar medido acima (references/corte.md)")
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""Sincroniza gravações feitas ao mesmo tempo (duas ou mais câmeras e/ou um áudio de lapela) pela onda.
|
|
2
|
+
|
|
3
|
+
Para cada fonte acha o deslocamento contra a principal e grava um JSON que o render_cut.py usa no campo
|
|
4
|
+
`imagem_de` de um trecho (o som continua da fonte do trecho e a imagem vem da outra câmera no mesmo instante).
|
|
5
|
+
|
|
6
|
+
Como mede
|
|
7
|
+
1. Envelope: volume em janelas de 10 ms (8 kHz, estéreo com fase invertida vira o canal mais forte), em dB,
|
|
8
|
+
com o chão de ruído aparado e a tendência lenta (1 s) tirada. Microfones diferentes (celular, câmera,
|
|
9
|
+
lapela) têm o mesmo desenho de sílabas, mesmo com volume, timbre e ruído diferentes.
|
|
10
|
+
2. Grosso: correlação cruzada dos envelopes em todos os deslocamentos (FFT), dividida pela sobreposição.
|
|
11
|
+
Dá o deslocamento com uns 10 ms de erro, a correlação (Pearson no trecho em comum) e o destaque do pico
|
|
12
|
+
(quantos desvios acima da média das outras posições).
|
|
13
|
+
3. Fino: GCC-PHAT a 16 kHz em janelas de 6 s com fala, espalhadas pela gravação, procurando só 0,15 s em
|
|
14
|
+
volta do grosso, com interpolação no pico. Cada janela dá um deslocamento; a nitidez do pico diz se ela
|
|
15
|
+
vale (janela em silêncio não vale).
|
|
16
|
+
4. Deriva: com janelas boas espalhadas por 20 s ou mais, ajusta uma reta (deslocamento contra o tempo). Se
|
|
17
|
+
ela anda 2 ms ou mais na gravação, o JSON leva a escala e o render corrige; de 10 ms para cima sai aviso.
|
|
18
|
+
Janela que foge da reta mais de 15 ms é aviso de câmera que parou, perdeu quadros ou cortou a gravação.
|
|
19
|
+
|
|
20
|
+
Regra do JSON: tempo na fonte = deslocamento + ajuste_s + escala x tempo na principal.
|
|
21
|
+
Deslocamento positivo: a outra fonte começou a gravar antes (o mesmo som aparece mais tarde no arquivo dela).
|
|
22
|
+
`ajuste_s` é para correção à mão (conferida no olho numa palma): fica guardado quando o arquivo é refeito.
|
|
23
|
+
|
|
24
|
+
Saída de avisos: correlação fraca, pico sem destaque (som repetitivo, música), pouca sobreposição, deriva,
|
|
25
|
+
sincronia que não é uma reta, fonte sem áudio. Sai com 1 quando alguma fonte ficou fraca (o JSON é gravado
|
|
26
|
+
mesmo assim, com "confianca": "fraca").
|
|
27
|
+
|
|
28
|
+
`--prova` grava, para cada câmera, em <pasta do JSON>/verify/: sincronia_<NOME>.mp4 (as duas imagens lado a
|
|
29
|
+
lado, som da principal, 3 s em volta da batida mais forte ou de --em) e sincronia_<NOME>.png (5 quadros
|
|
30
|
+
seguidos de cada câmera no instante da batida, principal em cima). Palma, batida na mesa ou claquete: a mão
|
|
31
|
+
fecha no mesmo quadro nas duas linhas.
|
|
32
|
+
|
|
33
|
+
Uso:
|
|
34
|
+
uv run python helpers/sincronizar_cameras.py <principal> <outra> [...] -o <EDIT>/sincronia.json [--prova]
|
|
35
|
+
uv run python helpers/sincronizar_cameras.py CAM1=/v/a.mp4 CAM2=/v/b.mp4 LAPELA=/v/l.wav -o ... --principal LAPELA
|
|
36
|
+
uv run python helpers/sincronizar_cameras.py --edl <EDIT>/edl_manual.json [--principal CAM1] [--prova]
|
|
37
|
+
O nome de cada fonte é o nome do arquivo sem extensão (o mesmo da chave de "sources" no edl_manual.json), ou
|
|
38
|
+
o que vier antes do "=".
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import math
|
|
46
|
+
import os
|
|
47
|
+
import subprocess
|
|
48
|
+
import sys
|
|
49
|
+
import tempfile
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
import numpy as np
|
|
53
|
+
|
|
54
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
55
|
+
from common import file_signature, load_json, now_iso, probe_video, require_binary, save_json # noqa: E402
|
|
56
|
+
|
|
57
|
+
VERSION = 1
|
|
58
|
+
HOP_S = 0.01
|
|
59
|
+
ENV_SR = 8000
|
|
60
|
+
FINE_SR = 16000
|
|
61
|
+
FINE_WIN_S = 6.0
|
|
62
|
+
FINE_MARGIN_S = 0.15
|
|
63
|
+
FINE_BAND_HZ = (150.0, 3800.0)
|
|
64
|
+
DETREND_S = 1.0
|
|
65
|
+
MIN_OVERLAP_S = 5.0
|
|
66
|
+
MIN_OVERLAP_SHARE = 0.2
|
|
67
|
+
GOOD_SHARPNESS = 6.0 # pico do GCC-PHAT pelo RMS das outras posições: abaixo disso a janela não vale
|
|
68
|
+
DRIFT_MIN_SPAN_S = 20.0
|
|
69
|
+
DRIFT_APPLY_S = 0.002 # a reta anda pelo menos isso na gravação: a escala entra no JSON
|
|
70
|
+
DRIFT_WARN_S = 0.010
|
|
71
|
+
NONLINEAR_WARN_S = 0.015
|
|
72
|
+
OUTLIER_S = 0.004
|
|
73
|
+
CORR_BOA, CORR_MEDIA = 0.5, 0.3
|
|
74
|
+
DESTAQUE_BOM, DESTAQUE_MIN = 6.0, 5.0
|
|
75
|
+
PEAK_EXCLUDE_S = 1.0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SyncError(Exception):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def utf8_console() -> None:
|
|
83
|
+
for stream in (sys.stdout, sys.stderr):
|
|
84
|
+
try:
|
|
85
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
86
|
+
except (AttributeError, ValueError, OSError):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------ leitura do áudio
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def audio_facts(path: Path) -> dict:
|
|
94
|
+
try:
|
|
95
|
+
info = probe_video(path)
|
|
96
|
+
except Exception as exc: # noqa: BLE001
|
|
97
|
+
raise SyncError(f"não consegui ler {path.name}: {exc}")
|
|
98
|
+
if not info.get("has_audio"):
|
|
99
|
+
raise SyncError(f"{path.name} não tem áudio: sem som não dá para sincronizar pela onda. Sincronize no olho "
|
|
100
|
+
"(numa palma) e escreva o deslocamento à mão no JSON")
|
|
101
|
+
return info
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _decode_cmd(path: Path, sr: int, channels: int, start: float | None = None, dur: float | None = None,
|
|
105
|
+
pan: str | None = None) -> list[str]:
|
|
106
|
+
cmd = ["ffmpeg", "-v", "error", "-nostdin"]
|
|
107
|
+
if start is not None:
|
|
108
|
+
cmd += ["-ss", f"{max(0.0, start):.6f}"]
|
|
109
|
+
if dur is not None:
|
|
110
|
+
cmd += ["-t", f"{dur:.6f}"]
|
|
111
|
+
# first_pts=0: o áudio que começa depois do vídeo (AAC com atraso) ganha silêncio na frente, então o tempo 0
|
|
112
|
+
# é o começo do arquivo, o mesmo que o -ss do render_cut usa
|
|
113
|
+
af = f"aresample={sr}:async=1:first_pts=0"
|
|
114
|
+
if pan:
|
|
115
|
+
af += f",{pan}"
|
|
116
|
+
cmd += ["-i", str(path), "-vn", "-map", "0:a:0", "-af", af]
|
|
117
|
+
if not pan:
|
|
118
|
+
cmd += ["-ac", str(channels)]
|
|
119
|
+
cmd += ["-f", "f32le", "-"]
|
|
120
|
+
return cmd
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def envelope(path: Path, info: dict) -> tuple[np.ndarray, str]:
|
|
124
|
+
"""dB em janelas de 10 ms (índice 0 = tempo 0 do arquivo) e o jeito de ler os canais para o fino."""
|
|
125
|
+
ch = 2 if int(info.get("audio_channels") or 1) >= 2 else 1
|
|
126
|
+
w = int(ENV_SR * HOP_S)
|
|
127
|
+
proc = subprocess.Popen(_decode_cmd(path, ENV_SR, ch), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
128
|
+
block = w * ch * 4 * 2000
|
|
129
|
+
sums: list[np.ndarray] = []
|
|
130
|
+
rest = b""
|
|
131
|
+
assert proc.stdout is not None
|
|
132
|
+
while True:
|
|
133
|
+
chunk = proc.stdout.read(block)
|
|
134
|
+
if not chunk:
|
|
135
|
+
break
|
|
136
|
+
data = rest + chunk
|
|
137
|
+
usable = len(data) - len(data) % (w * ch * 4)
|
|
138
|
+
rest = data[usable:]
|
|
139
|
+
if not usable:
|
|
140
|
+
continue
|
|
141
|
+
x = np.frombuffer(data[:usable], dtype=np.float32).astype(np.float64).reshape(-1, w, ch)
|
|
142
|
+
if ch == 2:
|
|
143
|
+
left, right = x[:, :, 0], x[:, :, 1]
|
|
144
|
+
mid = 0.5 * (left + right)
|
|
145
|
+
sums.append(np.stack([(left ** 2).mean(1), (right ** 2).mean(1), (mid ** 2).mean(1)], axis=1))
|
|
146
|
+
else:
|
|
147
|
+
sums.append((x[:, :, 0] ** 2).mean(1)[:, None])
|
|
148
|
+
err = proc.stderr.read().decode("utf-8", "replace") if proc.stderr else ""
|
|
149
|
+
proc.wait()
|
|
150
|
+
if proc.returncode != 0 or not sums:
|
|
151
|
+
raise SyncError(f"não consegui ler o áudio de {path.name}: {err.strip()[-400:]}")
|
|
152
|
+
p = np.concatenate(sums, axis=0)
|
|
153
|
+
how = "mix"
|
|
154
|
+
if ch == 2:
|
|
155
|
+
total = p.sum(axis=0) + 1e-20
|
|
156
|
+
best = int(np.argmax(total[:2]))
|
|
157
|
+
if 10 * math.log10(total[2] / total[best]) < -6: # L+R some: fase invertida, usa o canal mais forte
|
|
158
|
+
how = "esquerdo" if best == 0 else "direito"
|
|
159
|
+
p = p[:, best]
|
|
160
|
+
else:
|
|
161
|
+
p = p[:, 2]
|
|
162
|
+
else:
|
|
163
|
+
p = p[:, 0]
|
|
164
|
+
return (10 * np.log10(p + 1e-12)).astype(np.float64), how
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def pan_for(how: str) -> str | None:
|
|
168
|
+
return {"esquerdo": "pan=mono|c0=c0", "direito": "pan=mono|c0=c1"}.get(how)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def read_window(path: Path, how: str, start: float, dur: float, sr: int = FINE_SR) -> np.ndarray:
|
|
172
|
+
pan = pan_for(how)
|
|
173
|
+
proc = subprocess.run(_decode_cmd(path, sr, 1, start, dur, pan), capture_output=True)
|
|
174
|
+
if proc.returncode != 0:
|
|
175
|
+
raise SyncError(f"não consegui ler {path.name} em {start:.2f} s: "
|
|
176
|
+
f"{proc.stderr.decode('utf-8', 'replace').strip()[-300:]}")
|
|
177
|
+
x = np.frombuffer(proc.stdout, dtype=np.float32).astype(np.float64)
|
|
178
|
+
need = int(round(dur * sr))
|
|
179
|
+
if start < 0: # antes do começo do arquivo: silêncio
|
|
180
|
+
pad = int(round(-start * sr))
|
|
181
|
+
x = np.concatenate([np.zeros(pad), x])
|
|
182
|
+
if len(x) < need:
|
|
183
|
+
x = np.concatenate([x, np.zeros(need - len(x))])
|
|
184
|
+
return x[:need]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------ medida
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def feature(db: np.ndarray) -> np.ndarray:
|
|
191
|
+
"""Envelope que casa entre microfones: chão aparado, tendência de 1 s tirada, média 0 e desvio 1."""
|
|
192
|
+
floor = float(np.percentile(db, 10))
|
|
193
|
+
loud = float(np.percentile(db, 95))
|
|
194
|
+
e = np.clip(db, max(floor + 3.0, loud - 45.0), None)
|
|
195
|
+
k = max(1, int(round(DETREND_S / HOP_S)))
|
|
196
|
+
kernel = np.ones(k) / k
|
|
197
|
+
trend = np.convolve(np.pad(e, (k // 2, k - 1 - k // 2), mode="edge"), kernel, mode="valid")
|
|
198
|
+
f = e - trend
|
|
199
|
+
sd = float(f.std())
|
|
200
|
+
return (f - f.mean()) / sd if sd > 1e-9 else np.zeros_like(f)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def coarse_offset(fp: np.ndarray, fx: np.ndarray) -> dict:
|
|
204
|
+
"""Deslocamento (s) de x contra p pelos envelopes: evento em p no tempo t aparece em x em t + d."""
|
|
205
|
+
n, m = len(fp), len(fx)
|
|
206
|
+
size = 1 << int(math.ceil(math.log2(n + m)))
|
|
207
|
+
spec = np.conj(np.fft.rfft(fp, size)) * np.fft.rfft(fx, size)
|
|
208
|
+
raw = np.fft.irfft(spec, size)
|
|
209
|
+
# lag L de -(n-1) a m-1: r[L] = sum p[t] x[t+L]
|
|
210
|
+
lags = np.arange(-(n - 1), m)
|
|
211
|
+
vals = np.concatenate([raw[size - (n - 1):], raw[:m]])
|
|
212
|
+
overlap = np.minimum(n, m - lags) - np.maximum(0, -lags)
|
|
213
|
+
overlap = np.minimum(overlap, np.minimum(n, m))
|
|
214
|
+
need = max(int(MIN_OVERLAP_S / HOP_S), int(MIN_OVERLAP_SHARE * min(n, m)))
|
|
215
|
+
ok = overlap >= need
|
|
216
|
+
if not ok.any():
|
|
217
|
+
raise SyncError("as gravações são curtas demais para comparar (menos de 5 s em comum)")
|
|
218
|
+
curve = np.full(len(vals), -np.inf)
|
|
219
|
+
curve[ok] = vals[ok] / overlap[ok]
|
|
220
|
+
best = int(np.argmax(curve))
|
|
221
|
+
lag = int(lags[best])
|
|
222
|
+
# destaque: o pico contra as outras posições (fora de 1 s em volta dele)
|
|
223
|
+
far = ok & (np.abs(lags - lag) > int(PEAK_EXCLUDE_S / HOP_S))
|
|
224
|
+
others = curve[far]
|
|
225
|
+
destaque = float((curve[best] - others.mean()) / (others.std() + 1e-9)) if others.size > 10 else 0.0
|
|
226
|
+
segundo = float(others.max()) if others.size else 0.0
|
|
227
|
+
# interpolação no pico (parábola)
|
|
228
|
+
frac = 0.0
|
|
229
|
+
if 0 < best < len(curve) - 1 and np.isfinite(curve[best - 1]) and np.isfinite(curve[best + 1]):
|
|
230
|
+
y0, y1, y2 = curve[best - 1], curve[best], curve[best + 1]
|
|
231
|
+
den = y0 - 2 * y1 + y2
|
|
232
|
+
if abs(den) > 1e-12:
|
|
233
|
+
frac = float(np.clip(0.5 * (y0 - y2) / den, -0.5, 0.5))
|
|
234
|
+
# correlação de verdade (Pearson) no trecho em comum
|
|
235
|
+
a0, a1 = max(0, -lag), min(n, m - lag)
|
|
236
|
+
pa, xa = fp[a0:a1], fx[a0 + lag:a1 + lag]
|
|
237
|
+
corr = float(np.corrcoef(pa, xa)[0, 1]) if len(pa) > 10 and pa.std() > 0 and xa.std() > 0 else 0.0
|
|
238
|
+
return {"deslocamento": (lag + frac) * HOP_S, "correlacao": corr, "destaque": destaque,
|
|
239
|
+
"segundo_pico": round(segundo / curve[best], 3) if curve[best] > 0 else None,
|
|
240
|
+
"sobreposicao_s": float(overlap[best]) * HOP_S}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def gcc_phat(a: np.ndarray, b: np.ndarray, max_k: int, sr: int = FINE_SR) -> tuple[float, float]:
|
|
244
|
+
"""(k, nitidez): b[i + k] ~ a[i], k em [0, max_k] com fração de amostra."""
|
|
245
|
+
size = 1 << int(math.ceil(math.log2(len(a) + len(b))))
|
|
246
|
+
win_a = a * np.hanning(len(a))
|
|
247
|
+
A = np.fft.rfft(win_a, size)
|
|
248
|
+
B = np.fft.rfft(b, size)
|
|
249
|
+
R = np.conj(A) * B
|
|
250
|
+
R /= np.abs(R) + 1e-12
|
|
251
|
+
freqs = np.fft.rfftfreq(size, 1.0 / sr)
|
|
252
|
+
R[(freqs < FINE_BAND_HZ[0]) | (freqs > FINE_BAND_HZ[1])] = 0
|
|
253
|
+
r = np.fft.irfft(R, size)[: max_k + 1]
|
|
254
|
+
mag = np.abs(r)
|
|
255
|
+
k = int(np.argmax(mag))
|
|
256
|
+
rest = np.concatenate([mag[: max(0, k - 32)], mag[k + 33:]])
|
|
257
|
+
sharp = float(mag[k] / (np.sqrt(np.mean(rest ** 2)) + 1e-12)) if rest.size else 0.0
|
|
258
|
+
frac = 0.0
|
|
259
|
+
if 0 < k < len(mag) - 1:
|
|
260
|
+
y0, y1, y2 = mag[k - 1], mag[k], mag[k + 1]
|
|
261
|
+
den = y0 - 2 * y1 + y2
|
|
262
|
+
if abs(den) > 1e-12:
|
|
263
|
+
frac = float(np.clip(0.5 * (y0 - y2) / den, -0.5, 0.5))
|
|
264
|
+
return k + frac, sharp
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def window_centers(env_p: np.ndarray, t0: float, t1: float) -> list[float]:
|
|
268
|
+
"""Centros das janelas finas: uma por fatia da parte em comum, onde a principal tem mais fala."""
|
|
269
|
+
span = t1 - t0
|
|
270
|
+
if span < FINE_WIN_S + 0.5:
|
|
271
|
+
return [0.5 * (t0 + t1)] if span > 1.0 else []
|
|
272
|
+
k = int(min(9, max(3, span // 20)))
|
|
273
|
+
k = max(1, min(k, int(span // FINE_WIN_S)))
|
|
274
|
+
lin = 10 ** (env_p / 10)
|
|
275
|
+
win = int(FINE_WIN_S / HOP_S)
|
|
276
|
+
csum = np.concatenate([[0.0], np.cumsum(lin)])
|
|
277
|
+
out = []
|
|
278
|
+
for i in range(k):
|
|
279
|
+
a = t0 + span * i / k + FINE_WIN_S / 2
|
|
280
|
+
b = t0 + span * (i + 1) / k - FINE_WIN_S / 2
|
|
281
|
+
if b < a:
|
|
282
|
+
a = b = t0 + span * (i + 0.5) / k
|
|
283
|
+
ia, ib = int(a / HOP_S), int(b / HOP_S)
|
|
284
|
+
best, best_e = 0.5 * (a + b), -1.0
|
|
285
|
+
for c in range(ia, ib + 1, 25):
|
|
286
|
+
lo, hi = max(0, c - win // 2), min(len(lin), c + win // 2)
|
|
287
|
+
if hi <= lo:
|
|
288
|
+
continue
|
|
289
|
+
e = (csum[hi] - csum[lo]) / (hi - lo)
|
|
290
|
+
if e > best_e:
|
|
291
|
+
best, best_e = c * HOP_S, e
|
|
292
|
+
out.append(best)
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def fine_offsets(p: dict, x: dict, coarse: float, dur_p: float, dur_x: float) -> list[dict]:
|
|
297
|
+
t0 = max(0.0, -coarse) + 0.2
|
|
298
|
+
t1 = min(dur_p, dur_x - coarse) - 0.2
|
|
299
|
+
rows = []
|
|
300
|
+
max_k = int(round(2 * FINE_MARGIN_S * FINE_SR))
|
|
301
|
+
for tc in window_centers(p["env"], t0, t1):
|
|
302
|
+
start_p = tc - FINE_WIN_S / 2
|
|
303
|
+
a = read_window(p["path"], p["how"], start_p, FINE_WIN_S)
|
|
304
|
+
start_x = start_p + coarse - FINE_MARGIN_S
|
|
305
|
+
b = read_window(x["path"], x["how"], start_x, FINE_WIN_S + 2 * FINE_MARGIN_S)
|
|
306
|
+
if np.sqrt(np.mean(a ** 2)) < 1e-5 or np.sqrt(np.mean(b ** 2)) < 1e-5:
|
|
307
|
+
continue
|
|
308
|
+
k, sharp = gcc_phat(a, b, max_k)
|
|
309
|
+
d = start_x - start_p + k / FINE_SR
|
|
310
|
+
rows.append({"em": round(tc, 3), "deslocamento": d, "nitidez": round(sharp, 1)})
|
|
311
|
+
return rows
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def fit_line(rows: list[dict]) -> tuple[float, float, list[dict], float]:
|
|
315
|
+
"""(a, b, janelas usadas, maior resíduo) de d = a + b t, tirando o que foge mais de OUTLIER_S."""
|
|
316
|
+
use = [r for r in rows if r["nitidez"] >= GOOD_SHARPNESS]
|
|
317
|
+
if not use:
|
|
318
|
+
return float("nan"), 0.0, [], 0.0
|
|
319
|
+
for _ in range(3):
|
|
320
|
+
t = np.array([r["em"] for r in use])
|
|
321
|
+
d = np.array([r["deslocamento"] for r in use])
|
|
322
|
+
wts = np.array([r["nitidez"] for r in use])
|
|
323
|
+
if len(use) >= 2 and t.max() - t.min() >= DRIFT_MIN_SPAN_S:
|
|
324
|
+
A = np.stack([np.ones_like(t), t], axis=1) * np.sqrt(wts)[:, None]
|
|
325
|
+
a, b = np.linalg.lstsq(A, d * np.sqrt(wts), rcond=None)[0]
|
|
326
|
+
else:
|
|
327
|
+
a, b = float(np.median(d)), 0.0
|
|
328
|
+
res = d - (a + b * t)
|
|
329
|
+
worst = int(np.argmax(np.abs(res)))
|
|
330
|
+
if abs(res[worst]) <= OUTLIER_S or len(use) <= 3:
|
|
331
|
+
break
|
|
332
|
+
use = [r for i, r in enumerate(use) if i != worst]
|
|
333
|
+
t = np.array([r["em"] for r in use])
|
|
334
|
+
d = np.array([r["deslocamento"] for r in use])
|
|
335
|
+
all_t = np.array([r["em"] for r in rows if r["nitidez"] >= GOOD_SHARPNESS])
|
|
336
|
+
all_d = np.array([r["deslocamento"] for r in rows if r["nitidez"] >= GOOD_SHARPNESS])
|
|
337
|
+
worst_all = float(np.max(np.abs(all_d - (a + b * all_t)))) if all_t.size else 0.0
|
|
338
|
+
return float(a), float(b), use, worst_all
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def measure_pair(p: dict, x: dict, fps: float) -> dict:
|
|
342
|
+
coarse = coarse_offset(p["feat"], x["feat"])
|
|
343
|
+
d0 = coarse["deslocamento"]
|
|
344
|
+
rows = fine_offsets(p, x, d0, p["dur"], x["dur"])
|
|
345
|
+
a, b, used, worst = fit_line(rows)
|
|
346
|
+
avisos: list[str] = []
|
|
347
|
+
span_t = (max(r["em"] for r in used) - min(r["em"] for r in used)) if len(used) >= 2 else 0.0
|
|
348
|
+
t_lo = max(0.0, -d0)
|
|
349
|
+
t_hi = min(p["dur"], x["dur"] - d0)
|
|
350
|
+
over = max(0.0, t_hi - t_lo)
|
|
351
|
+
if not used:
|
|
352
|
+
desloc, escala = d0, 1.0
|
|
353
|
+
avisos.append("não achei janela com fala nítida nas duas para o ajuste fino: ficou só a medida grossa "
|
|
354
|
+
"(uns 10 ms de erro). Confira numa palma")
|
|
355
|
+
else:
|
|
356
|
+
desloc, escala = a, 1.0 + b
|
|
357
|
+
total = abs(b) * over
|
|
358
|
+
if span_t < DRIFT_MIN_SPAN_S or total < DRIFT_APPLY_S:
|
|
359
|
+
desloc, escala = float(np.median([r["deslocamento"] for r in used])), 1.0
|
|
360
|
+
if abs(desloc - d0) > 0.05:
|
|
361
|
+
avisos.append(f"a medida fina ({desloc:+.3f} s) ficou longe da grossa ({d0:+.3f} s): confira numa palma")
|
|
362
|
+
deriva_ms_min = (escala - 1.0) * 60_000.0
|
|
363
|
+
deriva_total = abs(escala - 1.0) * over
|
|
364
|
+
if deriva_total >= DRIFT_WARN_S:
|
|
365
|
+
avisos.append(f"deriva de relógio: a fonte anda {deriva_ms_min:+.1f} ms por minuto contra a principal "
|
|
366
|
+
f"({deriva_total * 1000:.0f} ms ao longo de {over / 60:.1f} min). O render corrige pela reta "
|
|
367
|
+
"(escala no JSON); confira a sincronia no começo e no fim")
|
|
368
|
+
if worst > NONLINEAR_WARN_S:
|
|
369
|
+
far = max((r for r in rows if r["nitidez"] >= GOOD_SHARPNESS),
|
|
370
|
+
key=lambda r: abs(r["deslocamento"] - (desloc + (escala - 1.0) * r["em"])))
|
|
371
|
+
avisos.append(f"a diferença entre as fontes não é uma reta (foge até {worst * 1000:.0f} ms, perto de "
|
|
372
|
+
f"{far['em']:.1f} s da principal): a câmera pode ter parado, perdido quadros ou cortado a "
|
|
373
|
+
"gravação. Confira no olho ali e, se for isso, sincronize cada pedaço à parte")
|
|
374
|
+
corr, dest = coarse["correlacao"], coarse["destaque"]
|
|
375
|
+
if corr >= CORR_BOA and dest >= DESTAQUE_BOM and used and 2 * len(used) >= len(rows):
|
|
376
|
+
conf = "boa"
|
|
377
|
+
elif corr >= CORR_MEDIA and dest >= DESTAQUE_MIN and used:
|
|
378
|
+
conf = "media"
|
|
379
|
+
else:
|
|
380
|
+
conf = "fraca"
|
|
381
|
+
avisos.append(f"correlação fraca (envelope {corr:.2f}, destaque {dest:.1f}): o som das duas quase não se "
|
|
382
|
+
"parece. Não confie; confira numa palma e, se precisar, escreva o deslocamento à mão")
|
|
383
|
+
if coarse["segundo_pico"] is not None and coarse["segundo_pico"] > 0.8 and conf != "fraca":
|
|
384
|
+
avisos.append("há outro deslocamento quase tão bom (som repetitivo, música de fundo): confira numa palma")
|
|
385
|
+
if over < 30:
|
|
386
|
+
avisos.append(f"só {over:.0f} s em comum entre as duas gravações")
|
|
387
|
+
step = 1.0 / fps if fps else 1 / 30
|
|
388
|
+
return {
|
|
389
|
+
"deslocamento": round(desloc, 5), "escala": round(escala, 9), "ajuste_s": 0.0,
|
|
390
|
+
"deriva_ms_por_min": round(deriva_ms_min, 2), "confianca": conf,
|
|
391
|
+
"correlacao": round(corr, 3), "destaque": round(dest, 1), "grosso": round(d0, 4),
|
|
392
|
+
"em_comum_s": round(over, 2), "em_comum_na_principal": [round(t_lo, 3), round(t_hi, 3)],
|
|
393
|
+
"janelas": [{"em": r["em"], "deslocamento": round(r["deslocamento"], 5), "nitidez": r["nitidez"],
|
|
394
|
+
"usada": r in used} for r in rows],
|
|
395
|
+
"maior_desvio_ms": round(worst * 1000, 1), "quadro_s": round(step, 5), "avisos": avisos,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ------------------------------------------------------------------ prova no olho
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def strongest_hit(env: np.ndarray, t0: float, t1: float) -> float:
|
|
403
|
+
"""A batida mais forte (maior subida de volume em 30 ms) entre t0 e t1 da principal."""
|
|
404
|
+
i0, i1 = max(3, int((t0 + 1.6) / HOP_S)), min(len(env), int((t1 - 1.6) / HOP_S))
|
|
405
|
+
if i1 <= i0:
|
|
406
|
+
return 0.5 * (t0 + t1)
|
|
407
|
+
rise = env[i0:i1] - env[i0 - 3:i1 - 3]
|
|
408
|
+
return (i0 + int(np.argmax(rise))) * HOP_S
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def make_proof(doc: dict, name: str, t_p: float, out_dir: Path) -> list[Path]:
|
|
412
|
+
fontes = doc["fontes"]
|
|
413
|
+
p, x = fontes[doc["principal"]], fontes[name]
|
|
414
|
+
pinfo, xinfo = probe_video(p["arquivo"]), probe_video(x["arquivo"])
|
|
415
|
+
if not pinfo.get("has_video") or not xinfo.get("has_video"):
|
|
416
|
+
return []
|
|
417
|
+
t_x = x["deslocamento"] + x.get("ajuste_s", 0.0) + x["escala"] * t_p
|
|
418
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
mp4, png = out_dir / f"sincronia_{name}.mp4", out_dir / f"sincronia_{name}.png"
|
|
420
|
+
fps = float(pinfo.get("fps") or 30.0)
|
|
421
|
+
half = 1.5
|
|
422
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-nostdin", "-ss", f"{max(0.0, t_p - half):.4f}", "-t", f"{2 * half}",
|
|
423
|
+
"-i", p["arquivo"], "-ss", f"{max(0.0, t_x - half):.4f}", "-t", f"{2 * half}", "-i", x["arquivo"],
|
|
424
|
+
"-filter_complex", "[0:v]scale=-2:640,setsar=1[a];[1:v]scale=-2:640,setsar=1[b];[a][b]hstack[v]",
|
|
425
|
+
"-map", "[v]", "-map", "0:a:0?", "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
|
426
|
+
"-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", str(mp4)], capture_output=True)
|
|
427
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
428
|
+
rows = []
|
|
429
|
+
for i, (src, t) in enumerate(((p["arquivo"], t_p), (x["arquivo"], t_x))):
|
|
430
|
+
row = Path(tmp) / f"linha{i}.png"
|
|
431
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-nostdin", "-ss", f"{max(0.0, t - 2 / fps):.4f}", "-i", src,
|
|
432
|
+
"-frames:v", "5", "-vf", "scale=-2:300,setsar=1,tile=5x1:padding=4:color=white",
|
|
433
|
+
"-frames:v", "1", str(row)], capture_output=True)
|
|
434
|
+
if row.exists():
|
|
435
|
+
rows.append(row)
|
|
436
|
+
if len(rows) == 2:
|
|
437
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-nostdin", "-i", str(rows[0]), "-i", str(rows[1]),
|
|
438
|
+
"-filter_complex", "[0:v]pad=iw:ih+8:0:0:white[a];[1:v][a]scale2ref=iw:ow/mdar[b][a2];"
|
|
439
|
+
"[a2][b]vstack", "-frames:v", "1", str(png)], capture_output=True)
|
|
440
|
+
if not png.exists():
|
|
441
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-nostdin", "-i", str(rows[0]), "-i", str(rows[1]),
|
|
442
|
+
"-filter_complex", "[1:v][0:v]scale2ref=iw:ih[b][a];[a][b]vstack", "-frames:v", "1",
|
|
443
|
+
str(png)], capture_output=True)
|
|
444
|
+
return [f for f in (mp4, png) if f.exists()]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ------------------------------------------------------------------ main
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def parse_sources(items: list[str]) -> dict[str, Path]:
|
|
451
|
+
out: dict[str, Path] = {}
|
|
452
|
+
for it in items:
|
|
453
|
+
name, sep, rest = it.partition("=")
|
|
454
|
+
if sep and rest and "/" not in name and "\\" not in name:
|
|
455
|
+
path = Path(rest).expanduser()
|
|
456
|
+
else:
|
|
457
|
+
path = Path(it).expanduser()
|
|
458
|
+
name = path.stem
|
|
459
|
+
if name in out:
|
|
460
|
+
raise SyncError(f"dois arquivos com o nome {name!r}: use NOME=caminho para dar nomes diferentes")
|
|
461
|
+
out[name] = Path(os.path.abspath(path))
|
|
462
|
+
return out
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def sources_from_edl(edl_path: Path) -> dict[str, Path]:
|
|
466
|
+
edl = load_json(edl_path)
|
|
467
|
+
if not isinstance(edl, dict) or not isinstance(edl.get("sources"), dict) or not edl["sources"]:
|
|
468
|
+
raise SyncError(f"{edl_path} não tem \"sources\"")
|
|
469
|
+
base = edl_path.parent
|
|
470
|
+
out = {}
|
|
471
|
+
for k, v in edl["sources"].items():
|
|
472
|
+
p = Path(str(v)).expanduser()
|
|
473
|
+
if not p.is_absolute():
|
|
474
|
+
cands = [base / p, base.parent / p]
|
|
475
|
+
p = next((c for c in cands if c.exists()), cands[0])
|
|
476
|
+
out[str(k)] = Path(os.path.abspath(p))
|
|
477
|
+
return out
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def main(argv: list[str] | None = None) -> int:
|
|
481
|
+
ap = argparse.ArgumentParser(description="Sincroniza câmeras e áudio de lapela gravados ao mesmo tempo pela onda.")
|
|
482
|
+
ap.add_argument("fontes", nargs="*", help="arquivos (ou NOME=arquivo); o primeiro é a principal, se não houver --principal")
|
|
483
|
+
ap.add_argument("--edl", type=Path, help="edl_manual.json: sincroniza as fontes de \"sources\"")
|
|
484
|
+
ap.add_argument("--principal", help="nome da fonte principal (padrão: a primeira)")
|
|
485
|
+
ap.add_argument("-o", "--saida", type=Path, help="JSON de saída (padrão com --edl: sincronia.json na pasta do EDL)")
|
|
486
|
+
ap.add_argument("--prova", action="store_true", help="grava verify/sincronia_<NOME>.mp4 e .png para conferir no olho")
|
|
487
|
+
ap.add_argument("--em", type=float, help="com --prova: segundo da principal para a prova (padrão: a batida mais forte)")
|
|
488
|
+
args = ap.parse_args(argv)
|
|
489
|
+
utf8_console()
|
|
490
|
+
try:
|
|
491
|
+
require_binary("ffmpeg")
|
|
492
|
+
require_binary("ffprobe")
|
|
493
|
+
sources = sources_from_edl(args.edl.expanduser().absolute()) if args.edl else {}
|
|
494
|
+
sources.update(parse_sources(args.fontes))
|
|
495
|
+
if len(sources) < 2:
|
|
496
|
+
raise SyncError("preciso de pelo menos dois arquivos gravados ao mesmo tempo")
|
|
497
|
+
missing = [f"{k} ({v})" for k, v in sources.items() if not v.is_file()]
|
|
498
|
+
if missing:
|
|
499
|
+
raise SyncError("não encontrei: " + ", ".join(missing))
|
|
500
|
+
principal = args.principal or next(iter(sources))
|
|
501
|
+
if principal not in sources:
|
|
502
|
+
raise SyncError(f"--principal {principal!r} não é uma das fontes ({', '.join(sources)})")
|
|
503
|
+
saida = args.saida or (args.edl.expanduser().absolute().parent / "sincronia.json" if args.edl else None)
|
|
504
|
+
if saida is None:
|
|
505
|
+
raise SyncError("diga onde gravar: -o <EDIT>/sincronia.json")
|
|
506
|
+
saida = Path(os.path.abspath(saida.expanduser()))
|
|
507
|
+
|
|
508
|
+
data: dict[str, dict] = {}
|
|
509
|
+
for name, path in sources.items():
|
|
510
|
+
info = audio_facts(path)
|
|
511
|
+
env, how = envelope(path, info)
|
|
512
|
+
data[name] = {"path": path, "info": info, "env": env, "how": how, "feat": feature(env),
|
|
513
|
+
"dur": len(env) * HOP_S}
|
|
514
|
+
old = load_json(saida, default={}) or {}
|
|
515
|
+
old_fontes = old.get("fontes") if isinstance(old, dict) and isinstance(old.get("fontes"), dict) else {}
|
|
516
|
+
p = data[principal]
|
|
517
|
+
fps = float(p["info"].get("fps") or 0) or 30.0
|
|
518
|
+
fontes: dict[str, dict] = {}
|
|
519
|
+
for name, d in data.items():
|
|
520
|
+
base = {"arquivo": str(d["path"]), "assinatura": file_signature(d["path"]),
|
|
521
|
+
"tem_imagem": bool(d["info"].get("has_video")), "canal": d["how"]}
|
|
522
|
+
if name == principal:
|
|
523
|
+
fontes[name] = {**base, "deslocamento": 0.0, "escala": 1.0, "ajuste_s": 0.0, "confianca": "principal"}
|
|
524
|
+
continue
|
|
525
|
+
res = measure_pair(p, d, fps)
|
|
526
|
+
prev = old_fontes.get(name) if isinstance(old_fontes.get(name), dict) else None
|
|
527
|
+
if prev and prev.get("arquivo") == str(d["path"]) and old.get("principal") == principal:
|
|
528
|
+
try:
|
|
529
|
+
res["ajuste_s"] = float(prev.get("ajuste_s") or 0.0)
|
|
530
|
+
except (TypeError, ValueError):
|
|
531
|
+
pass
|
|
532
|
+
fontes[name] = {**base, **res}
|
|
533
|
+
doc = {"versao": VERSION, "criado_em": now_iso(), "principal": principal,
|
|
534
|
+
"regra": "tempo na fonte = deslocamento + ajuste_s + escala x tempo na principal",
|
|
535
|
+
"fontes": fontes}
|
|
536
|
+
save_json(saida, doc)
|
|
537
|
+
except SyncError as exc:
|
|
538
|
+
print(f"erro: {exc}", file=sys.stderr)
|
|
539
|
+
return 2
|
|
540
|
+
|
|
541
|
+
print(f"Sincronia contra {principal} ({Path(fontes[principal]['arquivo']).name}):")
|
|
542
|
+
fraca = False
|
|
543
|
+
for name, f in fontes.items():
|
|
544
|
+
if name == principal:
|
|
545
|
+
continue
|
|
546
|
+
frames = f["deslocamento"] * fps
|
|
547
|
+
extra = f", ajuste à mão {f['ajuste_s']:+.3f} s" if f.get("ajuste_s") else ""
|
|
548
|
+
drift = f", deriva {f['deriva_ms_por_min']:+.1f} ms/min" if f["escala"] != 1.0 else ""
|
|
549
|
+
print(f" {name}: {f['deslocamento']:+.3f} s ({frames:+.1f} quadros){drift}{extra} · confiança {f['confianca']} "
|
|
550
|
+
f"(correlação {f['correlacao']:.2f}, destaque {f['destaque']:.0f}, "
|
|
551
|
+
f"{sum(1 for j in f['janelas'] if j['usada'])}/{len(f['janelas'])} janelas finas)")
|
|
552
|
+
for a in f["avisos"]:
|
|
553
|
+
print(f" aviso: {a}")
|
|
554
|
+
fraca = fraca or f["confianca"] == "fraca"
|
|
555
|
+
print(f" gravado em {saida}")
|
|
556
|
+
if args.prova:
|
|
557
|
+
for name, f in fontes.items():
|
|
558
|
+
if name == principal:
|
|
559
|
+
continue
|
|
560
|
+
lo, hi = f["em_comum_na_principal"]
|
|
561
|
+
t = args.em if args.em is not None else strongest_hit(data[principal]["env"], lo, hi)
|
|
562
|
+
made = make_proof(doc, name, t, saida.parent / "verify")
|
|
563
|
+
if made:
|
|
564
|
+
print(f" prova de {name} em {t:.2f} s da principal: " + ", ".join(str(m) for m in made))
|
|
565
|
+
else:
|
|
566
|
+
print(f" prova de {name}: uma das duas não tem imagem; confira de ouvido")
|
|
567
|
+
return 1 if fraca else 0
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
if __name__ == "__main__":
|
|
571
|
+
sys.exit(main())
|