primocode 10.16.0 → 10.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/edit-ia-pro.txt +32 -0
- package/LICENSES/fontes-OFL.txt +476 -0
- package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
- package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
- package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
- package/LICENSES/three-MIT.txt +23 -0
- package/LICENSES/video-use-MIT.txt +24 -0
- package/LICENSES/woven-sfx-CC0.txt +16 -0
- package/LICENSES/yunet-MIT.txt +21 -0
- package/README.md +2 -2
- package/bin/primocode.js +11 -2
- package/editor/LICENSE.md +11 -0
- package/editor/README.md +321 -0
- package/editor/SKILL.md +307 -0
- package/editor/THIRD_PARTY_NOTICES.md +228 -0
- package/editor/assets/baixar.json +591 -0
- package/editor/assets/fonts/anton/OFL.txt +93 -0
- package/editor/assets/fonts/caveat/OFL.txt +93 -0
- package/editor/assets/fonts/figtree/OFL.txt +93 -0
- package/editor/assets/fonts/fonts.json +10 -0
- package/editor/assets/fonts/montserrat/OFL.txt +93 -0
- package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
- package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
- package/editor/assets/models/YUNET_LICENSE.txt +21 -0
- package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
- package/editor/assets/sfx/licencas/cash.txt +76 -0
- package/editor/assets/sfx/licencas/charge.txt +31 -0
- package/editor/assets/sfx/licencas/click.txt +46 -0
- package/editor/assets/sfx/licencas/coin.txt +50 -0
- package/editor/assets/sfx/licencas/count.txt +47 -0
- package/editor/assets/sfx/licencas/data.txt +31 -0
- package/editor/assets/sfx/licencas/error.txt +32 -0
- package/editor/assets/sfx/licencas/flash.txt +31 -0
- package/editor/assets/sfx/licencas/gears.txt +82 -0
- package/editor/assets/sfx/licencas/keyboard.txt +48 -0
- package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
- package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
- package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
- package/editor/assets/sfx/licencas/notification.txt +54 -0
- package/editor/assets/sfx/licencas/pop.txt +38 -0
- package/editor/assets/sfx/licencas/rewind.txt +32 -0
- package/editor/assets/sfx/licencas/rise.txt +46 -0
- package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
- package/editor/assets/sfx/licencas/snap.txt +50 -0
- package/editor/assets/sfx/licencas/success.txt +40 -0
- package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
- package/editor/assets/sfx/licencas/tick.txt +14 -0
- package/editor/assets/sfx/licencas/whoosh.txt +47 -0
- package/editor/assets/sfx/licencas/wind.txt +31 -0
- package/editor/assets/sfx/sfx.json +832 -0
- package/editor/assets/sfx/sfx_sinteticos.json +317 -0
- package/editor/assets/sfx/sfx_woven.json +387 -0
- package/editor/assets/sfx/woven/LICENCA.md +16 -0
- package/editor/fichas/briefing-do-video.md +45 -0
- package/editor/fichas/conferencia-da-amostra.md +56 -0
- package/editor/fichas/matriz-de-ganchos.csv +3 -0
- package/editor/fichas/padrao.json +16 -0
- package/editor/fichas/planilha-de-cenas.csv +4 -0
- package/editor/fichas/planilha-de-versoes.csv +4 -0
- package/editor/fichas/plano-em-blocos.md +32 -0
- package/editor/fichas/registro-de-materiais.json +27 -0
- package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
- package/editor/helpers/bordas_precisas.py +272 -0
- package/editor/helpers/camera_viva.py +426 -0
- package/editor/helpers/captions.py +601 -0
- package/editor/helpers/common.py +308 -0
- package/editor/helpers/compose_final.py +3555 -0
- package/editor/helpers/conferir_corte.py +234 -0
- package/editor/helpers/conferir_dados.py +405 -0
- package/editor/helpers/contact_sheet.py +282 -0
- package/editor/helpers/corte_por_onda.py +433 -0
- package/editor/helpers/corte_seco.py +595 -0
- package/editor/helpers/denoise.py +971 -0
- package/editor/helpers/efeitos.py +408 -0
- package/editor/helpers/efeitos_gerar.py +159 -0
- package/editor/helpers/elevenlabs_api.py +442 -0
- package/editor/helpers/face_layout.py +570 -0
- package/editor/helpers/fetch_images.py +423 -0
- package/editor/helpers/ganho_por_trecho.py +214 -0
- package/editor/helpers/gen_image.py +312 -0
- package/editor/helpers/gen_music.py +316 -0
- package/editor/helpers/gen_sfx.py +148 -0
- package/editor/helpers/grade.py +307 -0
- package/editor/helpers/icloud.py +119 -0
- package/editor/helpers/make_sfx.py +940 -0
- package/editor/helpers/matte.py +1342 -0
- package/editor/helpers/medir_referencia.py +2232 -0
- package/editor/helpers/mixar_trilha.py +354 -0
- package/editor/helpers/montar_corte.py +295 -0
- package/editor/helpers/motion/__init__.py +13 -0
- package/editor/helpers/motion/engine.py +1915 -0
- package/editor/helpers/motion/registry.py +510 -0
- package/editor/helpers/motion/render.py +665 -0
- package/editor/helpers/motion/templates/barra_progresso.py +370 -0
- package/editor/helpers/motion/templates/carimbo.py +132 -0
- package/editor/helpers/motion/templates/checklist.py +271 -0
- package/editor/helpers/motion/templates/comentario_cta.py +380 -0
- package/editor/helpers/motion/templates/contador.py +222 -0
- package/editor/helpers/motion/templates/destaque.py +263 -0
- package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
- package/editor/helpers/motion/templates/grafico_barras.py +510 -0
- package/editor/helpers/motion/templates/grafico_linha.py +462 -0
- package/editor/helpers/motion/templates/notificacao.py +266 -0
- package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
- package/editor/helpers/motion/templates/passos.py +321 -0
- package/editor/helpers/motion/templates/riscado.py +298 -0
- package/editor/helpers/motion/templates/selo_garantia.py +150 -0
- package/editor/helpers/motion_render.py +241 -0
- package/editor/helpers/motion_sugerir.py +961 -0
- package/editor/helpers/noise_check.py +134 -0
- package/editor/helpers/pack_transcripts.py +223 -0
- package/editor/helpers/palavras_na_onda.py +176 -0
- package/editor/helpers/plano_sfx.py +839 -0
- package/editor/helpers/portao.py +427 -0
- package/editor/helpers/preview_server.py +1177 -0
- package/editor/helpers/rastrear_rosto.py +298 -0
- package/editor/helpers/render_cut.py +1256 -0
- package/editor/helpers/respiros.py +89 -0
- package/editor/helpers/sincronizar_cameras.py +571 -0
- package/editor/helpers/speech_regions.py +423 -0
- package/editor/helpers/style_catalog.json +231 -0
- package/editor/helpers/style_samples.py +246 -0
- package/editor/helpers/tela_verde.py +2113 -0
- package/editor/helpers/text_render.py +2059 -0
- package/editor/helpers/texto_na_tela.py +366 -0
- package/editor/helpers/timeline_view.py +292 -0
- package/editor/helpers/tira_de_quadros.py +113 -0
- package/editor/helpers/transcribe.py +734 -0
- package/editor/helpers/transcribe_batch.py +129 -0
- package/editor/helpers/trilhas.py +1146 -0
- package/editor/helpers/trilhas_humores.json +66 -0
- package/editor/helpers/valores.py +187 -0
- package/editor/helpers/verify_cut.py +820 -0
- package/editor/helpers/voice_levels.py +233 -0
- package/editor/helpers/voz_check.py +386 -0
- package/editor/helpers/voz_limpa.py +81 -0
- package/editor/helpers/watch_preview.py +372 -0
- package/editor/modelo-projeto/LEIA.md +402 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
- package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
- package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
- package/editor/modelo-projeto/assets/three/LICENSE +21 -0
- package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
- package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
- package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
- package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
- package/editor/modelo-projeto/build.py +4828 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
- package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
- package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
- package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
- package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
- package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
- package/editor/modelo-projeto/hyperframes.json +12 -0
- package/editor/modelo-projeto/package.json +11 -0
- package/editor/modelo-projeto/projeto.json +16 -0
- package/editor/preview/app.css +1009 -0
- package/editor/preview/app.js +2122 -0
- package/editor/preview/index.html +307 -0
- package/editor/pyproject.toml +23 -0
- package/editor/references/3d.md +236 -0
- package/editor/references/animacao-catalogo.md +672 -0
- package/editor/references/animacao.md +498 -0
- package/editor/references/comandos.md +92 -0
- package/editor/references/corte-seco-como-funciona.md +50 -0
- package/editor/references/corte-seco.md +88 -0
- package/editor/references/corte.md +290 -0
- package/editor/references/direcao-por-blocos.md +218 -0
- package/editor/references/enquadramento.md +654 -0
- package/editor/references/estilos-prontos.md +355 -0
- package/editor/references/estudio.md +100 -0
- package/editor/references/exemplo-eventos.json +67 -0
- package/editor/references/lettering.md +252 -0
- package/editor/references/modelos.md +494 -0
- package/editor/references/phase2.md +1007 -0
- package/editor/references/referencia-aplicar.md +203 -0
- package/editor/references/referencia-medir.md +375 -0
- package/editor/references/revisao.md +335 -0
- package/editor/references/som-e-trilha.md +146 -0
- package/editor/references/tabela-de-sons.md +130 -0
- package/editor/references/variacoes.md +234 -0
- package/editor/uv.lock +1141 -0
- package/lib/act.js +36 -8
- package/lib/catalogo.js +4 -0
- package/lib/chamada-torta.js +81 -0
- package/lib/editor-pro.js +786 -0
- package/lib/entrega.js +58 -0
- package/lib/estudio/conferencia-visual.js +76 -0
- package/lib/estudio/diretor.js +6 -1
- package/lib/estudio/exportar.js +3 -1
- package/lib/estudio/gosto.js +45 -4
- package/lib/estudio/index.js +8 -5
- package/lib/estudio/kit-ae/base.jsx +119 -13
- package/lib/estudio/kit-ae/cenas.jsx +47 -15
- package/lib/estudio/kit-ae/guarda.jsx +274 -0
- package/lib/estudio/kit-ae/primitivas.jsx +180 -24
- package/lib/estudio/maquina-diretor.js +61 -7
- package/lib/estudio/maquina-foto.js +177 -0
- package/lib/estudio/maquina-guarda.js +207 -0
- package/lib/estudio/maquina-layout.js +22 -1
- package/lib/estudio/maquina.js +60 -11
- package/lib/estudio/referencias.js +1 -1
- package/lib/estudio/visao/visao.swift +97 -0
- package/lib/estudio/visao.js +60 -0
- package/lib/local/cerebro.json +192 -6
- package/lib/local/motor.js +5 -2
- package/lib/nuvem.js +11 -1
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +19 -11
- package/lib/protecao.js +134 -0
- package/lib/titulo-conversa.js +46 -0
- package/lib/tools.js +3 -0
- package/lib/ui.js +1 -1
- package/package.json +3 -2
- package/skills/editar-meu-video/SKILL.md +6 -2
- package/skills/editar-video/SKILL.md +113 -0
- package/studio/pessoa.py +52 -0
- package/voz/primo_voz/app.py +43 -4
- package/voz/primo_voz/chat.html +17 -6
- package/voz/primo_voz/painel_dados.py +134 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mixa a trilha contínua por baixo da voz, com duck (sidechain), no vídeo JÁ pronto (references/som-e-trilha.md).
|
|
3
|
+
|
|
4
|
+
A trilha entra depois do corte seco, por cima de tudo, sem pular nos cortes. O script:
|
|
5
|
+
1. mede a voz do vídeo (LUFS integrado);
|
|
6
|
+
2. acerta o ganho da trilha pra ela ficar N dB abaixo da voz (medido na trilha como ela sai, já com o duck);
|
|
7
|
+
3. abaixa a trilha quando a voz entra (sidechaincompress guiado pela voz) e devolve nas pausas longas;
|
|
8
|
+
4. opcional: entra num segundo escolhido (--entrada-em) com um respiro mais alto no começo (--respiro);
|
|
9
|
+
5. fecha com fade no fim e deixa o vídeo em -14 LUFS (loudnorm em 2 passadas, linear); o fade do fim nunca passa de
|
|
10
|
+
um terço do tempo em que a trilha toca (com --entrada-em perto do fim, 1,5 s de fade engoliria a trilha inteira:
|
|
11
|
+
ele diminui sozinho e avisa);
|
|
12
|
+
6. confere: dB abaixo da voz, quanto o duck abaixou, duração, voz no lugar (desvio em ms), volume final.
|
|
13
|
+
|
|
14
|
+
Uso (<SKILL> é a pasta desta skill; o Python dela tem numpy):
|
|
15
|
+
uv run --directory "<SKILL>" python helpers/mixar_trilha.py --video final.mp4 --trilha musica.wav
|
|
16
|
+
[--abaixo-da-voz 22] [--entrada-em S] [--respiro [SEG]] [--respiro-db 10] [--duck 8] [--fade-fim 1.5]
|
|
17
|
+
[--inicio-trilha 0] [--saida saida.mp4]
|
|
18
|
+
|
|
19
|
+
Pegadinha do ffmpeg 8: o sidechaincompress corta a saída quando uma das entradas acaba antes. Por isso todas as
|
|
20
|
+
entradas passam por apad + atrim na duração exata do vídeo.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import numpy as np
|
|
36
|
+
except ImportError: # pragma: no cover
|
|
37
|
+
sys.exit('Falta o numpy. Rode com o Python da skill: uv run --directory "<SKILL>" python helpers/mixar_trilha.py ...')
|
|
38
|
+
|
|
39
|
+
XFADE_LACO = 1.0 # segundos de crossfade quando a trilha é mais curta que o vídeo e precisa repetir
|
|
40
|
+
FADE_FIM_PADRAO = 1.5 # segundos de fade da trilha no fim
|
|
41
|
+
FADE_FIM_FRACAO = 1 / 3 # o fade do fim ocupa no máximo isso do tempo em que a trilha toca (quando não foi pedido)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def erro(msg: str, code: int = 2):
|
|
45
|
+
print("ERRO: " + msg, file=sys.stderr)
|
|
46
|
+
sys.exit(code)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run(cmd: list, **kw):
|
|
50
|
+
r = subprocess.run(cmd, capture_output=True, text=True, **kw)
|
|
51
|
+
if r.returncode:
|
|
52
|
+
erro("o ffmpeg falhou:\n" + r.stderr[-2000:])
|
|
53
|
+
return r
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sonda(p: str) -> dict:
|
|
57
|
+
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
|
|
58
|
+
"stream=codec_type,duration,sample_rate:format=duration", "-of", "json", p],
|
|
59
|
+
capture_output=True, text=True)
|
|
60
|
+
if r.returncode:
|
|
61
|
+
erro(f"não consegui abrir {p}")
|
|
62
|
+
d = json.loads(r.stdout)
|
|
63
|
+
out = dict(formato=float(d.get("format", {}).get("duration", 0) or 0), video=None, audio=None)
|
|
64
|
+
for s in d.get("streams", []):
|
|
65
|
+
if s.get("codec_type") in ("video", "audio") and out[s["codec_type"]] is None:
|
|
66
|
+
out[s["codec_type"]] = float(s.get("duration") or out["formato"])
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def loudness(p: str, af: str = "") -> dict:
|
|
71
|
+
r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", p, "-vn", "-af",
|
|
72
|
+
af + "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"],
|
|
73
|
+
capture_output=True, text=True).stderr
|
|
74
|
+
try:
|
|
75
|
+
return json.loads(r[r.rindex("{"): r.rindex("}") + 1])
|
|
76
|
+
except ValueError:
|
|
77
|
+
erro(f"não consegui medir o volume de {p}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cadeia_loudnorm(wav: str, alvo: float = -14.0, tp: float = -1.0) -> tuple[str, dict]:
|
|
81
|
+
"""-14 LUFS em 2 passadas, sempre linear. Se o pico real passaria de -1 dBTP, um limitador com o atraso
|
|
82
|
+
compensado (latency=1, não desloca o áudio) segura só os picos antes."""
|
|
83
|
+
j = loudness(wav)
|
|
84
|
+
ganho = alvo - float(j["input_i"])
|
|
85
|
+
pre = ""
|
|
86
|
+
if float(j["input_tp"]) + ganho > tp - 0.2:
|
|
87
|
+
lim = 10 ** ((tp - ganho - 1.0) / 20)
|
|
88
|
+
pre = f"alimiter=limit={max(0.0625, min(1.0, lim)):.5f}:attack=5:release=60:level=false:latency=1,"
|
|
89
|
+
j = loudness(wav, pre)
|
|
90
|
+
af = (pre + f"loudnorm=I={alvo}:TP={tp}:LRA=11:measured_I={j['input_i']}:measured_TP={j['input_tp']}"
|
|
91
|
+
f":measured_LRA={j['input_lra']}:measured_thresh={j['input_thresh']}:offset={j['target_offset']}"
|
|
92
|
+
":linear=true:print_format=json,aresample=48000")
|
|
93
|
+
return af, j
|
|
94
|
+
|
|
95
|
+
def finalizar(video: str, wav: str, saida: str) -> tuple[dict, str, dict]:
|
|
96
|
+
"""junta a imagem (copiada) com o áudio em -14 LUFS e confere o pico real DEPOIS do AAC: o codificador pode
|
|
97
|
+
passar do teto em até ~2 dB. Se passou de -0,5 dBTP, refaz com o teto mais baixo (um limitador leve só nos
|
|
98
|
+
picos; o volume integrado continua -14 LUFS)."""
|
|
99
|
+
tp = -1.0
|
|
100
|
+
for tentativa in range(2):
|
|
101
|
+
af, j = cadeia_loudnorm(wav, tp=tp)
|
|
102
|
+
r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", video, "-i", wav, "-map", "0:v:0",
|
|
103
|
+
"-map", "1:a:0", "-c:v", "copy", "-af", af, "-c:a", "aac", "-b:a", "256k",
|
|
104
|
+
"-movflags", "+faststart", saida], capture_output=True, text=True)
|
|
105
|
+
if r.returncode:
|
|
106
|
+
erro("o ffmpeg falhou ao gravar o vídeo final:\n" + r.stderr[-1500:])
|
|
107
|
+
tipo = "linear"
|
|
108
|
+
try:
|
|
109
|
+
tipo = json.loads(r.stderr[r.stderr.rindex("{"): r.stderr.rindex("}") + 1]).get("normalization_type",
|
|
110
|
+
"linear")
|
|
111
|
+
except ValueError:
|
|
112
|
+
pass
|
|
113
|
+
fim = loudness(saida)
|
|
114
|
+
if float(fim["input_tp"]) <= -0.5:
|
|
115
|
+
break
|
|
116
|
+
tp = round(tp - (float(fim["input_tp"]) + 0.8), 2)
|
|
117
|
+
return j, tipo, fim
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def audio_mono(p: str, sr: int = 16000) -> "np.ndarray":
|
|
122
|
+
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", p, "-vn", "-ac", "1", "-ar", str(sr), "-f", "f32le",
|
|
123
|
+
"-acodec", "pcm_f32le", "-"], capture_output=True).stdout
|
|
124
|
+
return np.frombuffer(raw, dtype=np.float32)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def desvio_sincronia(ref: str, out: str, janelas: int = 10, sr: int = 16000) -> float:
|
|
128
|
+
"""maior deslocamento (ms) do áudio de saída em relação ao de entrada, medido em janelas de 1 s com fala"""
|
|
129
|
+
a, b = audio_mono(ref, sr), audio_mono(out, sr)
|
|
130
|
+
n = min(len(a), len(b))
|
|
131
|
+
w, L = sr, int(0.05 * sr)
|
|
132
|
+
if n < 3 * w:
|
|
133
|
+
return 0.0
|
|
134
|
+
en = [float((a[i:i + w] ** 2).mean()) for i in range(L, n - w - L, w)]
|
|
135
|
+
idx = sorted(range(len(en)), key=lambda k: -en[k])[:janelas]
|
|
136
|
+
pior = 0.0
|
|
137
|
+
for k in idx:
|
|
138
|
+
i = L + k * w
|
|
139
|
+
c = np.correlate(b[i - L:i + w + L], a[i:i + w], mode="valid")
|
|
140
|
+
pior = max(pior, abs((int(c.argmax()) - L) / sr * 1000))
|
|
141
|
+
return round(pior, 2)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def env_db(x: "np.ndarray", sr: int, hop: float) -> "np.ndarray":
|
|
145
|
+
w = max(1, int(sr * hop))
|
|
146
|
+
n = len(x) // w
|
|
147
|
+
return 10 * np.log10((x[: n * w].astype(np.float64).reshape(n, w) ** 2).mean(axis=1) + 1e-12)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ajustar_fade_fim(tocando: float, fade_fim: float) -> tuple[float, str | None]:
|
|
151
|
+
"""(fade do fim que cabe, aviso). A trilha precisa soar no nível certo por um tempo: o fade do fim fica com no
|
|
152
|
+
máximo um terço do tempo em que ela toca. Visto em 29/09/2026: --entrada-em 7,95 num vídeo de 9,3 s deixava
|
|
153
|
+
1,35 s de trilha, e o fade padrão de 1,5 s engolia tudo."""
|
|
154
|
+
maximo = round(max(0.1, tocando * FADE_FIM_FRACAO), 2)
|
|
155
|
+
if fade_fim <= maximo:
|
|
156
|
+
return fade_fim, None
|
|
157
|
+
return maximo, (f"a trilha toca só {tocando:.2f} s; um fade de {fade_fim:g} s no fim engoliria quase tudo. Usei "
|
|
158
|
+
f"{maximo:.2f} s (um terço). Para outro valor, passe --fade-fim.")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------ filtro
|
|
162
|
+
def cadeia_trilha(dur_trilha: float, inicio_trilha: float, precisa: float, entrada: float, dur_video: float,
|
|
163
|
+
ganho_db: float, respiro: float, respiro_db: float, fade_fim: float) -> tuple[str, int]:
|
|
164
|
+
"""filtro da trilha (entrada 1) até o rótulo [mus]: laço se precisar, ganho, respiro, fades, atraso,
|
|
165
|
+
apad+atrim na duração do vídeo. Devolve (filtro, quantas cópias da trilha)."""
|
|
166
|
+
util = dur_trilha - inicio_trilha
|
|
167
|
+
partes = []
|
|
168
|
+
if util >= precisa + 0.05:
|
|
169
|
+
partes.append(f"[1:a]atrim=start={inicio_trilha:.4f}:duration={precisa:.4f},asetpts=PTS-STARTPTS[t0];")
|
|
170
|
+
copias = 1
|
|
171
|
+
else: # repete a trilha com crossfade até cobrir o vídeo
|
|
172
|
+
extra = precisa - util
|
|
173
|
+
copias = 1 + math.ceil(extra / max(1.0, dur_trilha - XFADE_LACO)) + 1
|
|
174
|
+
labs = "".join(f"[c{k}]" for k in range(copias))
|
|
175
|
+
partes.append(f"[1:a]asplit={copias}{labs};")
|
|
176
|
+
partes.append(f"[c0]atrim=start={inicio_trilha:.4f},asetpts=PTS-STARTPTS[x0];")
|
|
177
|
+
for k in range(1, copias):
|
|
178
|
+
partes.append(f"[x{k - 1}][c{k}]acrossfade=d={XFADE_LACO}:c1=tri:c2=tri[x{k}];")
|
|
179
|
+
partes.append(f"[x{copias - 1}]atrim=duration={precisa:.4f},asetpts=PTS-STARTPTS[t0];")
|
|
180
|
+
cad = [f"aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo", f"volume={ganho_db:.3f}dB"]
|
|
181
|
+
if respiro > 0:
|
|
182
|
+
rg = 10 ** (respiro_db / 20)
|
|
183
|
+
rampa = 0.6
|
|
184
|
+
cad.append(f"volume='if(lt(t,{respiro:.3f}),{rg:.5f},if(lt(t,{respiro + rampa:.3f}),"
|
|
185
|
+
f"{rg:.5f}+(1-{rg:.5f})*(t-{respiro:.3f})/{rampa},1))':eval=frame")
|
|
186
|
+
cad.append(f"afade=t=in:st=0:d={0.4 if entrada > 0 else 0.15}")
|
|
187
|
+
if fade_fim > 0:
|
|
188
|
+
cad.append(f"afade=t=out:st={max(0.0, precisa - fade_fim):.4f}:d={min(fade_fim, precisa):.4f}")
|
|
189
|
+
fim = ["apad", f"atrim=end={dur_video:.6f}", "asetpts=PTS-STARTPTS"]
|
|
190
|
+
if entrada > 0:
|
|
191
|
+
# silêncio concatenado antes da música. No ffmpeg 8 o adelay seguido de asetpts zera o atraso
|
|
192
|
+
# e a música entrava no segundo 0 (visto em 27/09/2026 com --entrada-em 20.3).
|
|
193
|
+
partes.append("[t0]" + ",".join(cad) + "[tpre];")
|
|
194
|
+
partes.append(f"anullsrc=r=48000:cl=stereo,atrim=duration={entrada:.4f},"
|
|
195
|
+
"aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[sil];")
|
|
196
|
+
partes.append("[sil][tpre]concat=n=2:v=0:a=1," + ",".join(fim) + "[mus];")
|
|
197
|
+
else:
|
|
198
|
+
partes.append("[t0]" + ",".join(cad + fim) + "[mus];")
|
|
199
|
+
return "".join(partes), copias
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def filtro_completo(args, dur_video: float, dur_trilha: float, ganho_db: float, limiar: float,
|
|
203
|
+
duck_db: float | None = None) -> str:
|
|
204
|
+
precisa = dur_video - args.entrada_em
|
|
205
|
+
trilha, _ = cadeia_trilha(dur_trilha, args.inicio_trilha, precisa, args.entrada_em, dur_video, ganho_db,
|
|
206
|
+
args.respiro, args.respiro_db, args.fade_fim)
|
|
207
|
+
mix = 1 - 10 ** (-(args.duck if duck_db is None else duck_db) / 20)
|
|
208
|
+
return (trilha +
|
|
209
|
+
"[0:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,"
|
|
210
|
+
f"apad,atrim=end={dur_video:.6f},asetpts=PTS-STARTPTS,asplit=2[voz][sc];"
|
|
211
|
+
f"[mus][sc]sidechaincompress=threshold={limiar:.6f}:ratio=20:attack=15:release=350:knee=2:"
|
|
212
|
+
f"detection=rms:link=maximum:mix={mix:.4f}[duck];"
|
|
213
|
+
"[duck]asplit=2[d1][d2];"
|
|
214
|
+
"[voz][d1]amix=inputs=2:normalize=0:duration=first[mix]")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def renderizar(args, dur_video, dur_trilha, ganho_db, limiar, mix_wav, stem_wav, duck_db=None):
|
|
218
|
+
fc = filtro_completo(args, dur_video, dur_trilha, ganho_db, limiar, duck_db)
|
|
219
|
+
run(["ffmpeg", "-v", "error", "-y", "-i", args.video, "-i", args.trilha, "-filter_complex", fc,
|
|
220
|
+
"-map", "[mix]", "-c:a", "pcm_f32le", mix_wav, "-map", "[d2]", "-c:a", "pcm_f32le", stem_wav])
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def medir_duck(voz_path: str, stem_path: str, seco_path: str, depois_de: float) -> dict:
|
|
224
|
+
"""compara a trilha com duck e a mesma trilha sem duck, em janelas de 100 ms: quanto ela abaixa quando a voz
|
|
225
|
+
fala, quanto tempo fica abaixada, e voz x trilha durante a fala"""
|
|
226
|
+
sr = 16000
|
|
227
|
+
v, m, s = audio_mono(voz_path, sr), audio_mono(stem_path, sr), audio_mono(seco_path, sr)
|
|
228
|
+
n = min(len(v), len(m), len(s))
|
|
229
|
+
ev, em, es = env_db(v[:n], sr, 0.1), env_db(m[:n], sr, 0.1), env_db(s[:n], sr, 0.1)
|
|
230
|
+
k0 = int(depois_de / 0.1) + 1
|
|
231
|
+
ev, em, es = ev[k0:], em[k0:], es[k0:]
|
|
232
|
+
viva = es > -80 # só onde a trilha toca
|
|
233
|
+
if viva.sum() < 20:
|
|
234
|
+
return {}
|
|
235
|
+
fala = (ev > np.percentile(ev, 90) - 18) & viva
|
|
236
|
+
res = {}
|
|
237
|
+
if fala.sum() > 5:
|
|
238
|
+
red = es[fala] - em[fala]
|
|
239
|
+
res["duck_na_fala_db"] = round(float(np.median(red)), 1)
|
|
240
|
+
mf = 10 * np.log10((10 ** (em[fala] / 10)).mean())
|
|
241
|
+
vf = 10 * np.log10((10 ** (ev[fala] / 10)).mean())
|
|
242
|
+
res["voz_menos_trilha_na_fala_db"] = round(float(vf - mf), 1)
|
|
243
|
+
res["tempo_abaixada_pct"] = round(float(((es - em) > 3)[viva].mean() * 100), 0)
|
|
244
|
+
return res
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main(argv=None):
|
|
248
|
+
try:
|
|
249
|
+
sys.stdout.reconfigure(line_buffering=True) # mensagens aparecem na hora, mesmo em log
|
|
250
|
+
except AttributeError:
|
|
251
|
+
pass
|
|
252
|
+
ap = argparse.ArgumentParser(description="Trilha contínua por baixo da voz, com duck, medida em dB abaixo da voz.")
|
|
253
|
+
ap.add_argument("--video", required=True, help="vídeo pronto (sem trilha; pode ter efeitos sonoros)")
|
|
254
|
+
ap.add_argument("--trilha", required=True, help="arquivo da música (wav, mp3, m4a...)")
|
|
255
|
+
ap.add_argument("--abaixo-da-voz", type=float, default=22.0,
|
|
256
|
+
help="quantos dB a trilha fica abaixo da voz (padrão 22; 28 ou mais some no celular)")
|
|
257
|
+
ap.add_argument("--entrada-em", type=float, default=0.0, help="segundo do vídeo em que a trilha entra (padrão 0)")
|
|
258
|
+
ap.add_argument("--respiro", type=float, nargs="?", const=2.0, default=0.0,
|
|
259
|
+
help="a trilha entra mais alta por SEG segundos (padrão 2) e depois assenta")
|
|
260
|
+
ap.add_argument("--respiro-db", type=float, default=10.0, help="quanto o respiro fica acima do nível normal (10)")
|
|
261
|
+
ap.add_argument("--duck", type=float, default=8.0, help="quanto a trilha abaixa quando a voz fala, no máximo (8 dB)")
|
|
262
|
+
ap.add_argument("--fade-fim", type=float, default=None,
|
|
263
|
+
help="fade da trilha no fim do vídeo em segundos (1.5; com pouca trilha, um terço do que ela toca)")
|
|
264
|
+
ap.add_argument("--inicio-trilha", type=float, default=0.0, help="de que segundo da música começar (0)")
|
|
265
|
+
ap.add_argument("--saida", help="vídeo de saída (padrão: <video>_trilha.mp4 ao lado)")
|
|
266
|
+
a = ap.parse_args(argv)
|
|
267
|
+
|
|
268
|
+
for b in ("ffmpeg", "ffprobe"):
|
|
269
|
+
if not shutil.which(b):
|
|
270
|
+
erro(f"não achei o {b}. Instale o ffmpeg (no Mac: brew install ffmpeg).")
|
|
271
|
+
for p in (a.video, a.trilha):
|
|
272
|
+
if not Path(p).exists():
|
|
273
|
+
erro(f"não achei {p}")
|
|
274
|
+
saida = a.saida or str(Path(a.video).with_name(Path(a.video).stem + "_trilha.mp4"))
|
|
275
|
+
if Path(saida).resolve() in (Path(a.video).resolve(), Path(a.trilha).resolve()):
|
|
276
|
+
erro("a saída não pode ter o mesmo nome da entrada")
|
|
277
|
+
if not 6 <= a.abaixo_da_voz <= 40:
|
|
278
|
+
erro("--abaixo-da-voz fica entre 6 e 40 dB (o normal é 20 a 24)")
|
|
279
|
+
sv, st = sonda(a.video), sonda(a.trilha)
|
|
280
|
+
if sv["audio"] is None:
|
|
281
|
+
erro("o vídeo não tem áudio (a voz). A trilha é medida contra a voz.")
|
|
282
|
+
if st["audio"] is None:
|
|
283
|
+
erro("o arquivo da trilha não tem áudio")
|
|
284
|
+
D = sv["video"] or sv["formato"]
|
|
285
|
+
Dm = st["audio"] or st["formato"]
|
|
286
|
+
if a.entrada_em >= D - 1:
|
|
287
|
+
erro(f"--entrada-em {a.entrada_em} passa do fim do vídeo ({D:.2f} s)")
|
|
288
|
+
if a.inicio_trilha >= Dm - 1:
|
|
289
|
+
erro(f"--inicio-trilha {a.inicio_trilha} passa do fim da música ({Dm:.2f} s)")
|
|
290
|
+
if a.fade_fim is None:
|
|
291
|
+
a.fade_fim, aviso_fade = ajustar_fade_fim(D - a.entrada_em, FADE_FIM_PADRAO)
|
|
292
|
+
if aviso_fade:
|
|
293
|
+
print("aviso: " + aviso_fade)
|
|
294
|
+
elif ajustar_fade_fim(D - a.entrada_em, a.fade_fim)[1]:
|
|
295
|
+
print(f"aviso: o --fade-fim {a.fade_fim:g} s ocupa mais de um terço dos {D - a.entrada_em:.2f} s em que a trilha "
|
|
296
|
+
"toca; ficou como você pediu.")
|
|
297
|
+
if a.respiro and a.respiro + 0.6 > D - a.entrada_em - a.fade_fim:
|
|
298
|
+
print(f"aviso: o respiro de {a.respiro:g} s não cabe nos {D - a.entrada_em:.2f} s de trilha: ela fica alta até "
|
|
299
|
+
"o fade do fim. Diminua o --respiro ou entre antes.")
|
|
300
|
+
|
|
301
|
+
jv = loudness(a.video)
|
|
302
|
+
Lv = float(jv["input_i"])
|
|
303
|
+
if Lv < -60:
|
|
304
|
+
erro("o vídeo está praticamente mudo; não dá pra medir a voz")
|
|
305
|
+
limiar = max(0.000977, min(0.5, 10 ** ((Lv - 30) / 20))) # a voz passa ~20 dB acima disso quando fala
|
|
306
|
+
alvo = Lv - a.abaixo_da_voz
|
|
307
|
+
print(f"voz do vídeo: {Lv:.1f} LUFS · alvo da trilha: {alvo:.1f} LUFS ({a.abaixo_da_voz:g} dB abaixo)")
|
|
308
|
+
if Dm - a.inicio_trilha < D - a.entrada_em:
|
|
309
|
+
print(f"a música ({Dm:.1f} s) é mais curta que o trecho ({D - a.entrada_em:.1f} s): vai repetir com "
|
|
310
|
+
f"crossfade de {XFADE_LACO:g} s")
|
|
311
|
+
|
|
312
|
+
with tempfile.TemporaryDirectory() as td:
|
|
313
|
+
mix_wav, stem_wav = os.path.join(td, "mix.wav"), os.path.join(td, "stem.wav")
|
|
314
|
+
ganho = 0.0
|
|
315
|
+
diff = None
|
|
316
|
+
for passada in range(3): # o duck depende só da voz, então o ganho é linear: 1 correção basta
|
|
317
|
+
renderizar(a, D, Dm, ganho, limiar, mix_wav, stem_wav)
|
|
318
|
+
Ls = float(loudness(stem_wav)["input_i"])
|
|
319
|
+
diff = Lv - Ls
|
|
320
|
+
if abs(diff - a.abaixo_da_voz) <= 0.3:
|
|
321
|
+
break
|
|
322
|
+
ganho += alvo - Ls
|
|
323
|
+
seco_wav = os.path.join(td, "seco.wav")
|
|
324
|
+
renderizar(a, D, Dm, ganho, limiar, os.path.join(td, "mix_seco.wav"), seco_wav, duck_db=0.0)
|
|
325
|
+
duck = medir_duck(a.video, stem_wav, seco_wav, a.entrada_em + (a.respiro + 0.6 if a.respiro else 0.0))
|
|
326
|
+
jm, tipo, fim = finalizar(a.video, mix_wav, saida)
|
|
327
|
+
|
|
328
|
+
so = sonda(saida)
|
|
329
|
+
sinc = desvio_sincronia(a.video, saida)
|
|
330
|
+
print(f"trilha: ganho {ganho:+.1f} dB · ficou {diff:.1f} dB abaixo da voz (LUFS integrado, já com o duck)")
|
|
331
|
+
if duck.get("duck_na_fala_db") is not None:
|
|
332
|
+
print(f"duck: a trilha abaixa {duck['duck_na_fala_db']:.1f} dB quando a voz fala (abaixada em "
|
|
333
|
+
f"{duck['tempo_abaixada_pct']:.0f}% do tempo em que a trilha toca; volta nas pausas longas e no fim) · durante a fala a voz fica "
|
|
334
|
+
f"{duck['voz_menos_trilha_na_fala_db']:.1f} dB acima da trilha")
|
|
335
|
+
print(f"saída: {saida}")
|
|
336
|
+
print(f" duração vídeo {so['video']:.3f} s, áudio {so['audio']:.3f} s (entrada {D:.3f} s) · volume final "
|
|
337
|
+
f"{fim['input_i']} LUFS, pico {fim['input_tp']} dBTP (ajuste {tipo}) · voz no lugar: desvio {sinc} ms")
|
|
338
|
+
if abs(so["audio"] - D) > 0.05:
|
|
339
|
+
print(" aviso: a duração do áudio não bate com a do vídeo; confira antes de entregar")
|
|
340
|
+
if sinc > 5:
|
|
341
|
+
print(" aviso: a voz escorregou; não entregue sem conferir")
|
|
342
|
+
if tipo != "linear":
|
|
343
|
+
print(" aviso: o ajuste de volume saiu dinâmico; confira se a voz ficou natural")
|
|
344
|
+
rel = dict(video=a.video, trilha=a.trilha, saida=saida, voz_lufs=Lv, abaixo_pedido_db=a.abaixo_da_voz,
|
|
345
|
+
abaixo_medido_db=round(diff, 2), ganho_trilha_db=round(ganho, 2), entrada_em=a.entrada_em,
|
|
346
|
+
respiro_s=a.respiro, respiro_db=a.respiro_db, duck_max_db=a.duck, fade_fim_s=a.fade_fim, **duck,
|
|
347
|
+
duracao_entrada=round(D, 3), duracao_saida_video=round(so["video"], 3),
|
|
348
|
+
duracao_saida_audio=round(so["audio"], 3), lufs_final=float(fim["input_i"]),
|
|
349
|
+
pico_final_dbtp=float(fim["input_tp"]), ajuste=tipo, desvio_voz_ms=sinc)
|
|
350
|
+
Path(saida + ".trilha.json").write_text(json.dumps(rel, ensure_ascii=False, indent=1))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if __name__ == "__main__":
|
|
354
|
+
main()
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""First cut built automatically: every phrase once, the last take of each repeated one, no pauses.
|
|
2
|
+
|
|
3
|
+
The agent used to write the EDL range by range. For a talking video that follows a script this
|
|
4
|
+
helper does the mechanical part and the agent only reviews what it dropped:
|
|
5
|
+
|
|
6
|
+
1. Each source is split into speech blocks at the pauses measured on the waveform (at least
|
|
7
|
+
--pausa s below the noise floor + 10 dB). Whisper decides the words, never the edges.
|
|
8
|
+
2. A block is a false start when a later block, up to JANELA_BLOCOS ahead and within
|
|
9
|
+
JANELA_S seconds, begins with nearly the same words (difflib >= PARECIDO on the first words):
|
|
10
|
+
the speaker said it again, so the last take wins. Chains ("E nesse exato momento..." three
|
|
11
|
+
times) keep only the last one.
|
|
12
|
+
3. Blocks without words (breath, cough, noise) or with only hesitation words go out.
|
|
13
|
+
4. What is left becomes one range per block, and bordas_precisas.refine_edl() puts every edge in
|
|
14
|
+
a measured pause (0.08 s before, 0.12 s after): the junction silence lands at ~0.2 s.
|
|
15
|
+
5. A take abandoned at the end of a block with no pause before its retake is trimmed off, and a
|
|
16
|
+
whole sentence said again later (even inside a longer take, or in the next video) goes out.
|
|
17
|
+
6. voice_levels.py sets gain_db on ranges GANHO_DB or more away from the voice reference.
|
|
18
|
+
|
|
19
|
+
Writes <edit>/edl.json (the old one goes to edl.json.bak) and <edit>/corte_auto.md with every
|
|
20
|
+
dropped block, its reason and the take that replaced it. Read corte_auto.md before rendering:
|
|
21
|
+
a block dropped as a retake that was NOT one (two different sentences starting the same way) is
|
|
22
|
+
the error to look for.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
uv run python helpers/montar_corte.py --edit-dir <edit> [--fontes a.mov b.mov ...]
|
|
26
|
+
[--pausa 0.35] [--formato vertical] [--dry-run]
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import difflib
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import unicodedata
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
from bordas_precisas import HOP, OFF_DB, refine_edl, source_levels
|
|
43
|
+
from common import load_json, save_json
|
|
44
|
+
|
|
45
|
+
PAUSA_S = 0.35 # a pause this long splits two blocks
|
|
46
|
+
MIN_ILHA_S = 0.03 # speech islands shorter than this are clicks and join the silence
|
|
47
|
+
# (0.08 swallowed short words: "ser", "o que", "É")
|
|
48
|
+
JANELA_BLOCOS = 4 # a retake is looked for this many blocks ahead ...
|
|
49
|
+
JANELA_S = 25.0 # ... and within this many seconds
|
|
50
|
+
PARECIDO = 0.6 # share of the block's words found, in order, at the start of a near later block
|
|
51
|
+
PARECIDO_LONGE = 0.75 # ... of a later block further away or in a later source (retake of a whole part)
|
|
52
|
+
LONGE_BLOCOS = 12 # how far ahead, in blocks across sources, a whole-part retake is looked for
|
|
53
|
+
MIN_LONGE = 5 # blocks shorter than this are never matched far away
|
|
54
|
+
QUASE_IGUAL = 0.9 # a block of MIN_LONGE+ words found this complete anywhere later is a retake
|
|
55
|
+
CURTO = 3 # a block this short is a false start when all its words open the next block
|
|
56
|
+
VOZ_SEM_PALAVRA_S = 0.25 # a block without transcribed words this long ...
|
|
57
|
+
VOZ_PICO_DB = 20.0 # ... and this loud over the floor is a word Whisper misplaced, not a breath
|
|
58
|
+
HESITACAO = {"é", "e", "ah", "ahn", "hã", "hum", "hm", "uhm", "eh", "éé", "tá", "né", "então", "aí"}
|
|
59
|
+
GANHO_DB = 4.0 # ranges this far from the voice reference get their gain_db (verify uses 4 too)
|
|
60
|
+
FORMATOS = {"vertical": (1080, 1920), "horizontal": (1920, 1080), "square": (1080, 1080)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def norm(word: str) -> str:
|
|
64
|
+
w = unicodedata.normalize("NFD", word.lower())
|
|
65
|
+
w = "".join(c for c in w if unicodedata.category(c) != "Mn")
|
|
66
|
+
return re.sub(r"[^\w]", "", w)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def blocks_of(db: np.ndarray, floor: float, pausa: float) -> list[tuple[float, float]]:
|
|
70
|
+
"""Speech blocks (start, end) in seconds, split at quiet runs of at least `pausa`."""
|
|
71
|
+
loud = db >= floor + OFF_DB
|
|
72
|
+
# clicks: loud runs shorter than MIN_ILHA_S become silence
|
|
73
|
+
k, n, short = 0, loud.size, int(round(MIN_ILHA_S / HOP))
|
|
74
|
+
while k < n:
|
|
75
|
+
if loud[k]:
|
|
76
|
+
j = k
|
|
77
|
+
while j < n and loud[j]:
|
|
78
|
+
j += 1
|
|
79
|
+
if j - k < short:
|
|
80
|
+
loud[k:j] = False
|
|
81
|
+
k = j
|
|
82
|
+
else:
|
|
83
|
+
k += 1
|
|
84
|
+
out, k, gap = [], 0, int(round(pausa / HOP))
|
|
85
|
+
while k < n:
|
|
86
|
+
if not loud[k]:
|
|
87
|
+
k += 1
|
|
88
|
+
continue
|
|
89
|
+
a = k
|
|
90
|
+
quiet = 0
|
|
91
|
+
while k < n and quiet < gap:
|
|
92
|
+
quiet = 0 if loud[k] else quiet + 1
|
|
93
|
+
k += 1
|
|
94
|
+
out.append((a * HOP, (k - quiet) * HOP))
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def words_in(words: list[dict], a: float, b: float) -> list[dict]:
|
|
99
|
+
return [w for w in words if a <= (float(w["start"]) + float(w["end"])) / 2 < b]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def coverage(first: list[str], later: list[str]) -> tuple[float, int]:
|
|
103
|
+
"""(share of `first` found in order in `later` in runs of 2+ words, where that match starts)."""
|
|
104
|
+
if not first or not later:
|
|
105
|
+
return 0.0, 0
|
|
106
|
+
sm = difflib.SequenceMatcher(None, first, later, autojunk=False)
|
|
107
|
+
runs = [m for m in sm.get_matching_blocks() if m.size >= 2]
|
|
108
|
+
if not runs:
|
|
109
|
+
return 0.0, 0
|
|
110
|
+
return sum(m.size for m in runs) / len(first), runs[0].b
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def retake_of(bl: dict, nxt: list[dict], near: bool) -> tuple[float, int]:
|
|
114
|
+
"""(how clearly `bl` is an earlier take of what `nxt` says, which of `nxt` the take starts in);
|
|
115
|
+
(0, 0) if it is not a retake."""
|
|
116
|
+
toks = bl["toks"]
|
|
117
|
+
later = [t for b in nxt for t in b["toks"]]
|
|
118
|
+
if near and len(toks) <= CURTO:
|
|
119
|
+
opening = nxt[0]["toks"][:8]
|
|
120
|
+
hit = opening and all(t in opening for t in toks) and len(opening) > len(toks)
|
|
121
|
+
return (1.0, 0) if hit else (0.0, 0)
|
|
122
|
+
cov, at = coverage(toks, later[: len(toks) + 12])
|
|
123
|
+
if at > 3:
|
|
124
|
+
# the later take must begin with it, not quote it in the middle ... unless it repeats a
|
|
125
|
+
# long stretch almost word for word (a whole sentence said again inside a longer take)
|
|
126
|
+
cov, at = coverage(toks, later)
|
|
127
|
+
if not (len(toks) >= MIN_LONGE and cov >= QUASE_IGUAL):
|
|
128
|
+
return 0.0, 0
|
|
129
|
+
which, acc = 0, 0
|
|
130
|
+
for k, b in enumerate(nxt):
|
|
131
|
+
if at < acc + len(b["toks"]):
|
|
132
|
+
which = k
|
|
133
|
+
break
|
|
134
|
+
acc += len(b["toks"])
|
|
135
|
+
if near:
|
|
136
|
+
return (cov, which) if cov >= PARECIDO and cov * len(toks) >= 3 else (0.0, 0)
|
|
137
|
+
return (cov, which) if len(toks) >= MIN_LONGE and cov >= PARECIDO_LONGE else (0.0, 0)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main() -> int:
|
|
141
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
142
|
+
ap.add_argument("--edit-dir", type=Path, required=True)
|
|
143
|
+
ap.add_argument("--fontes", nargs="*", help="vídeos na ordem do roteiro (padrão: todos os transcritos, por nome)")
|
|
144
|
+
ap.add_argument("--pausa", type=float, default=PAUSA_S)
|
|
145
|
+
ap.add_argument("--formato", default="vertical", choices=sorted(FORMATOS))
|
|
146
|
+
ap.add_argument("--dry-run", action="store_true", help="só mostra, não grava o edl.json")
|
|
147
|
+
args = ap.parse_args()
|
|
148
|
+
edit = args.edit_dir
|
|
149
|
+
|
|
150
|
+
trs = {}
|
|
151
|
+
for p in sorted((edit / "transcripts").glob("*.json")):
|
|
152
|
+
if p.stem == "cut":
|
|
153
|
+
continue
|
|
154
|
+
d = load_json(p)
|
|
155
|
+
if isinstance(d, dict) and d.get("words") is not None and d.get("source"):
|
|
156
|
+
trs[p.stem] = d
|
|
157
|
+
if args.fontes:
|
|
158
|
+
order = [Path(f).stem for f in args.fontes]
|
|
159
|
+
missing = [s for s in order if s not in trs]
|
|
160
|
+
if missing:
|
|
161
|
+
print(f"sem transcrição: {', '.join(missing)} (rode o transcribe.py antes)", file=sys.stderr)
|
|
162
|
+
return 2
|
|
163
|
+
else:
|
|
164
|
+
order = list(trs)
|
|
165
|
+
if not order:
|
|
166
|
+
print(f"nenhuma transcrição em {edit / 'transcripts'}", file=sys.stderr)
|
|
167
|
+
return 2
|
|
168
|
+
|
|
169
|
+
sources, ranges, dropped, envs = {}, [], [], {}
|
|
170
|
+
allb: list[dict] = [] # every block of every source, in script order
|
|
171
|
+
n = 0
|
|
172
|
+
for stem in order:
|
|
173
|
+
tr = trs[stem]
|
|
174
|
+
src = str(tr["source"])
|
|
175
|
+
lv = source_levels(src, print)
|
|
176
|
+
envs[stem] = lv
|
|
177
|
+
sources[stem] = src
|
|
178
|
+
if lv is None:
|
|
179
|
+
print(f"{stem}: entra inteiro, revise.")
|
|
180
|
+
allb.append({"stem": stem, "a": 0.0, "b": float(tr.get("duration") or 0), "text": "", "toks": ["?"],
|
|
181
|
+
"whole": True})
|
|
182
|
+
continue
|
|
183
|
+
db, floor = lv
|
|
184
|
+
words = [w for w in tr["words"] if w.get("start") is not None]
|
|
185
|
+
for a, b in blocks_of(db, floor, args.pausa):
|
|
186
|
+
ws = words_in(words, a, b)
|
|
187
|
+
text = " ".join(str(w.get("text", "")).strip() for w in ws)
|
|
188
|
+
ws = [w for w in ws if norm(str(w.get("text", "")))] # one token per word, aligned
|
|
189
|
+
peak = float(db[int(a / HOP):max(int(a / HOP) + 1, int(b / HOP))].max()) - floor
|
|
190
|
+
allb.append({"stem": stem, "a": a, "b": b, "text": text, "words": ws, "peak": peak,
|
|
191
|
+
"toks": [norm(str(w.get("text", ""))) for w in ws],
|
|
192
|
+
"cut_off": text.rstrip().endswith(("...", "…", "-"))})
|
|
193
|
+
|
|
194
|
+
for i, bl in enumerate(allb):
|
|
195
|
+
bl["keep"], bl["why"] = True, ""
|
|
196
|
+
if bl.get("whole"):
|
|
197
|
+
continue
|
|
198
|
+
if not bl["toks"]:
|
|
199
|
+
if bl["b"] - bl["a"] >= VOZ_SEM_PALAVRA_S and bl.get("peak", 0.0) >= VOZ_PICO_DB:
|
|
200
|
+
# Whisper put the word somewhere else ("Eu" 1 s early): loud enough to be a word, so it stays
|
|
201
|
+
bl["text"] = "(fala sem transcrição: confira)"
|
|
202
|
+
continue
|
|
203
|
+
bl["keep"], bl["why"] = False, "sem fala (respiro, barulho)"
|
|
204
|
+
continue
|
|
205
|
+
if all(t in HESITACAO for t in bl["toks"]):
|
|
206
|
+
bl["keep"], bl["why"] = False, f"só hesitação (\"{bl['text']}\")"
|
|
207
|
+
continue
|
|
208
|
+
for j in range(i + 1, min(len(allb), i + 1 + LONGE_BLOCOS)):
|
|
209
|
+
later = allb[j]
|
|
210
|
+
if not later["toks"] or later.get("whole"):
|
|
211
|
+
continue
|
|
212
|
+
near = j - i <= JANELA_BLOCOS and (later["stem"] != bl["stem"] or later["a"] - bl["b"] <= JANELA_S)
|
|
213
|
+
if len(bl["toks"]) <= CURTO and j > i + 1 and near:
|
|
214
|
+
continue # a false start is redone right after it, not three blocks later ("Não!" ... "que não")
|
|
215
|
+
r, k = retake_of(bl, allb[j:j + 3], near)
|
|
216
|
+
if r:
|
|
217
|
+
later = allb[j + k]
|
|
218
|
+
onde = later["a"] if later["stem"] == bl["stem"] else f"{later['stem']} {later['a']:.2f}"
|
|
219
|
+
bl["keep"] = False
|
|
220
|
+
bl["why"] = (f"tomada repetida ({r:.0%} dela no começo de {onde if isinstance(onde, str) else f'{onde:.2f}'}s: "
|
|
221
|
+
f"\"{later['text'][:60]}\")")
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
# a take abandoned at the end of a block with no pause before the retake: "... da Shopee e um
|
|
225
|
+
# mecanismo..." + "E um mecanismo com inteligência artificial". The block ends before it.
|
|
226
|
+
kept = [b for b in allb if b["keep"] and not b.get("whole")]
|
|
227
|
+
for bl, nb in zip(kept, kept[1:]):
|
|
228
|
+
if not bl.get("cut_off") or len(bl["toks"]) < 3 or not nb["toks"]:
|
|
229
|
+
continue
|
|
230
|
+
for k in range(min(6, len(bl["toks"]) - 1), 1, -1):
|
|
231
|
+
tail, head = bl["toks"][-k:], nb["toks"][: k + 2]
|
|
232
|
+
cov, at = coverage(tail, head)
|
|
233
|
+
if cov >= 0.99 and at <= 1:
|
|
234
|
+
t0 = float(bl["words"][-k]["start"])
|
|
235
|
+
if t0 - bl["a"] > 0.3:
|
|
236
|
+
dropped.append((bl["stem"], {"a": t0, "b": bl["b"], "text": " ".join(
|
|
237
|
+
str(w.get("text", "")).strip() for w in bl["words"][-k:])},
|
|
238
|
+
f"fim de tomada abandonada, refeita em {nb['a']:.2f}s"))
|
|
239
|
+
bl["b"] = t0
|
|
240
|
+
bl["text"] = " ".join(str(w.get("text", "")).strip() for w in bl["words"][:-k])
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
for bl in allb:
|
|
244
|
+
if bl["keep"]:
|
|
245
|
+
n += 1
|
|
246
|
+
ranges.append({"id": f"r{n:02d}", "source": bl["stem"], "start": round(bl["a"], 3),
|
|
247
|
+
"end": round(bl["b"], 3), "beat": "", "quote": bl["text"][:90],
|
|
248
|
+
"reason": "fonte sem pausas medíveis" if bl.get("whole")
|
|
249
|
+
else "montar_corte: última tomada da frase", "gain_db": 0})
|
|
250
|
+
else:
|
|
251
|
+
dropped.append((bl["stem"], bl, bl["why"]))
|
|
252
|
+
|
|
253
|
+
edl = {"version": 1, "sources": sources,
|
|
254
|
+
"output": {"format": args.formato, "width": FORMATOS[args.formato][0],
|
|
255
|
+
"height": FORMATOS[args.formato][1]},
|
|
256
|
+
"grade": "", "jcut": {"lead_frames": 4, "tail_trim_frames": 2}, "ranges": ranges}
|
|
257
|
+
log_lines: list[str] = []
|
|
258
|
+
alerts = refine_edl(edl, envs, log=log_lines.append)
|
|
259
|
+
|
|
260
|
+
fala = sum(r["end"] - r["start"] for r in ranges)
|
|
261
|
+
bruto = sum(float(t.get("duration") or 0) for s, t in trs.items() if s in order)
|
|
262
|
+
md = [f"# Corte automático · {len(ranges)} trechos, {fala:.1f} s de {bruto:.1f} s de bruto", "",
|
|
263
|
+
f"## Tirado ({len(dropped)} blocos) · confira se alguma frase boa saiu como repetida", ""]
|
|
264
|
+
for stem, bl, reason in dropped:
|
|
265
|
+
md.append(f"- {stem} [{bl['a']:.2f}-{bl['b']:.2f}] \"{bl['text'][:80]}\" · {reason}")
|
|
266
|
+
if alerts:
|
|
267
|
+
md += ["", "## SEM PAUSA (ouça essas bordas)", ""] + [f"- {a}" for a in alerts]
|
|
268
|
+
md += ["", "## Trechos", ""] + [f"- {r['id']} {r['source']} [{r['start']:.2f}-{r['end']:.2f}] {r['quote']}"
|
|
269
|
+
for r in ranges]
|
|
270
|
+
report = "\n".join(md) + "\n"
|
|
271
|
+
print(report)
|
|
272
|
+
if not args.dry_run:
|
|
273
|
+
edl_path = edit / "edl.json"
|
|
274
|
+
if edl_path.exists():
|
|
275
|
+
shutil.copy(edl_path, edit / "edl.json.bak")
|
|
276
|
+
save_json(edl_path, edl)
|
|
277
|
+
# voice level per range now, so the first render does not come back with LOW-LEVEL flags
|
|
278
|
+
vl = subprocess.run([sys.executable, str(Path(__file__).with_name("voice_levels.py")), "--edl", str(edl_path),
|
|
279
|
+
"--drop-db", str(GANHO_DB)], capture_output=True, text=True)
|
|
280
|
+
levels = load_json(edit / "verify" / "voice_levels.json") if vl.returncode == 0 else None
|
|
281
|
+
ganhos = {r["id"]: float(r["gain_db"]) for r in (levels or {}).get("ranges", [])
|
|
282
|
+
if (r.get("low") or r.get("high")) and r.get("gain_db") is not None}
|
|
283
|
+
if ganhos:
|
|
284
|
+
for r in edl["ranges"]:
|
|
285
|
+
if r["id"] in ganhos:
|
|
286
|
+
r["gain_db"] = ganhos[r["id"]]
|
|
287
|
+
save_json(edl_path, edl)
|
|
288
|
+
print("volume ajustado: " + ", ".join(f"{k} {v:+.1f} dB" for k, v in ganhos.items()))
|
|
289
|
+
(edit / "corte_auto.md").write_text(report, encoding="utf-8")
|
|
290
|
+
print(f"gravado: {edl_path} e {edit / 'corte_auto.md'}")
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
sys.exit(main())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Motion design for editor-video: animated texts and numbers drawn in Python (SPEC Contract 12).
|
|
2
|
+
|
|
3
|
+
from motion import engine, registry, render
|
|
4
|
+
item = render.plan_item("contador", {"value": 1904152.87, "prefix": "R$ "}, fps=30, duration=3.0)
|
|
5
|
+
clip = render.render_clip(item, "edit/renders/motion")
|
|
6
|
+
|
|
7
|
+
Templates live in helpers/motion/templates/<id>.py (one file each, found automatically).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from . import engine, registry, render # noqa: F401
|
|
11
|
+
from .engine import MotionError # noqa: F401
|
|
12
|
+
|
|
13
|
+
__all__ = ["engine", "registry", "render", "MotionError"]
|