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.
Files changed (245) hide show
  1. package/LICENSES/edit-ia-pro.txt +32 -0
  2. package/LICENSES/fontes-OFL.txt +476 -0
  3. package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
  4. package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
  5. package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
  6. package/LICENSES/three-MIT.txt +23 -0
  7. package/LICENSES/video-use-MIT.txt +24 -0
  8. package/LICENSES/woven-sfx-CC0.txt +16 -0
  9. package/LICENSES/yunet-MIT.txt +21 -0
  10. package/README.md +2 -2
  11. package/bin/primocode.js +74 -9
  12. package/editor/LICENSE.md +11 -0
  13. package/editor/README.md +321 -0
  14. package/editor/SKILL.md +307 -0
  15. package/editor/THIRD_PARTY_NOTICES.md +228 -0
  16. package/editor/assets/baixar.json +591 -0
  17. package/editor/assets/fonts/anton/OFL.txt +93 -0
  18. package/editor/assets/fonts/caveat/OFL.txt +93 -0
  19. package/editor/assets/fonts/figtree/OFL.txt +93 -0
  20. package/editor/assets/fonts/fonts.json +10 -0
  21. package/editor/assets/fonts/montserrat/OFL.txt +93 -0
  22. package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
  23. package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
  24. package/editor/assets/models/YUNET_LICENSE.txt +21 -0
  25. package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
  26. package/editor/assets/sfx/licencas/cash.txt +76 -0
  27. package/editor/assets/sfx/licencas/charge.txt +31 -0
  28. package/editor/assets/sfx/licencas/click.txt +46 -0
  29. package/editor/assets/sfx/licencas/coin.txt +50 -0
  30. package/editor/assets/sfx/licencas/count.txt +47 -0
  31. package/editor/assets/sfx/licencas/data.txt +31 -0
  32. package/editor/assets/sfx/licencas/error.txt +32 -0
  33. package/editor/assets/sfx/licencas/flash.txt +31 -0
  34. package/editor/assets/sfx/licencas/gears.txt +82 -0
  35. package/editor/assets/sfx/licencas/keyboard.txt +48 -0
  36. package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
  37. package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
  38. package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
  39. package/editor/assets/sfx/licencas/notification.txt +54 -0
  40. package/editor/assets/sfx/licencas/pop.txt +38 -0
  41. package/editor/assets/sfx/licencas/rewind.txt +32 -0
  42. package/editor/assets/sfx/licencas/rise.txt +46 -0
  43. package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
  44. package/editor/assets/sfx/licencas/snap.txt +50 -0
  45. package/editor/assets/sfx/licencas/success.txt +40 -0
  46. package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
  47. package/editor/assets/sfx/licencas/tick.txt +14 -0
  48. package/editor/assets/sfx/licencas/whoosh.txt +47 -0
  49. package/editor/assets/sfx/licencas/wind.txt +31 -0
  50. package/editor/assets/sfx/sfx.json +832 -0
  51. package/editor/assets/sfx/sfx_sinteticos.json +317 -0
  52. package/editor/assets/sfx/sfx_woven.json +387 -0
  53. package/editor/assets/sfx/woven/LICENCA.md +16 -0
  54. package/editor/fichas/briefing-do-video.md +45 -0
  55. package/editor/fichas/conferencia-da-amostra.md +56 -0
  56. package/editor/fichas/matriz-de-ganchos.csv +3 -0
  57. package/editor/fichas/padrao.json +16 -0
  58. package/editor/fichas/planilha-de-cenas.csv +4 -0
  59. package/editor/fichas/planilha-de-versoes.csv +4 -0
  60. package/editor/fichas/plano-em-blocos.md +32 -0
  61. package/editor/fichas/registro-de-materiais.json +27 -0
  62. package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
  63. package/editor/helpers/bordas_precisas.py +272 -0
  64. package/editor/helpers/camera_viva.py +426 -0
  65. package/editor/helpers/captions.py +601 -0
  66. package/editor/helpers/common.py +308 -0
  67. package/editor/helpers/compose_final.py +3555 -0
  68. package/editor/helpers/conferir_corte.py +234 -0
  69. package/editor/helpers/conferir_dados.py +405 -0
  70. package/editor/helpers/contact_sheet.py +282 -0
  71. package/editor/helpers/corte_por_onda.py +433 -0
  72. package/editor/helpers/corte_seco.py +595 -0
  73. package/editor/helpers/denoise.py +971 -0
  74. package/editor/helpers/efeitos.py +408 -0
  75. package/editor/helpers/efeitos_gerar.py +159 -0
  76. package/editor/helpers/elevenlabs_api.py +442 -0
  77. package/editor/helpers/face_layout.py +570 -0
  78. package/editor/helpers/fetch_images.py +423 -0
  79. package/editor/helpers/ganho_por_trecho.py +214 -0
  80. package/editor/helpers/gen_image.py +312 -0
  81. package/editor/helpers/gen_music.py +316 -0
  82. package/editor/helpers/gen_sfx.py +148 -0
  83. package/editor/helpers/grade.py +307 -0
  84. package/editor/helpers/icloud.py +119 -0
  85. package/editor/helpers/make_sfx.py +940 -0
  86. package/editor/helpers/matte.py +1342 -0
  87. package/editor/helpers/medir_referencia.py +2232 -0
  88. package/editor/helpers/mixar_trilha.py +354 -0
  89. package/editor/helpers/montar_corte.py +295 -0
  90. package/editor/helpers/motion/__init__.py +13 -0
  91. package/editor/helpers/motion/engine.py +1915 -0
  92. package/editor/helpers/motion/registry.py +510 -0
  93. package/editor/helpers/motion/render.py +665 -0
  94. package/editor/helpers/motion/templates/barra_progresso.py +370 -0
  95. package/editor/helpers/motion/templates/carimbo.py +132 -0
  96. package/editor/helpers/motion/templates/checklist.py +271 -0
  97. package/editor/helpers/motion/templates/comentario_cta.py +380 -0
  98. package/editor/helpers/motion/templates/contador.py +222 -0
  99. package/editor/helpers/motion/templates/destaque.py +263 -0
  100. package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
  101. package/editor/helpers/motion/templates/grafico_barras.py +510 -0
  102. package/editor/helpers/motion/templates/grafico_linha.py +462 -0
  103. package/editor/helpers/motion/templates/notificacao.py +266 -0
  104. package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
  105. package/editor/helpers/motion/templates/passos.py +321 -0
  106. package/editor/helpers/motion/templates/riscado.py +298 -0
  107. package/editor/helpers/motion/templates/selo_garantia.py +150 -0
  108. package/editor/helpers/motion_render.py +241 -0
  109. package/editor/helpers/motion_sugerir.py +961 -0
  110. package/editor/helpers/noise_check.py +134 -0
  111. package/editor/helpers/pack_transcripts.py +223 -0
  112. package/editor/helpers/palavras_na_onda.py +176 -0
  113. package/editor/helpers/plano_sfx.py +839 -0
  114. package/editor/helpers/portao.py +427 -0
  115. package/editor/helpers/preview_server.py +1177 -0
  116. package/editor/helpers/rastrear_rosto.py +298 -0
  117. package/editor/helpers/render_cut.py +1256 -0
  118. package/editor/helpers/respiros.py +89 -0
  119. package/editor/helpers/sincronizar_cameras.py +571 -0
  120. package/editor/helpers/speech_regions.py +423 -0
  121. package/editor/helpers/style_catalog.json +231 -0
  122. package/editor/helpers/style_samples.py +246 -0
  123. package/editor/helpers/tela_verde.py +2113 -0
  124. package/editor/helpers/text_render.py +2059 -0
  125. package/editor/helpers/texto_na_tela.py +366 -0
  126. package/editor/helpers/timeline_view.py +292 -0
  127. package/editor/helpers/tira_de_quadros.py +113 -0
  128. package/editor/helpers/transcribe.py +734 -0
  129. package/editor/helpers/transcribe_batch.py +129 -0
  130. package/editor/helpers/trilhas.py +1146 -0
  131. package/editor/helpers/trilhas_humores.json +66 -0
  132. package/editor/helpers/valores.py +187 -0
  133. package/editor/helpers/verify_cut.py +820 -0
  134. package/editor/helpers/voice_levels.py +233 -0
  135. package/editor/helpers/voz_check.py +386 -0
  136. package/editor/helpers/voz_limpa.py +81 -0
  137. package/editor/helpers/watch_preview.py +372 -0
  138. package/editor/modelo-projeto/LEIA.md +402 -0
  139. package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
  140. package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
  141. package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
  142. package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
  143. package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
  144. package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
  145. package/editor/modelo-projeto/assets/three/LICENSE +21 -0
  146. package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
  147. package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
  148. package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
  149. package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
  150. package/editor/modelo-projeto/build.py +4828 -0
  151. package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
  152. package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
  153. package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
  154. package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
  155. package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
  156. package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
  157. package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
  158. package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
  159. package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
  160. package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
  161. package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
  162. package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
  163. package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
  164. package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
  165. package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
  166. package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
  167. package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
  168. package/editor/modelo-projeto/hyperframes.json +12 -0
  169. package/editor/modelo-projeto/package.json +11 -0
  170. package/editor/modelo-projeto/projeto.json +16 -0
  171. package/editor/preview/app.css +1009 -0
  172. package/editor/preview/app.js +2122 -0
  173. package/editor/preview/index.html +307 -0
  174. package/editor/pyproject.toml +23 -0
  175. package/editor/references/3d.md +236 -0
  176. package/editor/references/animacao-catalogo.md +672 -0
  177. package/editor/references/animacao.md +498 -0
  178. package/editor/references/comandos.md +92 -0
  179. package/editor/references/corte-seco-como-funciona.md +50 -0
  180. package/editor/references/corte-seco.md +88 -0
  181. package/editor/references/corte.md +290 -0
  182. package/editor/references/direcao-por-blocos.md +218 -0
  183. package/editor/references/enquadramento.md +654 -0
  184. package/editor/references/estilos-prontos.md +355 -0
  185. package/editor/references/estudio.md +100 -0
  186. package/editor/references/exemplo-eventos.json +67 -0
  187. package/editor/references/lettering.md +252 -0
  188. package/editor/references/modelos.md +494 -0
  189. package/editor/references/phase2.md +1007 -0
  190. package/editor/references/referencia-aplicar.md +203 -0
  191. package/editor/references/referencia-medir.md +375 -0
  192. package/editor/references/revisao.md +335 -0
  193. package/editor/references/som-e-trilha.md +146 -0
  194. package/editor/references/tabela-de-sons.md +130 -0
  195. package/editor/references/variacoes.md +234 -0
  196. package/editor/uv.lock +1141 -0
  197. package/lib/act.js +38 -8
  198. package/lib/app-chat.js +97 -0
  199. package/lib/catalogo.js +7 -1
  200. package/lib/chamada-torta.js +81 -0
  201. package/lib/editor-pro.js +786 -0
  202. package/lib/entrega.js +58 -0
  203. package/lib/estudio/conferencia-visual.js +76 -0
  204. package/lib/estudio/diretor.js +6 -1
  205. package/lib/estudio/exportar.js +3 -1
  206. package/lib/estudio/gosto/tela.html +1349 -0
  207. package/lib/estudio/gosto.js +305 -0
  208. package/lib/estudio/index.js +12 -1
  209. package/lib/estudio/kit-ae/base.jsx +119 -13
  210. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  211. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  212. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  213. package/lib/estudio/maquina-diretor.js +70 -12
  214. package/lib/estudio/maquina-foto.js +177 -0
  215. package/lib/estudio/maquina-guarda.js +207 -0
  216. package/lib/estudio/maquina-layout.js +22 -1
  217. package/lib/estudio/maquina.js +64 -11
  218. package/lib/estudio/referencias.js +1 -1
  219. package/lib/estudio/visao/visao.swift +97 -0
  220. package/lib/estudio/visao.js +60 -0
  221. package/lib/headless.js +15 -3
  222. package/lib/local/cerebro.json +285 -7
  223. package/lib/local/motor.js +5 -2
  224. package/lib/memoria-usuario.js +164 -0
  225. package/lib/nuvem.js +33 -2
  226. package/lib/passagem.js +50 -0
  227. package/lib/perigo.js +19 -11
  228. package/lib/protecao.js +134 -0
  229. package/lib/repeticao.js +1 -1
  230. package/lib/sandbox.js +57 -9
  231. package/lib/titulo-conversa.js +46 -0
  232. package/lib/tools.js +23 -4
  233. package/lib/ui.js +2 -2
  234. package/package.json +3 -2
  235. package/skills/editar-meu-video/SKILL.md +6 -2
  236. package/skills/editar-video/SKILL.md +113 -0
  237. package/studio/pessoa.py +52 -0
  238. package/voz/primo_voz/__main__.py +55 -40
  239. package/voz/primo_voz/app.py +500 -4
  240. package/voz/primo_voz/chat.html +1773 -0
  241. package/voz/primo_voz/conversa.py +0 -26
  242. package/voz/primo_voz/hud.html +8 -2
  243. package/voz/primo_voz/hud.py +129 -5
  244. package/voz/primo_voz/identidade.py +1 -1
  245. 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())