primocode 10.16.0 → 10.17.1
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 +28 -8
- 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/mcp.js +105 -7
- package/lib/nuvem.js +11 -1
- package/lib/outra-pasta.js +1 -1
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +29 -12
- package/lib/protecao.js +134 -0
- package/lib/sandbox.js +11 -4
- 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,839 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Plano de efeitos sonoros casado com a animação (references/som-e-trilha.md).
|
|
3
|
+
|
|
4
|
+
Recebe a lista de eventos visuais do vídeo (o instante em que cada animação "bate") e devolve o
|
|
5
|
+
JSON de mixagem: qual som, onde ele começa, com que ganho, com o PICO do som caindo no quadro do
|
|
6
|
+
evento. Tira o excesso (espaço mínimo entre sons, fica o mais forte) e respeita um máximo por vídeo.
|
|
7
|
+
|
|
8
|
+
Eventos (JSON, lista, {"eventos": [...]} ou o sons_sugeridos.json do modelo-projeto, com "sons" e "em"):
|
|
9
|
+
[{"t": 1.23, "tipo": "pop"},
|
|
10
|
+
{"t": 4.10, "tipo": "impacto", "nivel": "destaque"},
|
|
11
|
+
{"t": 7.64, "tipo": "whoosh-grande", "forcar": true, "nota": "tela cheia vira moldura"},
|
|
12
|
+
{"t": 9.00, "som": "woven:notification-ding", "nivel": -11},
|
|
13
|
+
{"t": 12.0, "tipo": "digitar", "dur": 1.2}]
|
|
14
|
+
t segundos no vídeo, o quadro em que o movimento bate (pouso do pop, meio do deslize)
|
|
15
|
+
tipo um dos tipos de --tipos (aceita apelidos: "aparece", "deslize", "carimbo"...)
|
|
16
|
+
som opcional, troca o som do tipo: woven:ID, kit:NOME, bib:ID, bib:categoria[:nome] ou caminho
|
|
17
|
+
nivel opcional: "destaque" (-12), "medio" (-15), "discreto" (-18) ou número em dBFS de pico
|
|
18
|
+
db o nível que a peça pede no sons_sugeridos.json do build (dBFS de pico): vale quando não tem "nivel"
|
|
19
|
+
forcar opcional: fica mesmo perto de outro som e mesmo passando do máximo
|
|
20
|
+
dur opcional: quanto tempo o som toca (digitação, aplauso)
|
|
21
|
+
|
|
22
|
+
Uso (<SKILL> é a pasta desta skill; o Python dela tem numpy):
|
|
23
|
+
uv run --directory "<SKILL>" python helpers/plano_sfx.py --projeto "<PROJ>" [eventos.json ...]
|
|
24
|
+
o caminho do projeto-modelo: lê <PROJ>/sons_sugeridos.json (e <PROJ>/eventos.json, se existir, com os
|
|
25
|
+
eventos das cenas próprias), monta o plano, copia os sons para <PROJ>/assets/sfx e escreve o campo "sons"
|
|
26
|
+
do <PROJ>/projeto.json. Depois: python3 build.py completo (o estúdio toca os sons) e o render.
|
|
27
|
+
uv run --directory "<SKILL>" python helpers/plano_sfx.py eventos.json [...] [--saida plano_sfx.json]
|
|
28
|
+
[--video final.mp4 | --duracao 58.2] [--modo motion|fala] [--max N] [--espaco 0.65] [--variar]
|
|
29
|
+
[--aplicar final.mp4 --video-saida final_com_sons.mp4]
|
|
30
|
+
uv run --directory "<SKILL>" python helpers/plano_sfx.py --tipos lista os tipos e os sons de cada um
|
|
31
|
+
uv run --directory "<SKILL>" python helpers/plano_sfx.py --medir ARQ.wav duração, pico e começo de um som
|
|
32
|
+
|
|
33
|
+
Máximo de sons: no modo motion (padrão) é um por animação, e o que decide é o espaço mínimo entre sons (0,65 s,
|
|
34
|
+
fica o mais forte). No modo fala, uns 5 ou 6 no vídeo (um a cada 15 s). --max N troca.
|
|
35
|
+
|
|
36
|
+
Onde os sons moram (tudo dentro da skill, menos a biblioteca):
|
|
37
|
+
woven:ID <SKILL>/assets/sfx/woven/ID.wav (35 sons Woven SFX, CC0)
|
|
38
|
+
kit:NOME <SKILL>/assets/sfx (índice sfx.json); outra pasta: variável SC_KIT_SFX
|
|
39
|
+
bib:... ~/.editor-video/efeitos (índice efeitos.json); outra pasta: SC_BIBLIOTECA_SFX ou EDITOR_VIDEO_EFEITOS
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import math
|
|
46
|
+
import os
|
|
47
|
+
import shutil
|
|
48
|
+
import subprocess
|
|
49
|
+
import sys
|
|
50
|
+
import tempfile
|
|
51
|
+
import unicodedata
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
import numpy as np
|
|
56
|
+
except ImportError: # pragma: no cover
|
|
57
|
+
sys.exit('Falta o numpy. Rode com o Python da skill: uv run --directory "<SKILL>" python helpers/plano_sfx.py ...')
|
|
58
|
+
|
|
59
|
+
SKILL = Path(__file__).resolve().parent.parent
|
|
60
|
+
WOVEN = SKILL / "assets" / "sfx" / "woven"
|
|
61
|
+
KIT = Path(os.environ.get("SC_KIT_SFX") or SKILL / "assets" / "sfx")
|
|
62
|
+
BIB = Path(os.environ.get("SC_BIBLIOTECA_SFX") or os.environ.get("EDITOR_VIDEO_EFEITOS")
|
|
63
|
+
or Path.home() / ".editor-video" / "efeitos").expanduser()
|
|
64
|
+
|
|
65
|
+
VOZ_REF_LUFS = -14.0 # os níveis da tabela valem com a voz perto de -14 LUFS
|
|
66
|
+
# pico de amostra de verdade, medido no arquivo. Calibrado no IMG_1498 v2 (aprovado): destaques perto de -12,
|
|
67
|
+
# pequenos perto de -18, com a voz em -14 LUFS.
|
|
68
|
+
NIVEIS = {"destaque": -12.0, "forte": -12.0, "medio": -15.0, "médio": -15.0, "discreto": -18.0, "fraco": -18.0,
|
|
69
|
+
"baixo": -20.0}
|
|
70
|
+
GANHO_MAX_DB = 12.0
|
|
71
|
+
|
|
72
|
+
# tipo: (quando usar, sons em ordem de preferência, nível do pico em dBFS, alinhar em, cauda s depois da âncora, apelidos)
|
|
73
|
+
TIPOS = {
|
|
74
|
+
"pop": ("algo aparece: texto, ícone, cartão, balão", ["woven:pop-hand", "kit:pop"], -18.5, "pico", 0.8,
|
|
75
|
+
["aparece", "surge", "bolha", "icone", "cartao", "balao", "entra-texto"]),
|
|
76
|
+
"whoosh": ("algo desliza, entra ou sai rápido da tela", ["woven:fast-whoosh", "kit:whoosh"], -17.5, "pico", 0.8,
|
|
77
|
+
["deslize", "desliza", "entra", "sai", "voa", "passa", "swipe", "transicao", "swoosh"]),
|
|
78
|
+
"whoosh-grande": ("troca grande de cena: tela cheia vira moldura, a câmera gira, painel inteiro entra",
|
|
79
|
+
["woven:swish-whoosh-large", "woven:deep-whoosh"], -12.5, "pico", 0.8,
|
|
80
|
+
["troca-de-cena", "moldura", "tela-cheia", "virada", "giro", "whoosh-grave"]),
|
|
81
|
+
"impacto": ("algo pousa com força: carimbo, número final, palavra grande que cai",
|
|
82
|
+
["woven:bass-impact", "kit:bass_drop"], -11.5, "pico", 0.8,
|
|
83
|
+
["carimbo", "pousa", "bate-forte", "numero", "titulo", "tremida", "boom"]),
|
|
84
|
+
"revelacao": ("a grande revelação: logo, produto, placa final (1 ou 2 por vídeo)",
|
|
85
|
+
["woven:base-drop", "kit:metal_hit"], -11.5, "pico", 1.2,
|
|
86
|
+
["drop", "grande-revelacao", "logo", "marca", "placa", "produto"]),
|
|
87
|
+
"batida": ("algo bate ou encaixa sem drama: item que empilha, peça que encaixa", ["woven:dull-thud"], -14.5, "pico",
|
|
88
|
+
0.6, ["thud", "bate", "encaixa", "empilha", "encosta"]),
|
|
89
|
+
"risco": ("algo é riscado, cancelado ou negado", ["woven:record-needle-scratch", "woven:sideways-record-needle"],
|
|
90
|
+
-12, "pico", 0.8, ["riscar", "risca", "cancela", "x", "nao", "scratch", "anula"]),
|
|
91
|
+
"notificacao": ("notificação chega, venda cai, celular recebe", ["woven:notification-ding", "kit:notification"],
|
|
92
|
+
-12.5, "pico", 0.9, ["ding", "chegou", "alerta", "venda-caiu", "pix"]),
|
|
93
|
+
"mensagem": ("balão de conversa, botão final, resposta no chat", ["woven:message-pop-reply"], -15.5, "pico", 0.8,
|
|
94
|
+
["botao", "resposta", "chat", "whatsapp", "balao-de-conversa"]),
|
|
95
|
+
"glitch": ("tela liga, IA entra, falha digital", ["woven:glitch-logo", "kit:data"], -13.5, "pico", 0.7,
|
|
96
|
+
["tecnologia", "ia", "liga", "falha", "digital", "interferencia"]),
|
|
97
|
+
"clique": ("cursor clica, dedo toca num botão", ["woven:mouse-click-2", "woven:mouse-click-1", "kit:mouse_click"],
|
|
98
|
+
-18, "pico", 0.5, ["click", "cursor", "toque", "tap", "seleciona", "aperta"]),
|
|
99
|
+
"digitar": ("texto sendo digitado na tela (use \"dur\" com o tempo da digitação)",
|
|
100
|
+
["woven:keyboard-typing-realistic", "kit:keyboard"], -22, "inicio", 1.0,
|
|
101
|
+
["teclado", "digitando", "escrevendo", "digitacao"]),
|
|
102
|
+
"tique": ("item marcado, check, contagem", ["woven:tick-sound", "kit:tick"], -19, "pico", 0.4,
|
|
103
|
+
["tick", "check", "marca", "item", "contagem", "conta", "confere"]),
|
|
104
|
+
"beep": ("bipe curto de interface", ["woven:beep-short"], -19, "pico", 0.5, ["bipe", "bip"]),
|
|
105
|
+
"dinheiro": ("moedas, dinheiro, preço que aparece", ["woven:several-coins", "kit:cash", "woven:money-chalking"],
|
|
106
|
+
-15, "pico", 0.9, ["moeda", "moedas", "venda", "caixa", "preco", "grana"]),
|
|
107
|
+
"camera": ("print, foto, captura de tela", ["woven:camera-shutter-1-shot", "kit:click"], -15, "pico", 0.5,
|
|
108
|
+
["print", "foto", "flash", "captura", "obturador"]),
|
|
109
|
+
"subida": ("suspense que cresce até a revelação (o pico é o fim da subida)", ["kit:rise", "kit:metal_riser"],
|
|
110
|
+
-15, "pico", 0.3, ["riser", "cresce", "sobe", "suspense", "tensao"]),
|
|
111
|
+
"sucesso": ("deu certo, meta batida, tarefa concluída", ["kit:success"], -15, "pico", 0.9,
|
|
112
|
+
["certo", "deu-certo", "aprovado", "ok", "concluido"]),
|
|
113
|
+
"erro": ("errado, o que não fazer", ["kit:error"], -15, "pico", 0.8, ["errado", "falhou", "negado"]),
|
|
114
|
+
"surpresa": ("reação de espanto (usar pouco)", ["woven:female-gasp", "woven:male-gasp"], -17, "pico", 0.6,
|
|
115
|
+
["gasp", "uau", "espanto"]),
|
|
116
|
+
"aplauso": ("conquista, fechamento de história (usar pouco)", ["woven:cheers-and-applause", "woven:applause"],
|
|
117
|
+
-22, "inicio", 2.0, ["palmas", "comemora", "conquista"]),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def norm(txt: str) -> str:
|
|
122
|
+
t = unicodedata.normalize("NFKD", str(txt)).encode("ascii", "ignore").decode().lower().strip()
|
|
123
|
+
return t.replace(" ", "-").replace("_", "-")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
APELIDO = {}
|
|
127
|
+
for _tipo, _d in TIPOS.items():
|
|
128
|
+
APELIDO[norm(_tipo)] = _tipo
|
|
129
|
+
for _a in _d[5]:
|
|
130
|
+
APELIDO.setdefault(norm(_a), _tipo)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def erro(msg: str, code: int = 2):
|
|
134
|
+
print("ERRO: " + msg, file=sys.stderr)
|
|
135
|
+
sys.exit(code)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def precisa_ffmpeg():
|
|
139
|
+
for b in ("ffmpeg", "ffprobe"):
|
|
140
|
+
if not shutil.which(b):
|
|
141
|
+
erro(f"não achei o {b}. Instale o ffmpeg (no Mac: brew install ffmpeg).")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------ onde estão os sons
|
|
145
|
+
def kit_indice() -> dict:
|
|
146
|
+
p = KIT / "sfx.json"
|
|
147
|
+
if not p.exists():
|
|
148
|
+
return {}
|
|
149
|
+
try:
|
|
150
|
+
return json.loads(p.read_text())
|
|
151
|
+
except Exception:
|
|
152
|
+
return {}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def bib_indice() -> dict:
|
|
156
|
+
p = BIB / "efeitos.json"
|
|
157
|
+
if not p.exists():
|
|
158
|
+
return {}
|
|
159
|
+
try:
|
|
160
|
+
return json.loads(p.read_text()).get("efeitos", {})
|
|
161
|
+
except Exception:
|
|
162
|
+
return {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_BIB_GIRO: dict = {}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def resolver_som(ref: str) -> Path | None:
|
|
169
|
+
"""woven:ID, kit:NOME, bib:ID, bib:categoria[:nome], nome solto ou caminho -> arquivo (ou None)"""
|
|
170
|
+
ref = str(ref).strip()
|
|
171
|
+
p = Path(os.path.expanduser(ref))
|
|
172
|
+
if p.suffix.lower() in (".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aif", ".aiff") and p.exists():
|
|
173
|
+
return p.resolve()
|
|
174
|
+
fonte, _, nome = ref.partition(":")
|
|
175
|
+
if not nome:
|
|
176
|
+
fonte, nome = "", ref
|
|
177
|
+
fonte = fonte.lower()
|
|
178
|
+
if fonte in ("", "woven"):
|
|
179
|
+
w = WOVEN / f"{nome}.wav"
|
|
180
|
+
if w.exists():
|
|
181
|
+
return w
|
|
182
|
+
if fonte == "woven":
|
|
183
|
+
return None
|
|
184
|
+
if fonte in ("", "kit"):
|
|
185
|
+
k = kit_indice().get(nome)
|
|
186
|
+
if k and (KIT / k["file"]).exists():
|
|
187
|
+
return KIT / k["file"]
|
|
188
|
+
if fonte == "kit":
|
|
189
|
+
return None
|
|
190
|
+
if fonte in ("", "bib", "biblioteca"):
|
|
191
|
+
ind = bib_indice()
|
|
192
|
+
if nome in ind and (BIB / ind[nome]["arquivo"]).exists():
|
|
193
|
+
return BIB / ind[nome]["arquivo"]
|
|
194
|
+
cat, _, sub = nome.partition(":")
|
|
195
|
+
achados = [k for k, v in sorted(ind.items()) if v.get("categoria") == cat
|
|
196
|
+
and (not sub or norm(sub) in norm(v.get("nome", "")))]
|
|
197
|
+
if achados: # cada chamada pega outra versão da mesma categoria
|
|
198
|
+
i = _BIB_GIRO.get(nome, 0)
|
|
199
|
+
_BIB_GIRO[nome] = i + 1
|
|
200
|
+
return BIB / ind[achados[i % len(achados)]]["arquivo"]
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def id_do_arquivo(p: Path) -> str:
|
|
205
|
+
try:
|
|
206
|
+
if p.parent == WOVEN:
|
|
207
|
+
return "woven:" + p.stem
|
|
208
|
+
if KIT in p.parents:
|
|
209
|
+
for nome, v in kit_indice().items():
|
|
210
|
+
if (KIT / v["file"]).resolve() == p.resolve():
|
|
211
|
+
return "kit:" + nome
|
|
212
|
+
if BIB in p.parents:
|
|
213
|
+
return "bib:" + p.stem
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
return str(p)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------ medir o som
|
|
220
|
+
_MEDIDAS: dict = {}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def ponto_forte(m: "np.ndarray", sr: int) -> int:
|
|
224
|
+
"""o "pico" que o ouvido sente: o 1º instante em que o som chega a 1 dB do máximo. Em som com vários picos
|
|
225
|
+
quase iguais (glitch, risco de disco, impacto saturado) o máximo absoluto pode cair no fim do trecho forte;
|
|
226
|
+
o começo do trecho forte é o quadro do movimento."""
|
|
227
|
+
if not len(m):
|
|
228
|
+
return 0
|
|
229
|
+
w = max(1, int(sr * 0.005))
|
|
230
|
+
n = len(m) // w
|
|
231
|
+
if n == 0:
|
|
232
|
+
return int(m.argmax())
|
|
233
|
+
blocos = m[: n * w].reshape(n, w).max(axis=1)
|
|
234
|
+
k = int(np.nonzero(blocos >= blocos.max() * 10 ** (-1 / 20))[0][0])
|
|
235
|
+
lo = k * w
|
|
236
|
+
return lo + int(m[lo: lo + 2 * w].argmax())
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def medir(p: Path) -> dict:
|
|
240
|
+
"""duração, tempo e nível do pico (máximo entre canais, na taxa nativa) e começo do som"""
|
|
241
|
+
key = str(p)
|
|
242
|
+
if key in _MEDIDAS:
|
|
243
|
+
return _MEDIDAS[key]
|
|
244
|
+
info = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
245
|
+
"stream=sample_rate,channels", "-of", "csv=p=0", str(p)],
|
|
246
|
+
capture_output=True, text=True).stdout.strip().split(",")
|
|
247
|
+
if len(info) < 2 or not info[0]:
|
|
248
|
+
erro(f"não consegui ler o som {p}")
|
|
249
|
+
sr, ch = int(info[0]), int(info[1])
|
|
250
|
+
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(p), "-f", "f32le", "-acodec", "pcm_f32le", "-"],
|
|
251
|
+
capture_output=True).stdout
|
|
252
|
+
a = np.frombuffer(raw, dtype=np.float32)
|
|
253
|
+
a = a[: len(a) // ch * ch].reshape(-1, ch)
|
|
254
|
+
m = np.abs(a).max(axis=1) if len(a) else np.zeros(1)
|
|
255
|
+
i = ponto_forte(m, sr)
|
|
256
|
+
pk = float(m.max()) or 1e-6
|
|
257
|
+
pico_db = 20 * math.log10(pk)
|
|
258
|
+
w = max(1, int(sr * 0.005)) # começo: 1ª janela de 5 ms a menos de 35 dB do pico
|
|
259
|
+
n = len(m) // w
|
|
260
|
+
rms = np.sqrt((a[: n * w].astype(np.float64) ** 2).mean(axis=1).reshape(n, w).mean(axis=1)) if n else np.zeros(1)
|
|
261
|
+
acima = np.nonzero(20 * np.log10(rms + 1e-12) > pico_db - 35)[0]
|
|
262
|
+
inicio = float(acima[0] * w / sr) if len(acima) else 0.0
|
|
263
|
+
pico_s, origem = i / sr, "medido"
|
|
264
|
+
cur = ancora_curada(p)
|
|
265
|
+
if cur is not None and 0 <= cur < len(a) / sr: # kit da skill e biblioteca: o ponto forte já foi escolhido
|
|
266
|
+
pico_s, origem = cur, "curado"
|
|
267
|
+
nb = len(m) // w
|
|
268
|
+
blocos = m[: nb * w].reshape(nb, w).max(axis=1) if nb else m[:1]
|
|
269
|
+
_MEDIDAS[key] = dict(dur=round(len(a) / sr, 4), pico_s=round(pico_s, 4), pico_db=round(pico_db, 2),
|
|
270
|
+
inicio_s=round(min(inicio, pico_s), 4), ponto=origem, sr=sr, canais=ch,
|
|
271
|
+
_blocos=blocos, _bloco_s=w / sr)
|
|
272
|
+
return _MEDIDAS[key]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def pico_no_trecho(md: dict, t0: float, t1: float) -> float:
|
|
276
|
+
"""pico (dBFS) só do pedaço do arquivo que vai tocar"""
|
|
277
|
+
b, bs = md["_blocos"], md["_bloco_s"]
|
|
278
|
+
i0, i1 = int(t0 / bs), max(int(t0 / bs) + 1, int(math.ceil(t1 / bs)))
|
|
279
|
+
v = float(b[i0:i1].max()) if len(b[i0:i1]) else 10 ** (md["pico_db"] / 20)
|
|
280
|
+
return 20 * math.log10(max(v, 1e-6))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def ancora_curada(p: Path):
|
|
284
|
+
"""anchor_s do kit da skill (assets/sfx/sfx.json) ou da biblioteca (efeitos.json), se o arquivo for de lá"""
|
|
285
|
+
try:
|
|
286
|
+
rp = p.resolve()
|
|
287
|
+
if KIT.resolve() in rp.parents:
|
|
288
|
+
for v in kit_indice().values():
|
|
289
|
+
if (KIT / v["file"]).resolve() == rp:
|
|
290
|
+
return float(v["anchor_s"]) if v.get("anchor_s") is not None else None
|
|
291
|
+
if BIB.resolve() in rp.parents:
|
|
292
|
+
for v in bib_indice().values():
|
|
293
|
+
if (BIB / v["arquivo"]).resolve() == rp:
|
|
294
|
+
return float(v["anchor_s"]) if v.get("anchor_s") is not None else None
|
|
295
|
+
except (OSError, ValueError, KeyError):
|
|
296
|
+
return None
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def duracao_midia(p: str) -> float:
|
|
301
|
+
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", p],
|
|
302
|
+
capture_output=True, text=True).stdout.strip()
|
|
303
|
+
try:
|
|
304
|
+
return float(out)
|
|
305
|
+
except ValueError:
|
|
306
|
+
erro(f"não consegui ler a duração de {p}")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def loudness(p: str, af: str = "") -> dict:
|
|
310
|
+
r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", p, "-vn", "-af",
|
|
311
|
+
af + "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"],
|
|
312
|
+
capture_output=True, text=True).stderr
|
|
313
|
+
try:
|
|
314
|
+
return json.loads(r[r.rindex("{"): r.rindex("}") + 1])
|
|
315
|
+
except ValueError:
|
|
316
|
+
erro(f"não consegui medir o volume de {p}")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def cadeia_loudnorm(wav: str, alvo: float = -14.0, tp: float = -1.0) -> tuple[str, dict]:
|
|
320
|
+
"""-14 LUFS em 2 passadas, sempre linear (não mexe na dinâmica). Se o pico real passaria de -1 dBTP,
|
|
321
|
+
um limitador com atraso compensado (latency=1, não desloca o áudio) segura só os picos antes."""
|
|
322
|
+
j = loudness(wav)
|
|
323
|
+
ganho = alvo - float(j["input_i"])
|
|
324
|
+
pre = ""
|
|
325
|
+
if float(j["input_tp"]) + ganho > tp - 0.2:
|
|
326
|
+
lim = 10 ** ((tp - ganho - 1.0) / 20)
|
|
327
|
+
pre = f"alimiter=limit={max(0.0625, min(1.0, lim)):.5f}:attack=5:release=60:level=false:latency=1,"
|
|
328
|
+
j = loudness(wav, pre)
|
|
329
|
+
af = (pre + f"loudnorm=I={alvo}:TP={tp}:LRA=11:measured_I={j['input_i']}:measured_TP={j['input_tp']}"
|
|
330
|
+
f":measured_LRA={j['input_lra']}:measured_thresh={j['input_thresh']}:offset={j['target_offset']}"
|
|
331
|
+
":linear=true:print_format=json,aresample=48000")
|
|
332
|
+
return af, j
|
|
333
|
+
|
|
334
|
+
def finalizar(video: str, wav: str, saida: str) -> tuple[dict, str, dict]:
|
|
335
|
+
"""junta a imagem (copiada) com o áudio em -14 LUFS e confere o pico real DEPOIS do AAC: o codificador pode
|
|
336
|
+
passar do teto em até ~2 dB. Se passou de -0,5 dBTP, refaz com o teto mais baixo (um limitador leve só nos
|
|
337
|
+
picos; o volume integrado continua -14 LUFS)."""
|
|
338
|
+
tp = -1.0
|
|
339
|
+
for tentativa in range(2):
|
|
340
|
+
af, j = cadeia_loudnorm(wav, tp=tp)
|
|
341
|
+
r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", video, "-i", wav, "-map", "0:v:0",
|
|
342
|
+
"-map", "1:a:0", "-c:v", "copy", "-af", af, "-c:a", "aac", "-b:a", "256k",
|
|
343
|
+
"-movflags", "+faststart", saida], capture_output=True, text=True)
|
|
344
|
+
if r.returncode:
|
|
345
|
+
erro("o ffmpeg falhou ao gravar o vídeo final:\n" + r.stderr[-1500:])
|
|
346
|
+
tipo = "linear"
|
|
347
|
+
try:
|
|
348
|
+
tipo = json.loads(r.stderr[r.stderr.rindex("{"): r.stderr.rindex("}") + 1]).get("normalization_type",
|
|
349
|
+
"linear")
|
|
350
|
+
except ValueError:
|
|
351
|
+
pass
|
|
352
|
+
fim = loudness(saida)
|
|
353
|
+
if float(fim["input_tp"]) <= -0.5:
|
|
354
|
+
break
|
|
355
|
+
tp = round(tp - (float(fim["input_tp"]) + 0.8), 2)
|
|
356
|
+
return j, tipo, fim
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def audio_mono(p: str, sr: int = 16000) -> "np.ndarray":
|
|
361
|
+
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", p, "-vn", "-ac", "1", "-ar", str(sr), "-f", "f32le",
|
|
362
|
+
"-acodec", "pcm_f32le", "-"], capture_output=True).stdout
|
|
363
|
+
return np.frombuffer(raw, dtype=np.float32)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def desvio_sincronia(ref: str, out: str, janelas: int = 10, sr: int = 16000) -> float:
|
|
367
|
+
"""maior deslocamento (ms) do áudio de saída em relação ao de entrada, medido em janelas de 1 s com fala"""
|
|
368
|
+
a, b = audio_mono(ref, sr), audio_mono(out, sr)
|
|
369
|
+
n = min(len(a), len(b))
|
|
370
|
+
w, L = sr, int(0.05 * sr)
|
|
371
|
+
if n < 3 * w:
|
|
372
|
+
return 0.0
|
|
373
|
+
en = [float((a[i:i + w] ** 2).mean()) for i in range(L, n - w - L, w)]
|
|
374
|
+
idx = sorted(range(len(en)), key=lambda k: -en[k])[:janelas]
|
|
375
|
+
pior = 0.0
|
|
376
|
+
for k in idx:
|
|
377
|
+
i = L + k * w
|
|
378
|
+
x = a[i:i + w]
|
|
379
|
+
seg = b[i - L:i + w + L]
|
|
380
|
+
c = np.correlate(seg, x, mode="valid")
|
|
381
|
+
lag = (int(c.argmax()) - L) / sr * 1000
|
|
382
|
+
pior = max(pior, abs(lag))
|
|
383
|
+
return round(pior, 2)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ------------------------------------------------------------------ montar o plano
|
|
387
|
+
def nivel_do_evento(ev: dict, padrao: float) -> float:
|
|
388
|
+
"""o "nivel" do evento; sem ele, o "db" que a peça pede (sons_sugeridos.json do build); sem os dois, o do tipo"""
|
|
389
|
+
nv = ev.get("nivel")
|
|
390
|
+
if nv is None and ev.get("db") is not None:
|
|
391
|
+
nv = ev["db"]
|
|
392
|
+
if nv is None:
|
|
393
|
+
return float(padrao)
|
|
394
|
+
if isinstance(nv, (int, float)):
|
|
395
|
+
return float(nv)
|
|
396
|
+
k = norm(nv)
|
|
397
|
+
for nome, v in NIVEIS.items():
|
|
398
|
+
if norm(nome) == k:
|
|
399
|
+
return v
|
|
400
|
+
try:
|
|
401
|
+
return float(str(nv).replace(",", "."))
|
|
402
|
+
except ValueError:
|
|
403
|
+
erro(f"nível '{nv}' não existe. Use destaque, medio, discreto ou um número em dBFS (ex.: -15).")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def montar(eventos: list, dur_video: float, modo: str, max_sons: int | None, espaco: float, variar: bool,
|
|
407
|
+
ajuste_db: float = 0.0) -> dict:
|
|
408
|
+
giro: dict = {}
|
|
409
|
+
itens, descartados = [], []
|
|
410
|
+
for n, ev in enumerate(eventos, 1):
|
|
411
|
+
if "t" not in ev:
|
|
412
|
+
erro(f"o evento {n} não tem \"t\" (o segundo em que a animação bate): {ev}")
|
|
413
|
+
t = float(ev["t"])
|
|
414
|
+
tipo = APELIDO.get(norm(ev.get("tipo", ""))) if ev.get("tipo") else None
|
|
415
|
+
if ev.get("tipo") and not tipo and not ev.get("som"):
|
|
416
|
+
erro(f"tipo '{ev['tipo']}' (evento {n}) não existe. Tipos: {', '.join(TIPOS)}. "
|
|
417
|
+
"Rode com --tipos para ver quando usar cada um.")
|
|
418
|
+
if not tipo and not ev.get("som"):
|
|
419
|
+
erro(f"o evento {n} precisa de \"tipo\" ou \"som\": {ev}")
|
|
420
|
+
base = TIPOS.get(tipo) if tipo else ("som escolhido", [], -14, "pico", 0.8, [])
|
|
421
|
+
if t < 0 or t > dur_video:
|
|
422
|
+
descartados.append(dict(t_evento=t, tipo=tipo or "", motivo="fora do vídeo"))
|
|
423
|
+
continue
|
|
424
|
+
if ev.get("som"):
|
|
425
|
+
arq = resolver_som(ev["som"])
|
|
426
|
+
if not arq:
|
|
427
|
+
erro(f"não achei o som '{ev['som']}' (evento {n}). Use woven:ID, kit:NOME, bib:ID ou um caminho.")
|
|
428
|
+
else:
|
|
429
|
+
cands = [c for c in (resolver_som(s) for s in base[1]) if c]
|
|
430
|
+
if not cands:
|
|
431
|
+
erro(f"nenhum som do tipo '{tipo}' está instalado ({', '.join(base[1])}). "
|
|
432
|
+
"Confira as pastas assets/sfx/woven e assets/sfx (o kit) desta skill.")
|
|
433
|
+
k = giro.get(tipo, 0) if variar else 0
|
|
434
|
+
giro[tipo] = k + 1
|
|
435
|
+
arq = cands[k % len(cands)]
|
|
436
|
+
md = medir(arq)
|
|
437
|
+
alinhar = ev.get("alinhar", base[3])
|
|
438
|
+
ancora = md["pico_s"] if alinhar == "pico" else md["inicio_s"]
|
|
439
|
+
inicio = t - ancora
|
|
440
|
+
offset = 0.0
|
|
441
|
+
if inicio < 0: # evento muito no começo: corta a cabeça do som
|
|
442
|
+
offset, inicio = -inicio, 0.0
|
|
443
|
+
cauda = float(ev["dur"]) - (ancora - md["inicio_s"]) if ev.get("dur") else base[4]
|
|
444
|
+
fim_arq = min(md["dur"], ancora + max(0.1, cauda))
|
|
445
|
+
dur = fim_arq - offset
|
|
446
|
+
dur = min(dur, dur_video - inicio)
|
|
447
|
+
if dur <= 0.02:
|
|
448
|
+
descartados.append(dict(t_evento=t, tipo=tipo or "", motivo="sem espaço até o fim do vídeo"))
|
|
449
|
+
continue
|
|
450
|
+
cortado = offset + dur < md["dur"] - 0.01
|
|
451
|
+
fade = round(min(0.35, max(0.05, (offset + dur - ancora) * 0.5)), 3) if cortado else 0.0
|
|
452
|
+
nivel = nivel_do_evento(ev, base[2])
|
|
453
|
+
pico_toca = pico_no_trecho(md, offset, offset + dur) # o nível vale pro pico que realmente toca
|
|
454
|
+
ganho = max(-60.0, min(GANHO_MAX_DB, nivel - pico_toca + ajuste_db))
|
|
455
|
+
pico_tl = inicio + (md["pico_s"] - offset)
|
|
456
|
+
if pico_tl > inicio + dur: # alinhado no começo e o pico do arquivo ficou fora do trecho que toca
|
|
457
|
+
pico_tl = t
|
|
458
|
+
itens.append(dict(t_evento=round(t, 3), tipo=tipo or "livre", id=id_do_arquivo(arq), arquivo=str(arq),
|
|
459
|
+
inicio=round(inicio, 4), offset=round(offset, 4), dur=round(dur, 4),
|
|
460
|
+
pico=round(pico_tl, 4), pico_arquivo_s=md["pico_s"], pico_trecho_db=round(pico_toca, 2),
|
|
461
|
+
alinhar=alinhar, ponto=md["ponto"], nivel_db=round(nivel, 1), ganho_db=round(ganho, 2),
|
|
462
|
+
volume=round(10 ** (ganho / 20), 4), fade_out=fade, forcado=bool(ev.get("forcar")),
|
|
463
|
+
nota=str(ev.get("nota") or ev.get("peca") or "")))
|
|
464
|
+
|
|
465
|
+
# densidade: forçados primeiro, depois do mais forte pro mais fraco; perto demais de um que ficou -> sai
|
|
466
|
+
if max_sons is None:
|
|
467
|
+
# com animação, cada animação tem o seu som (o André aprovou 23 sons em 56 s): só o espaço mínimo decide
|
|
468
|
+
max_sons = max(5, round(dur_video / 15)) if modo == "fala" else max(5, len(itens))
|
|
469
|
+
ordem = sorted(itens, key=lambda x: (not x["forcado"], -x["nivel_db"], x["t_evento"]))
|
|
470
|
+
ficam = []
|
|
471
|
+
for it in ordem:
|
|
472
|
+
perto = [k for k in ficam if abs(k["t_evento"] - it["t_evento"]) < espaco]
|
|
473
|
+
if it["forcado"]:
|
|
474
|
+
if any(abs(k["t_evento"] - it["t_evento"]) < 0.05 for k in perto):
|
|
475
|
+
descartados.append(dict(t_evento=it["t_evento"], tipo=it["tipo"], motivo="repetido no mesmo quadro"))
|
|
476
|
+
continue
|
|
477
|
+
ficam.append(it)
|
|
478
|
+
continue
|
|
479
|
+
if perto:
|
|
480
|
+
descartados.append(dict(t_evento=it["t_evento"], tipo=it["tipo"],
|
|
481
|
+
motivo=f"perto demais do som em {perto[0]['t_evento']:.2f} s (fica o mais forte)"))
|
|
482
|
+
continue
|
|
483
|
+
ficam.append(it)
|
|
484
|
+
livres = [x for x in ficam if not x["forcado"]]
|
|
485
|
+
sobra = len(ficam) - max_sons
|
|
486
|
+
if sobra > 0: # passou do máximo: saem os mais fracos (os forçados ficam)
|
|
487
|
+
for it in sorted(livres, key=lambda x: (x["nivel_db"], -x["t_evento"]))[:sobra]:
|
|
488
|
+
ficam.remove(it)
|
|
489
|
+
descartados.append(dict(t_evento=it["t_evento"], tipo=it["tipo"],
|
|
490
|
+
motivo=f"passou do máximo de {max_sons} sons (saem os mais fracos)"))
|
|
491
|
+
ficam.sort(key=lambda x: x["t_evento"])
|
|
492
|
+
for i, it in enumerate(ficam, 1):
|
|
493
|
+
it["n"] = i
|
|
494
|
+
descartados.sort(key=lambda x: x["t_evento"])
|
|
495
|
+
return dict(versao=1, criado_por="helpers/plano_sfx.py (editor-video)", duracao_video=round(dur_video, 3),
|
|
496
|
+
modo=modo, espaco_min_s=espaco, max_sons=max_sons, ajuste_voz_db=round(ajuste_db, 2),
|
|
497
|
+
sons=[{k: it[k] for k in ["n"] + [c for c in it if c != "n"]} for it in ficam],
|
|
498
|
+
descartados=descartados)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# ------------------------------------------------------------------ ffmpeg
|
|
502
|
+
def filtro_sons(sons: list, primeiro_input: int, arquivos: list) -> tuple[list, list]:
|
|
503
|
+
"""devolve (partes do filtro, rótulos) de cada som já no lugar, 48 kHz estéreo"""
|
|
504
|
+
usos = {}
|
|
505
|
+
for s in sons:
|
|
506
|
+
usos[s["arquivo"]] = usos.get(s["arquivo"], 0) + 1
|
|
507
|
+
rot_arq, partes = {}, []
|
|
508
|
+
for a, qtd in usos.items():
|
|
509
|
+
idx = primeiro_input + arquivos.index(a)
|
|
510
|
+
if qtd == 1:
|
|
511
|
+
rot_arq[a] = [f"[{idx}:a]"]
|
|
512
|
+
else:
|
|
513
|
+
labs = [f"[f{idx}_{k}]" for k in range(qtd)]
|
|
514
|
+
partes.append(f"[{idx}:a]asplit={qtd}{''.join(labs)};")
|
|
515
|
+
rot_arq[a] = labs
|
|
516
|
+
labels = []
|
|
517
|
+
for i, s in enumerate(sons):
|
|
518
|
+
src = rot_arq[s["arquivo"]].pop(0)
|
|
519
|
+
ms = int(round(s["inicio"] * 1000))
|
|
520
|
+
fade = (f",afade=t=out:st={max(0.0, s['dur'] - s['fade_out']):.4f}:d={s['fade_out']:.4f}"
|
|
521
|
+
if s["fade_out"] > 0 else "")
|
|
522
|
+
partes.append(f"{src}atrim=start={s['offset']:.4f}:end={s['offset'] + s['dur']:.4f},asetpts=PTS-STARTPTS,"
|
|
523
|
+
f"aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,"
|
|
524
|
+
f"volume={s['ganho_db']:.2f}dB{fade},adelay={ms}|{ms}[s{i}];")
|
|
525
|
+
labels.append(f"[s{i}]")
|
|
526
|
+
return partes, labels
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def render_trilha_sfx(plano: dict, dur: float, saida_wav: str):
|
|
530
|
+
"""só os sons, sem voz (pra conferir onde caíram os picos)"""
|
|
531
|
+
sons = plano["sons"]
|
|
532
|
+
arquivos = sorted({s["arquivo"] for s in sons})
|
|
533
|
+
partes, labels = filtro_sons(sons, 1, arquivos)
|
|
534
|
+
fc = "".join(partes) + f"[0:a]{''.join(labels)}amix=inputs={len(labels) + 1}:normalize=0:duration=first[out]"
|
|
535
|
+
cmd = ["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-t", f"{dur:.4f}", "-i",
|
|
536
|
+
"anullsrc=r=48000:cl=stereo"]
|
|
537
|
+
for a in arquivos:
|
|
538
|
+
cmd += ["-i", a]
|
|
539
|
+
cmd += ["-filter_complex", fc, "-map", "[out]", "-c:a", "pcm_f32le", saida_wav]
|
|
540
|
+
subprocess.run(cmd, check=True)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def conferir_picos(plano: dict, dur: float) -> dict:
|
|
544
|
+
if not plano["sons"]:
|
|
545
|
+
return dict(conferidos=0)
|
|
546
|
+
with tempfile.TemporaryDirectory() as td:
|
|
547
|
+
w = os.path.join(td, "sfx.wav")
|
|
548
|
+
render_trilha_sfx(plano, dur, w)
|
|
549
|
+
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", w, "-f", "f32le", "-acodec", "pcm_f32le", "-"],
|
|
550
|
+
capture_output=True).stdout
|
|
551
|
+
a = np.abs(np.frombuffer(raw, dtype=np.float32).reshape(-1, 2)).max(axis=1)
|
|
552
|
+
sr = 48000
|
|
553
|
+
desvios = []
|
|
554
|
+
for s in plano["sons"]:
|
|
555
|
+
if s["alinhar"] != "pico" or s.get("ponto") == "curado":
|
|
556
|
+
continue
|
|
557
|
+
lo, hi = int(max(0, (s["pico"] - 0.08)) * sr), int(min(len(a), (s["pico"] + 0.08) * sr))
|
|
558
|
+
if hi <= lo:
|
|
559
|
+
continue
|
|
560
|
+
j = lo + ponto_forte(a[lo:hi], sr)
|
|
561
|
+
desvios.append((abs(j / sr - s["pico"]) * 1000, s["t_evento"]))
|
|
562
|
+
pior = max(desvios) if desvios else (0.0, 0.0)
|
|
563
|
+
return dict(conferidos=len(desvios), desvio_max_ms=round(pior[0], 1), no_evento=pior[1])
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def duracao_video(p: str) -> float:
|
|
567
|
+
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=duration",
|
|
568
|
+
"-of", "csv=p=0", p], capture_output=True, text=True).stdout.strip()
|
|
569
|
+
try:
|
|
570
|
+
return float(out)
|
|
571
|
+
except ValueError:
|
|
572
|
+
return duracao_midia(p)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def aplicar(plano: dict, video: str, saida: str):
|
|
576
|
+
sons = plano["sons"]
|
|
577
|
+
arquivos = sorted({s["arquivo"] for s in sons})
|
|
578
|
+
partes, labels = filtro_sons(sons, 1, arquivos)
|
|
579
|
+
dv = duracao_video(video) # o áudio sai com a duração exata da imagem (nem 1 amostra a mais)
|
|
580
|
+
fc = ("".join(partes) + "[0:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,"
|
|
581
|
+
f"apad,atrim=end={dv:.6f}[voz];"
|
|
582
|
+
f"[voz]{''.join(labels)}amix=inputs={len(labels) + 1}:normalize=0:duration=first[out]")
|
|
583
|
+
with tempfile.TemporaryDirectory() as td:
|
|
584
|
+
tmp = os.path.join(td, "mix.wav")
|
|
585
|
+
cmd = ["ffmpeg", "-v", "error", "-y", "-i", video]
|
|
586
|
+
for a in arquivos:
|
|
587
|
+
cmd += ["-i", a]
|
|
588
|
+
cmd += ["-filter_complex", fc, "-map", "[out]", "-c:a", "pcm_f32le", tmp]
|
|
589
|
+
subprocess.run(cmd, check=True)
|
|
590
|
+
j, tipo, fim = finalizar(video, tmp, saida)
|
|
591
|
+
return j, tipo, fim
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def ler_projeto(pasta: Path) -> dict:
|
|
595
|
+
"""o projeto-modelo: projeto.json, a pasta edit do corte, a duração do corte e a cauda (fim parado e cartão)"""
|
|
596
|
+
pj = pasta / "projeto.json"
|
|
597
|
+
if not pj.exists():
|
|
598
|
+
erro(f"não achei {pj}. O --projeto é a pasta do projeto (a cópia do modelo-projeto, com o projeto.json).")
|
|
599
|
+
try:
|
|
600
|
+
cfg = json.loads(pj.read_text(encoding="utf-8"))
|
|
601
|
+
except json.JSONDecodeError as e:
|
|
602
|
+
erro(f"{pj} não é um JSON válido: {e}")
|
|
603
|
+
edit = (pasta / cfg.get("edit", "../edit")).resolve()
|
|
604
|
+
vdur = None
|
|
605
|
+
tl = edit / "cut_timeline.json"
|
|
606
|
+
if tl.exists():
|
|
607
|
+
try:
|
|
608
|
+
vdur = float(json.loads(tl.read_text(encoding="utf-8"))["duration"])
|
|
609
|
+
except (ValueError, KeyError):
|
|
610
|
+
vdur = None
|
|
611
|
+
cf = cfg.get("cartao_final") or {}
|
|
612
|
+
cauda = float(cfg.get("segura_fim") or 0) + (float(cf.get("segundos", 1.8)) if cf.get("ligado") else 0.0)
|
|
613
|
+
return dict(cfg=cfg, arquivo=pj, edit=edit, vdur=vdur, cauda=cauda, video=edit / cfg.get("video", "cut.mp4"))
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def licenca_do_som(src: Path) -> tuple[str, Path | None]:
|
|
617
|
+
"""(linha de licença, arquivo de licença para ir junto) do som copiado para o projeto"""
|
|
618
|
+
try:
|
|
619
|
+
rp = src.resolve()
|
|
620
|
+
if rp.parent == WOVEN.resolve():
|
|
621
|
+
return "Woven SFX, CC0 1.0 (domínio público); LICENCA-woven.md", WOVEN / "LICENCA.md"
|
|
622
|
+
if KIT.resolve() in rp.parents:
|
|
623
|
+
for v in kit_indice().values():
|
|
624
|
+
if (KIT / v["file"]).resolve() == rp:
|
|
625
|
+
arq = KIT / v["license_file"] if v.get("license_file") else None
|
|
626
|
+
return (f"{v.get('license', '?')} ({v.get('source', 'kit do editor-video')})",
|
|
627
|
+
arq if arq and arq.exists() else None)
|
|
628
|
+
if BIB.resolve() in rp.parents:
|
|
629
|
+
for v in bib_indice().values():
|
|
630
|
+
if (BIB / v["arquivo"]).resolve() == rp:
|
|
631
|
+
return f"{v.get('licenca', '?')} ({v.get('fonte', 'biblioteca de efeitos')})", None
|
|
632
|
+
except (OSError, KeyError):
|
|
633
|
+
pass
|
|
634
|
+
return "arquivo trazido para o projeto: confira se pode usar em anúncio", None
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def aplicar_no_projeto(plano: dict, pasta: Path, proj: dict, janela: list | None) -> list:
|
|
638
|
+
"""copia os sons para PASTA/assets/sfx e escreve o campo "sons" do PASTA/projeto.json: o build.py põe cada
|
|
639
|
+
um no index.html (o estúdio toca e o render sai com eles). Troca só os sons que vieram do plano ("plano": true)
|
|
640
|
+
dentro da janela do sons_sugeridos.json; os escritos à mão ficam."""
|
|
641
|
+
dest = pasta / "assets" / "sfx"
|
|
642
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
643
|
+
usados: dict = {}
|
|
644
|
+
licencas: dict = {}
|
|
645
|
+
novos = []
|
|
646
|
+
for s in plano["sons"]:
|
|
647
|
+
src = Path(s["arquivo"])
|
|
648
|
+
nome = src.name
|
|
649
|
+
if nome in usados and usados[nome] != src.resolve(): # dois sons diferentes com o mesmo nome
|
|
650
|
+
nome = f"{id_do_arquivo(src).split(':')[0]}_{src.name}"
|
|
651
|
+
usados[nome] = src.resolve()
|
|
652
|
+
alvo = dest / nome
|
|
653
|
+
if not alvo.exists() or alvo.stat().st_size != src.stat().st_size:
|
|
654
|
+
shutil.copy2(src, alvo)
|
|
655
|
+
lic, arq_lic = licenca_do_som(src)
|
|
656
|
+
licencas[nome] = f"{nome}: {id_do_arquivo(src)} · {lic}"
|
|
657
|
+
if arq_lic is not None:
|
|
658
|
+
copia = dest / ("LICENCA-woven.md" if arq_lic.parent == WOVEN else f"licencas/{arq_lic.name}")
|
|
659
|
+
copia.parent.mkdir(parents=True, exist_ok=True)
|
|
660
|
+
if not copia.exists():
|
|
661
|
+
shutil.copy2(arq_lic, copia)
|
|
662
|
+
em = round(s["pico"], 3)
|
|
663
|
+
novos.append({"arquivo": f"assets/sfx/{nome}", "em": em,
|
|
664
|
+
"pico_no_arquivo": round(em - s["inicio"] + s["offset"], 4),
|
|
665
|
+
"inicio_no_arquivo": s["offset"], "dur": s["dur"], "volume": s["volume"],
|
|
666
|
+
"db": round(s["nivel_db"] + plano.get("ajuste_voz_db", 0.0), 1), "fade": s["fade_out"],
|
|
667
|
+
"tipo": s["tipo"], "nota": s["nota"], "plano": True})
|
|
668
|
+
(dest / "LICENCAS.txt").write_text(
|
|
669
|
+
"Sons deste projeto (escrito pelo helpers/plano_sfx.py do editor-video)\n\n"
|
|
670
|
+
+ "\n".join(licencas[k] for k in sorted(licencas)) + "\n", encoding="utf-8")
|
|
671
|
+
|
|
672
|
+
def tempo(x):
|
|
673
|
+
try:
|
|
674
|
+
return float(x.get("em"))
|
|
675
|
+
except (TypeError, ValueError):
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
def fica(x):
|
|
679
|
+
if not x.get("plano"):
|
|
680
|
+
return True # som escrito à mão: fica
|
|
681
|
+
t = tempo(x)
|
|
682
|
+
return janela is not None and t is not None and not (janela[0] - 0.01 <= t <= janela[1] + 0.01)
|
|
683
|
+
antigos = proj["cfg"].get("sons") or []
|
|
684
|
+
mantidos = [x for x in antigos if fica(x)]
|
|
685
|
+
cfg = dict(proj["cfg"])
|
|
686
|
+
cfg["sons"] = sorted(mantidos + novos, key=lambda x: (tempo(x) is None, tempo(x) or 0.0))
|
|
687
|
+
proj["arquivo"].write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
688
|
+
return mantidos
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
# ------------------------------------------------------------------ comandos
|
|
692
|
+
def cmd_tipos():
|
|
693
|
+
print("Tipos de evento (tipo: quando usar | sons | nível do pico)\n")
|
|
694
|
+
for tipo, d in TIPOS.items():
|
|
695
|
+
ok = [s for s in d[1] if resolver_som(s)]
|
|
696
|
+
falta = [s for s in d[1] if s not in ok]
|
|
697
|
+
print(f"- {tipo}: {d[0]}")
|
|
698
|
+
print(f" sons: {', '.join(ok) or 'nenhum instalado'}" + (f" (faltando: {', '.join(falta)})" if falta else ""))
|
|
699
|
+
print(f" nível: {d[2]} dBFS · alinha no {'pico' if d[3] == 'pico' else 'começo'} · apelidos: "
|
|
700
|
+
f"{', '.join(d[5])}")
|
|
701
|
+
print("\nNíveis prontos: destaque -12, medio -15, discreto -18 (dBFS de pico, com a voz perto de -14 LUFS).")
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def cmd_medir(arq: str):
|
|
705
|
+
p = resolver_som(arq)
|
|
706
|
+
if not p:
|
|
707
|
+
erro(f"não achei o som {arq}")
|
|
708
|
+
md = {k: v for k, v in medir(p).items() if not k.startswith("_")}
|
|
709
|
+
print(json.dumps(dict(arquivo=str(p), id=id_do_arquivo(p), **md), ensure_ascii=False, indent=1))
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def main(argv=None):
|
|
713
|
+
try:
|
|
714
|
+
sys.stdout.reconfigure(line_buffering=True) # mensagens aparecem na hora, mesmo em log
|
|
715
|
+
except AttributeError:
|
|
716
|
+
pass
|
|
717
|
+
ap = argparse.ArgumentParser(description="Plano de efeitos sonoros com o pico de cada som no quadro da animação.")
|
|
718
|
+
ap.add_argument("eventos", nargs="*", help="JSON com os eventos visuais [{\"t\":1.23,\"tipo\":\"pop\"}, ...] "
|
|
719
|
+
"(pode passar mais de um; com --projeto, o padrão é o sons_sugeridos.json e o eventos.json do projeto)")
|
|
720
|
+
ap.add_argument("--saida", help="JSON de mixagem (padrão: plano_sfx.json ao lado dos eventos, ou na pasta do projeto)")
|
|
721
|
+
ap.add_argument("--video", help="vídeo final (pega a duração e acerta o nível pela voz)")
|
|
722
|
+
ap.add_argument("--duracao", type=float, help="duração do vídeo em segundos (se não passar --video)")
|
|
723
|
+
ap.add_argument("--modo", choices=["motion", "fala"], default="motion",
|
|
724
|
+
help="motion: um som por animação (padrão). fala: vídeo sem motion, uns 5 ou 6 sons no total")
|
|
725
|
+
ap.add_argument("--max", type=int, help="máximo de sons no vídeo (padrão: motion um por animação, só o espaço "
|
|
726
|
+
"mínimo tira; fala 1 a cada 15 s)")
|
|
727
|
+
ap.add_argument("--espaco", type=float, default=0.65, help="espaço mínimo entre picos em segundos (padrão 0.65)")
|
|
728
|
+
ap.add_argument("--variar", action="store_true", help="alterna entre os sons do mesmo tipo")
|
|
729
|
+
ap.add_argument("--aplicar", metavar="VIDEO", help="junta os sons neste vídeo (voz intacta, -14 LUFS no fim)")
|
|
730
|
+
ap.add_argument("--video-saida", help="arquivo de saída do --aplicar")
|
|
731
|
+
ap.add_argument("--projeto", metavar="PASTA", help="pasta do projeto (modelo-projeto): copia os sons pra "
|
|
732
|
+
"PASTA/assets/sfx e escreve o campo \"sons\" do PASTA/projeto.json")
|
|
733
|
+
ap.add_argument("--html", metavar="PASTA", help=argparse.SUPPRESS)
|
|
734
|
+
ap.add_argument("--tipos", action="store_true", help="lista os tipos e os sons")
|
|
735
|
+
ap.add_argument("--medir", metavar="SOM", help="mede um som (duração, pico, começo)")
|
|
736
|
+
a = ap.parse_args(argv)
|
|
737
|
+
precisa_ffmpeg()
|
|
738
|
+
if a.tipos:
|
|
739
|
+
return cmd_tipos()
|
|
740
|
+
if a.medir:
|
|
741
|
+
return cmd_medir(a.medir)
|
|
742
|
+
if a.html:
|
|
743
|
+
erro("o --html saiu: use --projeto PASTA_DO_PROJETO. Ele escreve os sons no projeto.json, o build.py põe no "
|
|
744
|
+
"index.html (o estúdio toca e o render sai com eles). Nada de colar tag na composição.")
|
|
745
|
+
proj = ler_projeto(Path(a.projeto).expanduser().resolve()) if a.projeto else None
|
|
746
|
+
arquivos = [Path(x) for x in a.eventos]
|
|
747
|
+
if proj and not arquivos:
|
|
748
|
+
pasta = proj["arquivo"].parent
|
|
749
|
+
arquivos = [x for x in (pasta / "sons_sugeridos.json", pasta / "eventos.json") if x.exists()]
|
|
750
|
+
if not arquivos:
|
|
751
|
+
erro(f"não achei {pasta / 'sons_sugeridos.json'}. Rode antes no projeto: python3 build.py completo")
|
|
752
|
+
if not arquivos:
|
|
753
|
+
ap.error("passe o arquivo de eventos, ou --projeto PASTA (ou --tipos / --medir)")
|
|
754
|
+
eventos, janela = [], None
|
|
755
|
+
for arq in arquivos:
|
|
756
|
+
try:
|
|
757
|
+
dados = json.loads(arq.read_text(encoding="utf-8"))
|
|
758
|
+
except FileNotFoundError:
|
|
759
|
+
erro(f"não achei {arq}")
|
|
760
|
+
except json.JSONDecodeError as e:
|
|
761
|
+
erro(f"{arq} não é um JSON válido: {e}")
|
|
762
|
+
# Aceita também o sons_sugeridos.json do modelo-projeto: {"janela": [...], "sons": [{"tipo", "em", ...}]}
|
|
763
|
+
lista = (dados.get("eventos") or dados.get("sons") or []) if isinstance(dados, dict) else dados
|
|
764
|
+
if isinstance(dados, dict) and dados.get("janela"):
|
|
765
|
+
janela = [float(x) for x in dados["janela"]]
|
|
766
|
+
if not isinstance(lista, list):
|
|
767
|
+
erro(f"{arq}: formato não reconhecido. Use [{{\"t\": 1.23, \"tipo\": \"pop\"}}, ...]")
|
|
768
|
+
for e in lista:
|
|
769
|
+
if isinstance(e, dict) and "t" not in e and "em" in e:
|
|
770
|
+
e["t"] = e["em"]
|
|
771
|
+
eventos += lista
|
|
772
|
+
if proj:
|
|
773
|
+
print(f"eventos de {arq.name}: {len(lista)}")
|
|
774
|
+
if not eventos:
|
|
775
|
+
erro("o arquivo de eventos está vazio. Formato: [{\"t\": 1.23, \"tipo\": \"pop\"}, ...]")
|
|
776
|
+
video_ref = a.video or a.aplicar or (str(proj["video"]) if proj and proj["video"].exists() else None)
|
|
777
|
+
ajuste = 0.0
|
|
778
|
+
if video_ref:
|
|
779
|
+
dur = duracao_midia(video_ref)
|
|
780
|
+
lv = float(loudness(video_ref)["input_i"])
|
|
781
|
+
if lv > -70:
|
|
782
|
+
ajuste = lv - VOZ_REF_LUFS
|
|
783
|
+
elif a.duracao:
|
|
784
|
+
dur = a.duracao
|
|
785
|
+
else:
|
|
786
|
+
dur = max(float(e.get("t", 0)) for e in eventos) + 2.0
|
|
787
|
+
print(f"aviso: sem --video nem --duracao, considerei o vídeo com {dur:.1f} s")
|
|
788
|
+
if proj and not a.duracao:
|
|
789
|
+
# o vídeo do projeto é o corte mais o fim parado (segura_fim) e o cartão final
|
|
790
|
+
dur = (proj["vdur"] or dur) + proj["cauda"]
|
|
791
|
+
if proj and janela and proj["vdur"] and (janela[0] > 0.05 or janela[1] < proj["vdur"] - 0.05):
|
|
792
|
+
print(f"aviso: o sons_sugeridos.json é de uma amostra ({janela[0]:.2f} a {janela[1]:.2f} s). Os sons do plano "
|
|
793
|
+
f"fora desse trecho ficam como estavam. Para o vídeo todo: python3 build.py completo e rode de novo.")
|
|
794
|
+
plano = montar(eventos, dur, a.modo, a.max, a.espaco, a.variar, ajuste)
|
|
795
|
+
chk = conferir_picos(plano, dur)
|
|
796
|
+
plano["conferencia"] = chk
|
|
797
|
+
if a.saida:
|
|
798
|
+
saida = Path(a.saida)
|
|
799
|
+
elif proj:
|
|
800
|
+
saida = proj["arquivo"].parent / "plano_sfx.json"
|
|
801
|
+
else:
|
|
802
|
+
saida = arquivos[0].resolve().parent / "plano_sfx.json"
|
|
803
|
+
saida.write_text(json.dumps(plano, ensure_ascii=False, indent=1))
|
|
804
|
+
maximo = "um por animação" if a.modo == "motion" and a.max is None else str(plano["max_sons"])
|
|
805
|
+
print(f"{len(plano['sons'])} sons no plano, {len(plano['descartados'])} descartados "
|
|
806
|
+
f"(modo {plano['modo']}, máximo {maximo}, espaço {plano['espaco_min_s']} s)")
|
|
807
|
+
if ajuste:
|
|
808
|
+
print(f"voz do vídeo em {VOZ_REF_LUFS + ajuste:.1f} LUFS: ganhos ajustados em {ajuste:+.1f} dB")
|
|
809
|
+
for s in plano["sons"]:
|
|
810
|
+
print(f" {s['n']:>2}. {s['t_evento']:7.2f} s {s['tipo']:<13} {s['id']:<32} pico {s['nivel_db']:>5.1f} dBFS"
|
|
811
|
+
f" começa {s['inicio']:.3f}" + (" (forçado)" if s["forcado"] else ""))
|
|
812
|
+
for d in plano["descartados"]:
|
|
813
|
+
print(f" fora: {d['t_evento']:7.2f} s {d['tipo']:<13} {d['motivo']}")
|
|
814
|
+
if chk.get("conferidos"):
|
|
815
|
+
print(f"conferência: {chk['conferidos']} picos medidos, maior desvio {chk['desvio_max_ms']} ms "
|
|
816
|
+
f"(evento {chk['no_evento']} s)")
|
|
817
|
+
print(f"plano salvo em {saida}")
|
|
818
|
+
if proj:
|
|
819
|
+
mantidos = aplicar_no_projeto(plano, proj["arquivo"].parent, proj, janela)
|
|
820
|
+
print(f"projeto.json: {len(plano['sons'])} sons do plano no campo \"sons\" (arquivos em assets/sfx, licenças em "
|
|
821
|
+
f"assets/sfx/LICENCAS.txt)" + (f"; {len(mantidos)} som(ns) que já estavam ficaram" if mantidos else ""))
|
|
822
|
+
print("próximo: python3 build.py completo (o estúdio toca os sons), render e, por último, a trilha "
|
|
823
|
+
"(helpers/mixar_trilha.py)")
|
|
824
|
+
if a.aplicar:
|
|
825
|
+
vs = a.video_saida or str(Path(a.aplicar).with_name(Path(a.aplicar).stem + "_sfx.mp4"))
|
|
826
|
+
if Path(vs).resolve() == Path(a.aplicar).resolve():
|
|
827
|
+
erro("a saída não pode ser o mesmo arquivo do vídeo de entrada")
|
|
828
|
+
j, tipo, fim = aplicar(plano, a.aplicar, vs)
|
|
829
|
+
d_in, d_out = duracao_video(a.aplicar), duracao_midia(vs)
|
|
830
|
+
sinc = desvio_sincronia(a.aplicar, vs)
|
|
831
|
+
print(f"vídeo com sons: {vs}")
|
|
832
|
+
print(f" duração {d_out:.3f} s (entrada {d_in:.3f} s) · volume final {fim['input_i']} LUFS, pico "
|
|
833
|
+
f"{fim['input_tp']} dBTP (ajuste {tipo}) · voz no lugar: desvio {sinc} ms")
|
|
834
|
+
if tipo != "linear":
|
|
835
|
+
print(" aviso: o ajuste de volume saiu dinâmico; confira se a voz ficou natural")
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
if __name__ == "__main__":
|
|
839
|
+
main()
|