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,595 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Corte seco no vídeo JÁ pronto: tira as pausas que sobraram entre as frases (references/corte-seco.md).
|
|
3
|
+
|
|
4
|
+
Como o corte é feito no vídeo final (imagem e som juntos, no mesmo quadro), legenda, animações e efeitos
|
|
5
|
+
continuam no lugar em relação à fala. Em cada pausa maior que --pausa-min, sobra só --pausa-max de silêncio.
|
|
6
|
+
|
|
7
|
+
Onde cortar dentro da pausa:
|
|
8
|
+
- nunca na palavra: as bordas são medidas na onda (limiar pela voz do próprio vídeo) e ainda andam até 0,12 s
|
|
9
|
+
pra fora enquanto houver som fraco (consoante fraca, fim de "s"), com folga de 0,06 s depois da palavra
|
|
10
|
+
e 0,07 s antes da próxima;
|
|
11
|
+
- no trecho em que a imagem menos muda: com --sem-grafismo, olha só os grafismos (render sem a pessoa);
|
|
12
|
+
sem ele, olha a diferença entre quadros do próprio vídeo;
|
|
13
|
+
- nunca no ataque de um efeito sonoro (--sfx com o plano de sons; com --voz, qualquer som que não seja voz).
|
|
14
|
+
|
|
15
|
+
Uso (<SKILL> é a pasta desta skill; o Python dela tem numpy):
|
|
16
|
+
uv run --directory "<SKILL>" python helpers/corte_seco.py VIDEO_FINAL [--pausa-max 0.15]
|
|
17
|
+
[--sem-grafismo RENDER_SO_GRAFISMO.mp4] [--sfx PLANO_SFX.json] [--voz AUDIO_SO_DA_VOZ.mp4] [--cortar 41.06-41.76,...]
|
|
18
|
+
[--saida SAIDA.mp4] [--pausa-min 0.25] [--inicio 0.08] [--limiar -42] [--so-plano] [--crf 16]
|
|
19
|
+
|
|
20
|
+
Saída: o vídeo cortado (-14 LUFS, 2 passadas, linear) e SAIDA.corte.json com os cortes e o mapa de tempo.
|
|
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 fractions import Fraction
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import numpy as np
|
|
37
|
+
except ImportError: # pragma: no cover
|
|
38
|
+
sys.exit('Falta o numpy. Rode com o Python da skill: uv run --directory "<SKILL>" python helpers/corte_seco.py ...')
|
|
39
|
+
|
|
40
|
+
HOP = 0.01 # envelope de 10 ms
|
|
41
|
+
PRE0, POS0 = 0.06, 0.07 # folga mínima depois do fim da palavra / antes da próxima (borda no limiar)
|
|
42
|
+
POS1 = 0.04 # folga antes do começo "de verdade" da próxima palavra (borda estendida pelo som fraco)
|
|
43
|
+
ANDAR = 0.12 # quanto a borda da próxima palavra anda pra trás enquanto tiver som fraco (o "S" de "Só")
|
|
44
|
+
FADE = 0.008 # micro fade na emenda do áudio (sem estalo)
|
|
45
|
+
SR_ENV = 16000
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def erro(msg: str, code: int = 2):
|
|
49
|
+
print("ERRO: " + msg, file=sys.stderr)
|
|
50
|
+
sys.exit(code)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def aviso(msg: str):
|
|
54
|
+
print("aviso: " + msg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(cmd: list, texto=True, **kw):
|
|
58
|
+
r = subprocess.run(cmd, capture_output=True, text=texto, **kw)
|
|
59
|
+
if r.returncode:
|
|
60
|
+
err = r.stderr if texto else r.stderr.decode(errors="replace")
|
|
61
|
+
erro("o ffmpeg falhou:\n" + err[-2000:])
|
|
62
|
+
return r
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------ leitura
|
|
66
|
+
def sonda(p: str) -> dict:
|
|
67
|
+
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
|
|
68
|
+
"stream=index,codec_type,r_frame_rate,avg_frame_rate,width,height,duration,start_time,"
|
|
69
|
+
"sample_rate,channels,nb_frames,color_space,color_primaries,color_transfer,color_range"
|
|
70
|
+
":format=duration", "-of", "json", p], capture_output=True, text=True)
|
|
71
|
+
if r.returncode:
|
|
72
|
+
erro(f"não consegui abrir {p}")
|
|
73
|
+
d = json.loads(r.stdout)
|
|
74
|
+
v = next((s for s in d.get("streams", []) if s.get("codec_type") == "video"), None)
|
|
75
|
+
a = next((s for s in d.get("streams", []) if s.get("codec_type") == "audio"), None)
|
|
76
|
+
fmt = float(d.get("format", {}).get("duration", 0) or 0)
|
|
77
|
+
info = dict(formato=fmt, v=v, a=a)
|
|
78
|
+
if v:
|
|
79
|
+
fr = Fraction(v.get("r_frame_rate") or v.get("avg_frame_rate") or "30/1")
|
|
80
|
+
if fr <= 0:
|
|
81
|
+
fr = Fraction(30, 1)
|
|
82
|
+
info["fps"] = fr
|
|
83
|
+
info["dur_v"] = float(v.get("duration") or fmt)
|
|
84
|
+
info["v0"] = float(v.get("start_time") or 0)
|
|
85
|
+
info["w"], info["h"] = int(v.get("width", 0)), int(v.get("height", 0))
|
|
86
|
+
if a:
|
|
87
|
+
info["dur_a"] = float(a.get("duration") or fmt)
|
|
88
|
+
info["a0"] = float(a.get("start_time") or 0)
|
|
89
|
+
info["sr"] = int(a.get("sample_rate", 48000))
|
|
90
|
+
info["ch"] = int(a.get("channels", 2))
|
|
91
|
+
return info
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def audio_mono(p: str, sr: int = SR_ENV) -> "np.ndarray":
|
|
95
|
+
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", p, "-vn", "-ac", "1", "-ar", str(sr), "-f", "f32le",
|
|
96
|
+
"-acodec", "pcm_f32le", "-"], capture_output=True).stdout
|
|
97
|
+
return np.frombuffer(raw, dtype=np.float32)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def env_db(x: "np.ndarray", sr: int = SR_ENV, hop: float = HOP) -> "np.ndarray":
|
|
101
|
+
w = int(sr * hop)
|
|
102
|
+
n = len(x) // w
|
|
103
|
+
return 10 * np.log10((x[: n * w].astype(np.float64).reshape(n, w) ** 2).mean(axis=1) + 1e-12)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def quadros(p: str, largura: int, altura: int):
|
|
107
|
+
"""lê o vídeo quadro a quadro em cinza, pequeno, sem guardar tudo na memória"""
|
|
108
|
+
proc = subprocess.Popen(["ffmpeg", "-v", "error", "-i", p, "-an", "-vf",
|
|
109
|
+
f"scale={largura}:{altura}:flags=area,format=gray", "-f", "rawvideo", "-"],
|
|
110
|
+
stdout=subprocess.PIPE)
|
|
111
|
+
n = largura * altura
|
|
112
|
+
while True:
|
|
113
|
+
buf = proc.stdout.read(n)
|
|
114
|
+
if len(buf) < n:
|
|
115
|
+
break
|
|
116
|
+
yield np.frombuffer(buf, dtype=np.uint8)
|
|
117
|
+
proc.wait()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def atividade(p: str, w: int, h: int, guardar: bool = False):
|
|
121
|
+
"""atividade[i] = quanto o quadro i mudou em relação ao anterior. Com guardar=True devolve também os quadros
|
|
122
|
+
miniatura (40 px) pra conferência do corte."""
|
|
123
|
+
lw = 96
|
|
124
|
+
lh = max(2, int(round(lw * h / max(1, w) / 2)) * 2)
|
|
125
|
+
act, mini, ant = [], [], None
|
|
126
|
+
for f in quadros(p, lw, lh):
|
|
127
|
+
g = f.astype(np.int16)
|
|
128
|
+
act.append(0.0 if ant is None else float(np.abs(g - ant).mean()))
|
|
129
|
+
ant = g
|
|
130
|
+
if guardar:
|
|
131
|
+
mini.append(f.reshape(lh, lw)[::2, ::2].copy())
|
|
132
|
+
return np.array(act), (np.stack(mini) if guardar and mini else None)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def carregar_sfx(p: str) -> list:
|
|
136
|
+
"""intervalos [a, b] onde não pode cortar: do ataque ao pico de cada som (+0,1 s)"""
|
|
137
|
+
try:
|
|
138
|
+
d = json.loads(Path(p).read_text())
|
|
139
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
140
|
+
erro(f"não consegui ler o plano de sons {p}: {e}")
|
|
141
|
+
itens = d.get("sons", d.get("eventos", [])) if isinstance(d, dict) else d
|
|
142
|
+
out = []
|
|
143
|
+
for s in itens:
|
|
144
|
+
pico = s.get("pico", s.get("peak", s.get("t")))
|
|
145
|
+
if pico is None:
|
|
146
|
+
continue
|
|
147
|
+
pico = float(pico)
|
|
148
|
+
ini = float(s["inicio"]) if "inicio" in s else pico - 0.06
|
|
149
|
+
out.append((max(ini, pico - 0.3) - 0.04, pico + 0.06))
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def intervalos(mask: "np.ndarray", hop: float = HOP) -> list:
|
|
154
|
+
out, i = [], 0
|
|
155
|
+
while i < len(mask):
|
|
156
|
+
if mask[i]:
|
|
157
|
+
j = i
|
|
158
|
+
while j < len(mask) and mask[j]:
|
|
159
|
+
j += 1
|
|
160
|
+
out.append((i * hop, j * hop))
|
|
161
|
+
i = j
|
|
162
|
+
else:
|
|
163
|
+
i += 1
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def lag_ms(a: "np.ndarray", b: "np.ndarray", t_a: float, t_b: float, meio: float = 0.25, busca: float = 0.05,
|
|
168
|
+
sr: int = SR_ENV) -> float | None:
|
|
169
|
+
"""deslocamento (ms) do trecho de b em t_b em relação ao de a em t_a (janela de 2*meio s)"""
|
|
170
|
+
i, j, L, w = int(t_a * sr), int(t_b * sr), int(busca * sr), int(meio * sr)
|
|
171
|
+
x = a[i - w:i + w]
|
|
172
|
+
y = b[j - w - L:j + w + L]
|
|
173
|
+
if i - w < 0 or j - w - L < 0 or len(x) < 2 * w or len(y) < 2 * w + 2 * L or float((x ** 2).mean()) < 1e-7:
|
|
174
|
+
return None
|
|
175
|
+
c = np.correlate(y, x, mode="valid")
|
|
176
|
+
return (int(c.argmax()) - L) / sr * 1000
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def loudness(p: str, af: str = "") -> dict:
|
|
180
|
+
r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", p, "-vn", "-af",
|
|
181
|
+
af + "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"],
|
|
182
|
+
capture_output=True, text=True).stderr
|
|
183
|
+
try:
|
|
184
|
+
return json.loads(r[r.rindex("{"): r.rindex("}") + 1])
|
|
185
|
+
except ValueError:
|
|
186
|
+
erro(f"não consegui medir o volume de {p}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cadeia_loudnorm(wav: str, alvo: float = -14.0, tp: float = -1.0) -> tuple[str, dict]:
|
|
190
|
+
"""-14 LUFS em 2 passadas, sempre linear. Se o pico real passaria de -1 dBTP, um limitador com o atraso
|
|
191
|
+
compensado (latency=1, não desloca o áudio) segura só os picos antes."""
|
|
192
|
+
j = loudness(wav)
|
|
193
|
+
ganho = alvo - float(j["input_i"])
|
|
194
|
+
pre = ""
|
|
195
|
+
if float(j["input_tp"]) + ganho > tp - 0.2:
|
|
196
|
+
lim = 10 ** ((tp - ganho - 1.0) / 20)
|
|
197
|
+
pre = f"alimiter=limit={max(0.0625, min(1.0, lim)):.5f}:attack=5:release=60:level=false:latency=1,"
|
|
198
|
+
j = loudness(wav, pre)
|
|
199
|
+
af = (pre + f"loudnorm=I={alvo}:TP={tp}:LRA=11:measured_I={j['input_i']}:measured_TP={j['input_tp']}"
|
|
200
|
+
f":measured_LRA={j['input_lra']}:measured_thresh={j['input_thresh']}:offset={j['target_offset']}"
|
|
201
|
+
":linear=true:print_format=json,aresample=48000")
|
|
202
|
+
return af, j
|
|
203
|
+
|
|
204
|
+
def finalizar(video: str, wav: str, saida: str) -> tuple[dict, str, dict]:
|
|
205
|
+
"""junta a imagem (copiada) com o áudio em -14 LUFS e confere o pico real DEPOIS do AAC: o codificador pode
|
|
206
|
+
passar do teto em até ~2 dB. Se passou de -0,5 dBTP, refaz com o teto mais baixo (um limitador leve só nos
|
|
207
|
+
picos; o volume integrado continua -14 LUFS)."""
|
|
208
|
+
tp = -1.0
|
|
209
|
+
for tentativa in range(2):
|
|
210
|
+
af, j = cadeia_loudnorm(wav, tp=tp)
|
|
211
|
+
r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", video, "-i", wav, "-map", "0:v:0",
|
|
212
|
+
"-map", "1:a:0", "-c:v", "copy", "-af", af, "-c:a", "aac", "-b:a", "256k",
|
|
213
|
+
"-movflags", "+faststart", saida], capture_output=True, text=True)
|
|
214
|
+
if r.returncode:
|
|
215
|
+
erro("o ffmpeg falhou ao gravar o vídeo final:\n" + r.stderr[-1500:])
|
|
216
|
+
tipo = "linear"
|
|
217
|
+
try:
|
|
218
|
+
tipo = json.loads(r.stderr[r.stderr.rindex("{"): r.stderr.rindex("}") + 1]).get("normalization_type",
|
|
219
|
+
"linear")
|
|
220
|
+
except ValueError:
|
|
221
|
+
pass
|
|
222
|
+
fim = loudness(saida)
|
|
223
|
+
if float(fim["input_tp"]) <= -0.5:
|
|
224
|
+
break
|
|
225
|
+
tp = round(tp - (float(fim["input_tp"]) + 0.8), 2)
|
|
226
|
+
return j, tipo, fim
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------ plano
|
|
231
|
+
def planejar(a, info: dict, det: "np.ndarray", mix_env: "np.ndarray", usa_voz: bool, act: "np.ndarray",
|
|
232
|
+
bloqueios: list, n_quadros: int) -> dict:
|
|
233
|
+
fps = float(info["fps"])
|
|
234
|
+
fd = 1.0 / fps
|
|
235
|
+
floor5, floor10, fala = (float(np.percentile(det, q)) for q in (5, 10, 90))
|
|
236
|
+
thr = a.limiar if a.limiar is not None else max(floor5 + 10, fala - 30)
|
|
237
|
+
fraco = max(floor10 + 6, thr - 12)
|
|
238
|
+
if fala - floor5 < 40 and a.limiar is None:
|
|
239
|
+
aviso(f"a diferença entre a fala ({fala:.0f} dB) e o fundo ({floor5:.0f} dB) é pequena. Se o vídeo já tem "
|
|
240
|
+
"trilha, rode o corte seco ANTES da trilha (ou passe --voz com o áudio só da voz).")
|
|
241
|
+
sil = det < thr
|
|
242
|
+
dur_det = len(det) * HOP
|
|
243
|
+
gaps = [(x, y) for x, y in intervalos(sil) if (y - x) >= a.pausa_min - 1e-6 and y < dur_det - 0.05]
|
|
244
|
+
if not gaps and not a.cortar:
|
|
245
|
+
print("nenhuma pausa maior que", a.pausa_min, "s: nada pra cortar")
|
|
246
|
+
|
|
247
|
+
# bloqueios: ataque dos efeitos sonoros do plano + (com --voz) o ataque de qualquer som que não é voz.
|
|
248
|
+
# A cauda de um efeito pode ser cortada (a emenda tem micro fade); o ataque não.
|
|
249
|
+
blq = list(bloqueios)
|
|
250
|
+
if usa_voz:
|
|
251
|
+
n = min(len(det), len(mix_env))
|
|
252
|
+
blq += [(x - 0.04, min(y, x + 0.10)) for x, y in intervalos((mix_env[:n] > thr) & (det[:n] < thr))
|
|
253
|
+
if y - x >= 0.03]
|
|
254
|
+
|
|
255
|
+
def bloqueado(s0, s1):
|
|
256
|
+
return any(s0 < y and s1 > x for x, y in blq)
|
|
257
|
+
|
|
258
|
+
def k_env(t):
|
|
259
|
+
return min(len(det) - 1, max(0, int(t / HOP)))
|
|
260
|
+
|
|
261
|
+
cortes, pulados = [], []
|
|
262
|
+
for x, y in gaps:
|
|
263
|
+
ini = x < 0.02
|
|
264
|
+
# bordas na onda: a palavra seguinte pode começar com consoante fraca ("S", "F") que o limiar não pega.
|
|
265
|
+
# Anda pra trás até 0,12 s enquanto houver som fraco e protege esse trecho também.
|
|
266
|
+
y2 = y
|
|
267
|
+
while y2 > y - ANDAR and det[k_env(y2 - HOP)] > fraco:
|
|
268
|
+
y2 -= HOP
|
|
269
|
+
if ini: # começo do vídeo: entra quase direto na 1ª palavra
|
|
270
|
+
k_lo, k_fim = 0, math.floor((y2 - a.inicio) * fps + 1e-6)
|
|
271
|
+
nL = k_fim
|
|
272
|
+
else:
|
|
273
|
+
lo = x + PRE0
|
|
274
|
+
hi = min(y - POS0, y2 - POS1)
|
|
275
|
+
k_lo, k_fim = math.ceil(lo * fps - 1e-6), math.floor(hi * fps + 1e-6) # quadros onde o corte cabe
|
|
276
|
+
nL = min(math.floor(((y - x) - a.pausa_max) * fps + 1e-6), k_fim - k_lo)
|
|
277
|
+
if nL < 2:
|
|
278
|
+
pulados.append(dict(pausa=[round(x, 3), round(y, 3)], motivo="pausa curta demais depois das folgas"))
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
def busca(nL):
|
|
282
|
+
best = None
|
|
283
|
+
centro = (k_lo + k_fim - nL) / 2
|
|
284
|
+
for k in range(k_lo, (k_lo if ini else k_fim - nL) + 1):
|
|
285
|
+
if k + nL >= n_quadros:
|
|
286
|
+
break
|
|
287
|
+
s0, s1 = k * fd, (k + nL) * fd
|
|
288
|
+
custo = float(act[k: k + nL + 1].sum()) if len(act) else 0.0
|
|
289
|
+
if bloqueado(s0, s1):
|
|
290
|
+
custo += 1000
|
|
291
|
+
custo += 1e-3 * abs(k - centro)
|
|
292
|
+
if best is None or custo < best[0]:
|
|
293
|
+
best = (custo, k, nL)
|
|
294
|
+
return best
|
|
295
|
+
|
|
296
|
+
best = busca(nL)
|
|
297
|
+
while best is not None and best[0] >= 1000 and nL > 3: # encolhe até não pegar efeito sonoro
|
|
298
|
+
nL -= 1
|
|
299
|
+
best = busca(nL)
|
|
300
|
+
if best is None or best[0] >= 1000:
|
|
301
|
+
pulados.append(dict(pausa=[round(x, 3), round(y, 3)], motivo="só dava pra cortar em cima de efeito sonoro"))
|
|
302
|
+
continue
|
|
303
|
+
custo, k, nL = best
|
|
304
|
+
# trava de segurança: o trecho removido não pode ter voz acima do limiar
|
|
305
|
+
s0, s1 = k * fd, (k + nL) * fd
|
|
306
|
+
if float(det[k_env(s0):k_env(s1) + 1].max()) >= thr:
|
|
307
|
+
pulados.append(dict(pausa=[round(x, 3), round(y, 3)], motivo="encostaria na fala"))
|
|
308
|
+
continue
|
|
309
|
+
cortes.append(dict(pausa=[round(x, 3), round(y, 3)], q=[k, k + nL], removido=[round(s0, 4), round(s1, 4)],
|
|
310
|
+
dur=round(nL * fd, 4), sobra=round((y - x) - nL * fd, 3), imagem=round(custo, 2),
|
|
311
|
+
inicio=ini))
|
|
312
|
+
|
|
313
|
+
# cortes pedidos (ex.: tomada repetida que a transcrição juntou numa palavra só)
|
|
314
|
+
forc = []
|
|
315
|
+
if a.cortar:
|
|
316
|
+
for par in a.cortar.split(","):
|
|
317
|
+
try:
|
|
318
|
+
p0, p1 = (float(v) for v in par.strip().split("-"))
|
|
319
|
+
except ValueError:
|
|
320
|
+
erro(f"--cortar '{par}' inválido. Formato: 41.06-41.76,50.2-51.0 (segundos do vídeo de entrada)")
|
|
321
|
+
if p1 <= p0:
|
|
322
|
+
erro(f"--cortar '{par}': o fim tem que ser depois do começo")
|
|
323
|
+
k0, k1 = int(round(p0 * fps)), int(round(p1 * fps))
|
|
324
|
+
forc.append(dict(pausa=[p0, p1], q=[k0, k1], removido=[round(k0 * fd, 4), round(k1 * fd, 4)],
|
|
325
|
+
dur=round((k1 - k0) * fd, 4), forcado=True))
|
|
326
|
+
cortes = [c for c in cortes if not any(c["q"][0] < f["q"][1] and c["q"][1] > f["q"][0] for f in forc)]
|
|
327
|
+
cortes += forc
|
|
328
|
+
cortes.sort(key=lambda c: c["q"][0])
|
|
329
|
+
# junta cortes que se encostam
|
|
330
|
+
junt = []
|
|
331
|
+
for c in cortes:
|
|
332
|
+
if junt and c["q"][0] <= junt[-1]["q"][1]:
|
|
333
|
+
junt[-1]["q"][1] = max(junt[-1]["q"][1], c["q"][1])
|
|
334
|
+
junt[-1]["removido"][1] = round(junt[-1]["q"][1] * fd, 4)
|
|
335
|
+
junt[-1]["dur"] = round((junt[-1]["q"][1] - junt[-1]["q"][0]) * fd, 4)
|
|
336
|
+
else:
|
|
337
|
+
junt.append(c)
|
|
338
|
+
cortes = junt
|
|
339
|
+
manter, t = [], 0
|
|
340
|
+
for c in cortes:
|
|
341
|
+
if c["q"][0] > t:
|
|
342
|
+
manter.append([t, c["q"][0]])
|
|
343
|
+
t = max(t, c["q"][1])
|
|
344
|
+
if t < n_quadros:
|
|
345
|
+
manter.append([t, n_quadros])
|
|
346
|
+
return dict(limiar_db=round(thr, 1), fraco_db=round(fraco, 1), fundo_db=round(floor5, 1), fala_db=round(fala, 1),
|
|
347
|
+
cortes=cortes, pulados=pulados, manter_q=manter)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ------------------------------------------------------------------ render
|
|
351
|
+
def renderizar(a, info: dict, plano: dict, saida: str, td: str) -> dict:
|
|
352
|
+
fps = info["fps"]
|
|
353
|
+
num, den = fps.numerator, fps.denominator
|
|
354
|
+
manter = plano["manter_q"]
|
|
355
|
+
# vídeo: select pelos números dos quadros (um decodificador só, exato no quadro)
|
|
356
|
+
sel = "+".join(f"between(n\\,{k0}\\,{k1 - 1})" for k0, k1 in manter)
|
|
357
|
+
vf = f"select={sel},setpts=N*{den}/{num}/TB"
|
|
358
|
+
script = os.path.join(td, "vf.txt")
|
|
359
|
+
Path(script).write_text(vf)
|
|
360
|
+
v_tmp = os.path.join(td, "video.mp4")
|
|
361
|
+
extra = []
|
|
362
|
+
v = info["v"]
|
|
363
|
+
for chave, opt in (("color_space", "-colorspace"), ("color_primaries", "-color_primaries"),
|
|
364
|
+
("color_transfer", "-color_trc"), ("color_range", "-color_range")):
|
|
365
|
+
if v.get(chave) and v[chave] not in ("unknown", "reserved"):
|
|
366
|
+
extra += [opt, v[chave]]
|
|
367
|
+
preset = "slow" if info["dur_v"] <= 180 else "medium"
|
|
368
|
+
run(["ffmpeg", "-v", "error", "-y", "-i", a.video, "-an", "-/vf", script, "-fps_mode", "cfr", "-r", f"{num}/{den}",
|
|
369
|
+
"-c:v", "libx264", "-preset", preset, "-crf", str(a.crf), "-pix_fmt", "yuv420p", *extra, v_tmp])
|
|
370
|
+
# áudio: corte por amostra, com micro fade em cada emenda
|
|
371
|
+
sr, ch = info["sr"], info["ch"]
|
|
372
|
+
raw = run(["ffmpeg", "-v", "error", "-i", a.video, "-vn", "-f", "f32le", "-acodec", "pcm_f32le", "-"],
|
|
373
|
+
texto=False).stdout
|
|
374
|
+
au = np.frombuffer(raw, dtype=np.float32).reshape(-1, ch)
|
|
375
|
+
off = info["v0"] - info["a0"] # áudio que começa depois do vídeo (raro em vídeo renderizado)
|
|
376
|
+
nf = int(FADE * sr)
|
|
377
|
+
pedacos = []
|
|
378
|
+
for i, (k0, k1) in enumerate(manter):
|
|
379
|
+
s0 = max(0, int(round((k0 * den / num + off) * sr)))
|
|
380
|
+
s1 = min(len(au), int(round((k1 * den / num + off) * sr)))
|
|
381
|
+
p = au[s0:s1].copy()
|
|
382
|
+
rampa = np.linspace(0, 1, min(nf, len(p)), dtype=np.float32)[:, None]
|
|
383
|
+
if i > 0 and len(p):
|
|
384
|
+
p[: len(rampa)] *= rampa
|
|
385
|
+
if i < len(manter) - 1 and len(p):
|
|
386
|
+
p[len(p) - len(rampa):] *= rampa[::-1]
|
|
387
|
+
pedacos.append(p)
|
|
388
|
+
alvo = sum(k1 - k0 for k0, k1 in manter) * den / num
|
|
389
|
+
au_out = np.concatenate(pedacos) if pedacos else np.zeros((0, ch), np.float32)
|
|
390
|
+
falta = int(round(alvo * sr)) - len(au_out)
|
|
391
|
+
if falta > 0: # áudio mais curto que o vídeo no fim: completa com silêncio
|
|
392
|
+
au_out = np.concatenate([au_out, np.zeros((falta, ch), np.float32)])
|
|
393
|
+
a_tmp = os.path.join(td, "audio.wav")
|
|
394
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "f32le", "-ar", str(sr), "-ac", str(ch), "-i", "-",
|
|
395
|
+
"-c:a", "pcm_f32le", a_tmp], input=au_out.tobytes(), check=True)
|
|
396
|
+
j, tipo, _ = finalizar(v_tmp, a_tmp, saida)
|
|
397
|
+
return dict(volume_antes=float(j["input_i"]), ajuste=tipo)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def mapa_tempo(manter: list, fd: float) -> list:
|
|
401
|
+
"""[[início na entrada, fim na entrada, início na saída], ...] em segundos"""
|
|
402
|
+
out, t = [], 0
|
|
403
|
+
for k0, k1 in manter:
|
|
404
|
+
out.append([round(k0 * fd, 4), round(k1 * fd, 4), round(t * fd, 4)])
|
|
405
|
+
t += k1 - k0
|
|
406
|
+
return out
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def conferir(a, info: dict, plano: dict, saida: str, mini_src, thr_entrada: float, lufs_antes: float) -> dict:
|
|
410
|
+
fps = float(info["fps"])
|
|
411
|
+
fd = 1 / fps
|
|
412
|
+
manter = plano["manter_q"]
|
|
413
|
+
res = {}
|
|
414
|
+
so = sonda(saida)
|
|
415
|
+
esperado = sum(k1 - k0 for k0, k1 in manter) * fd
|
|
416
|
+
res["duracao_esperada"] = round(esperado, 3)
|
|
417
|
+
res["duracao_video"] = round(so.get("dur_v", 0), 3)
|
|
418
|
+
res["duracao_audio"] = round(so.get("dur_a", 0), 3)
|
|
419
|
+
# imagem: cada quadro da saída tem que ser o quadro certo da entrada
|
|
420
|
+
if mini_src is not None:
|
|
421
|
+
idx = np.concatenate([np.arange(k0, k1) for k0, k1 in manter]) if manter else np.zeros(0, int)
|
|
422
|
+
lh, lw = mini_src.shape[1] * 2, mini_src.shape[2] * 2
|
|
423
|
+
certos = total = 0
|
|
424
|
+
piores = []
|
|
425
|
+
for j, f in enumerate(quadros(saida, lw, lh)):
|
|
426
|
+
if j >= len(idx):
|
|
427
|
+
break
|
|
428
|
+
m = f.reshape(lh, lw)[::2, ::2].astype(np.int16)
|
|
429
|
+
e = int(idx[j])
|
|
430
|
+
viz = {d: float(np.abs(m - mini_src[e + d]).mean()) for d in (-2, -1, 0, 1, 2) if 0 <= e + d < len(mini_src)}
|
|
431
|
+
total += 1
|
|
432
|
+
if viz[0] <= min(viz.values()) + 0.35:
|
|
433
|
+
certos += 1
|
|
434
|
+
else:
|
|
435
|
+
piores.append(j)
|
|
436
|
+
res["quadros_conferidos"] = total
|
|
437
|
+
res["quadros_no_lugar"] = certos
|
|
438
|
+
res["quadros_fora"] = [round(j * fd, 3) for j in piores[:10]]
|
|
439
|
+
# som: cada trecho mantido tem que estar no mesmo lugar da imagem (correlação com a entrada)
|
|
440
|
+
ent, sai = audio_mono(a.video), audio_mono(saida)
|
|
441
|
+
lags = []
|
|
442
|
+
for (t_in0, t_in1, t_out0) in mapa_tempo(manter, fd):
|
|
443
|
+
if t_in1 - t_in0 < 0.7:
|
|
444
|
+
continue
|
|
445
|
+
meio = (t_in1 - t_in0) / 2
|
|
446
|
+
l = lag_ms(ent, sai, t_in0 + meio, t_out0 + meio)
|
|
447
|
+
if l is not None:
|
|
448
|
+
lags.append(abs(l))
|
|
449
|
+
res["trechos_de_som_conferidos"] = len(lags)
|
|
450
|
+
res["desvio_som_ms"] = round(max(lags), 2) if lags else None
|
|
451
|
+
# pausas que sobraram (medidas na saída)
|
|
452
|
+
fim = loudness(saida)
|
|
453
|
+
res["lufs_final"] = float(fim["input_i"])
|
|
454
|
+
res["pico_final_dbtp"] = float(fim["input_tp"])
|
|
455
|
+
ganho = res["lufs_final"] - lufs_antes # a saída foi normalizada: o limiar acompanha o ganho
|
|
456
|
+
es = env_db(sai)
|
|
457
|
+
sobras = [(x, y) for x, y in intervalos(es < thr_entrada + ganho)
|
|
458
|
+
if (y - x) > a.pausa_max + 0.07 and x > 0.02 and y < len(es) * HOP - 0.05]
|
|
459
|
+
res["pausas_que_sobraram"] = [[round(x, 2), round(y - x, 2)] for x, y in sobras]
|
|
460
|
+
return res
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def main(argv=None):
|
|
464
|
+
try:
|
|
465
|
+
sys.stdout.reconfigure(line_buffering=True) # mensagens aparecem na hora, mesmo em log
|
|
466
|
+
except AttributeError:
|
|
467
|
+
pass
|
|
468
|
+
ap = argparse.ArgumentParser(description="Tira as pausas que sobraram no vídeo pronto, sem cortar palavra.")
|
|
469
|
+
ap.add_argument("video", help="vídeo final já renderizado (sem trilha, de preferência)")
|
|
470
|
+
ap.add_argument("--pausa-max", type=float, default=0.15, help="silêncio que fica entre as frases (0.15 s)")
|
|
471
|
+
ap.add_argument("--pausa-min", type=float, default=0.25, help="só mexe em pausas a partir disso (0.25 s)")
|
|
472
|
+
ap.add_argument("--sem-grafismo", metavar="RENDER", help="render só com os grafismos (sem a pessoa), mesma duração")
|
|
473
|
+
ap.add_argument("--sfx", metavar="PLANO", help="plano de sons (plano_sfx.json): não corta no ataque de efeito")
|
|
474
|
+
ap.add_argument("--voz", metavar="AUDIO", help="áudio só da voz no mesmo tempo do vídeo (ex.: o cut.mp4 do corte, Fase 1)")
|
|
475
|
+
ap.add_argument("--cortar", help="trechos a tirar de qualquer jeito, em segundos: 41.06-41.76,50.2-51.0")
|
|
476
|
+
ap.add_argument("--inicio", type=float, default=0.08, help="silêncio antes da 1ª palavra (0.08 s)")
|
|
477
|
+
ap.add_argument("--limiar", type=float, help="dBFS abaixo do qual é silêncio (padrão: medido no vídeo)")
|
|
478
|
+
ap.add_argument("--crf", type=int, default=16, help="qualidade do vídeo (16 = muito alta)")
|
|
479
|
+
ap.add_argument("--saida", help="arquivo de saída (padrão: <video>_seco.mp4)")
|
|
480
|
+
ap.add_argument("--so-plano", action="store_true", help="só mostra e salva o plano, sem renderizar")
|
|
481
|
+
a = ap.parse_args(argv)
|
|
482
|
+
|
|
483
|
+
for b in ("ffmpeg", "ffprobe"):
|
|
484
|
+
if not shutil.which(b):
|
|
485
|
+
erro(f"não achei o {b}. Instale o ffmpeg (no Mac: brew install ffmpeg).")
|
|
486
|
+
if not Path(a.video).exists():
|
|
487
|
+
erro(f"não achei {a.video}")
|
|
488
|
+
if not 0.05 <= a.pausa_max <= 1.0:
|
|
489
|
+
erro("--pausa-max fica entre 0.05 e 1.0 s (o André usa 0.15)")
|
|
490
|
+
if a.pausa_min < a.pausa_max + 0.05:
|
|
491
|
+
a.pausa_min = round(a.pausa_max + 0.1, 3)
|
|
492
|
+
saida = a.saida or str(Path(a.video).with_name(Path(a.video).stem + "_seco.mp4"))
|
|
493
|
+
if Path(saida).resolve() == Path(a.video).resolve():
|
|
494
|
+
erro("a saída não pode ser o mesmo arquivo da entrada")
|
|
495
|
+
info = sonda(a.video)
|
|
496
|
+
if not info.get("v") or not info.get("a"):
|
|
497
|
+
erro("o vídeo precisa ter imagem e som")
|
|
498
|
+
fps = float(info["fps"])
|
|
499
|
+
fd = 1 / fps
|
|
500
|
+
|
|
501
|
+
print(f"lendo {Path(a.video).name}: {info['dur_v']:.2f} s, {info['w']}x{info['h']}, {fps:g} quadros/s")
|
|
502
|
+
mix = audio_mono(a.video)
|
|
503
|
+
mix_env = env_db(mix)
|
|
504
|
+
usa_voz = False
|
|
505
|
+
det = mix_env
|
|
506
|
+
if a.voz:
|
|
507
|
+
if not Path(a.voz).exists():
|
|
508
|
+
erro(f"não achei {a.voz}")
|
|
509
|
+
voz = audio_mono(a.voz)
|
|
510
|
+
lags = []
|
|
511
|
+
for t in np.linspace(2, min(len(voz), len(mix)) / SR_ENV - 2, 8):
|
|
512
|
+
l = lag_ms(voz, mix, float(t), float(t))
|
|
513
|
+
if l is not None:
|
|
514
|
+
lags.append(abs(l))
|
|
515
|
+
if lags and max(lags) <= 20:
|
|
516
|
+
det, usa_voz = env_db(voz), True
|
|
517
|
+
else:
|
|
518
|
+
aviso("o áudio de --voz não está no mesmo tempo do vídeo; vou medir as pausas no próprio vídeo")
|
|
519
|
+
bloqueios = carregar_sfx(a.sfx) if a.sfx else []
|
|
520
|
+
|
|
521
|
+
act_final, mini = atividade(a.video, info["w"], info["h"], guardar=not a.so_plano)
|
|
522
|
+
n_quadros = len(act_final) or int(round(info["dur_v"] * fps))
|
|
523
|
+
act = act_final
|
|
524
|
+
fonte_img = "diferença entre quadros do próprio vídeo"
|
|
525
|
+
if a.sem_grafismo:
|
|
526
|
+
ig = sonda(a.sem_grafismo)
|
|
527
|
+
if not ig.get("v"):
|
|
528
|
+
erro(f"{a.sem_grafismo} não tem vídeo")
|
|
529
|
+
act_g, _ = atividade(a.sem_grafismo, ig["w"], ig["h"])
|
|
530
|
+
if abs(len(act_g) - n_quadros) <= 1:
|
|
531
|
+
act = act_g[:n_quadros] if len(act_g) >= n_quadros else np.pad(act_g, (0, n_quadros - len(act_g)))
|
|
532
|
+
fonte_img = "render só com grafismo"
|
|
533
|
+
else:
|
|
534
|
+
aviso(f"o render de grafismo tem {len(act_g)} quadros e o vídeo {n_quadros}; usei o próprio vídeo")
|
|
535
|
+
|
|
536
|
+
plano = planejar(a, info, det, mix_env, usa_voz, act, bloqueios, n_quadros)
|
|
537
|
+
cortes = plano["cortes"]
|
|
538
|
+
removido = sum(c["dur"] for c in cortes)
|
|
539
|
+
dur_saida = sum(k1 - k0 for k0, k1 in plano["manter_q"]) * fd
|
|
540
|
+
print(f"limiar de silêncio {plano['limiar_db']} dB (fala {plano['fala_db']}, fundo {plano['fundo_db']}) · "
|
|
541
|
+
f"ponto de corte pela {fonte_img}" + (" · pausas medidas no áudio da voz" if usa_voz else ""))
|
|
542
|
+
print(f"{len(cortes)} cortes, {removido:.2f} s fora: {info['dur_v']:.2f} -> {dur_saida:.2f} s")
|
|
543
|
+
for c in cortes:
|
|
544
|
+
if c.get("forcado"):
|
|
545
|
+
print(f" corte pedido {c['removido'][0]:7.3f} - {c['removido'][1]:7.3f} ({c['dur']:.3f} s)")
|
|
546
|
+
else:
|
|
547
|
+
print(f" pausa {c['pausa'][0]:7.2f}-{c['pausa'][1]:6.2f} tira {c['removido'][0]:7.3f}-{c['removido'][1]:7.3f}"
|
|
548
|
+
f" ({c['dur']:.3f} s, sobra {c['sobra']:.2f} s, imagem {c['imagem']:.1f})"
|
|
549
|
+
+ (" começo" if c.get("inicio") else ""))
|
|
550
|
+
for p in plano["pulados"]:
|
|
551
|
+
print(f" mantida: pausa {p['pausa'][0]:.2f}-{p['pausa'][1]:.2f} ({p['motivo']})")
|
|
552
|
+
rel = dict(video=a.video, saida=saida, pausa_max=a.pausa_max, pausa_min=a.pausa_min, fps=fps,
|
|
553
|
+
ponto_de_corte=fonte_img, sfx=a.sfx, voz=a.voz if usa_voz else None,
|
|
554
|
+
limiar_db=plano["limiar_db"], fraco_db=plano["fraco_db"], cortes=cortes, mantidas=plano["pulados"],
|
|
555
|
+
removido_s=round(removido, 3), duracao_entrada=round(info["dur_v"], 3), duracao_saida=round(dur_saida, 3),
|
|
556
|
+
mapa=mapa_tempo(plano["manter_q"], fd))
|
|
557
|
+
rel_path = (saida + ".corte.json")
|
|
558
|
+
if a.so_plano:
|
|
559
|
+
Path(rel_path).write_text(json.dumps(rel, ensure_ascii=False, indent=1))
|
|
560
|
+
print(f"plano salvo em {rel_path} (nada foi renderizado)")
|
|
561
|
+
return
|
|
562
|
+
with tempfile.TemporaryDirectory() as td:
|
|
563
|
+
r = renderizar(a, info, plano, saida, td)
|
|
564
|
+
conf = conferir(a, info, plano, saida, mini, plano["limiar_db"], r["volume_antes"])
|
|
565
|
+
rel["conferencia"] = conf
|
|
566
|
+
rel["ajuste_volume"] = r["ajuste"]
|
|
567
|
+
Path(rel_path).write_text(json.dumps(rel, ensure_ascii=False, indent=1))
|
|
568
|
+
print(f"saída: {saida}")
|
|
569
|
+
print(f" duração vídeo {conf['duracao_video']:.3f} s, áudio {conf['duracao_audio']:.3f} s "
|
|
570
|
+
f"(esperado {conf['duracao_esperada']:.3f}) · volume {conf['lufs_final']} LUFS, pico "
|
|
571
|
+
f"{conf['pico_final_dbtp']} dBTP (ajuste {r['ajuste']})")
|
|
572
|
+
if "quadros_conferidos" in conf:
|
|
573
|
+
print(f" imagem: {conf['quadros_no_lugar']} de {conf['quadros_conferidos']} quadros no lugar certo · som: "
|
|
574
|
+
f"{conf['trechos_de_som_conferidos']} trechos conferidos, desvio máximo {conf['desvio_som_ms']} ms")
|
|
575
|
+
if conf["pausas_que_sobraram"]:
|
|
576
|
+
print(f" pausas acima de {a.pausa_max + 0.07:.2f} s que ficaram (efeito sonoro ou som fraco na borda): "
|
|
577
|
+
+ ", ".join(f"{x:.2f} s ({d:.2f})" for x, d in conf["pausas_que_sobraram"][:12]))
|
|
578
|
+
else:
|
|
579
|
+
print(f" nenhuma pausa acima de {a.pausa_max + 0.07:.2f} s ficou")
|
|
580
|
+
ok = True
|
|
581
|
+
if abs(conf["duracao_video"] - conf["duracao_esperada"]) > 1.5 * fd or abs(conf["duracao_audio"] - conf["duracao_video"]) > 0.05:
|
|
582
|
+
aviso("a duração não bate com o esperado; confira antes de entregar")
|
|
583
|
+
ok = False
|
|
584
|
+
if conf.get("desvio_som_ms") is not None and conf["desvio_som_ms"] > 5:
|
|
585
|
+
aviso("o som escorregou em relação à imagem; não entregue sem conferir")
|
|
586
|
+
ok = False
|
|
587
|
+
if "quadros_conferidos" in conf and conf["quadros_no_lugar"] < conf["quadros_conferidos"] * 0.98:
|
|
588
|
+
aviso(f"quadros fora do lugar em {conf['quadros_fora']}; confira")
|
|
589
|
+
ok = False
|
|
590
|
+
print(" tudo certo" if ok else " ATENÇÃO: veja os avisos acima")
|
|
591
|
+
print(f"relatório: {rel_path}")
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
if __name__ == "__main__":
|
|
595
|
+
main()
|