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