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,423 @@
|
|
|
1
|
+
"""Search and download free images for inserts (Contract 8).
|
|
2
|
+
|
|
3
|
+
Sources:
|
|
4
|
+
- wikimedia (default): Wikimedia Commons API, no key. By default only images with no strings
|
|
5
|
+
attached for a paid ad: CC0 and public domain. `--allow-attribution` also accepts CC BY (the
|
|
6
|
+
post must credit the author); `--allow-sharealike` also accepts CC BY-SA (credit, and the
|
|
7
|
+
adapted work falls under the same license). "No restrictions" (Flickr Commons) is not a
|
|
8
|
+
license and is refused, and so is any file with Restrictions metadata (trademark,
|
|
9
|
+
personality, insignia). Results are ranked CC0/PD first, then CC BY, then CC BY-SA, and an
|
|
10
|
+
image that needs credit but has no known author is skipped.
|
|
11
|
+
- pexels: only when PEXELS_API_KEY is set (environment or .env in the skill root). Without
|
|
12
|
+
the key it says so and uses Wikimedia instead.
|
|
13
|
+
|
|
14
|
+
Downloads go to <edit>/assets/ and every image is appended to <edit>/assets/credits.json
|
|
15
|
+
(a JSON list with url, author, license, attribution_required, share_alike and credit_line, the
|
|
16
|
+
ready "Foto: autor, licença, link" text when credit is required). Re-running the same search does
|
|
17
|
+
not download the same image twice.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
uv run python helpers/fetch_images.py "<busca>" --edit-dir DIR [--source wikimedia|pexels]
|
|
21
|
+
[--count 3] [--orientation portrait|landscape] [--min-width 800] [--allow-attribution]
|
|
22
|
+
[--allow-sharealike] [--json]
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import html
|
|
29
|
+
import io
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
import unicodedata
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import requests
|
|
38
|
+
from PIL import Image
|
|
39
|
+
|
|
40
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
41
|
+
from common import SKILL_ROOT, load_json, now_iso, save_json # noqa: E402
|
|
42
|
+
|
|
43
|
+
USER_AGENT = "editor-video/0.1 (suporte@sistemajarvis.com)"
|
|
44
|
+
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
|
|
45
|
+
PEXELS_API = "https://api.pexels.com/v1/search"
|
|
46
|
+
TIMEOUT = 25
|
|
47
|
+
ALLOWED_MIME = {"image/jpeg", "image/png", "image/webp"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FetchError(Exception):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EnvFileError(Exception):
|
|
55
|
+
"""The skill's .env exists but cannot be read (message in PT-BR)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------- small helpers
|
|
59
|
+
|
|
60
|
+
_ENV_TIP_SHOWN = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _decode_env(raw: bytes) -> str:
|
|
64
|
+
if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
65
|
+
return raw.decode("utf-16") # PowerShell 5.1 `echo KEY=... > .env`
|
|
66
|
+
try:
|
|
67
|
+
return raw.decode("utf-8-sig") # Notepad's "UTF-8 with BOM" too
|
|
68
|
+
except UnicodeDecodeError:
|
|
69
|
+
if b"\x00" in raw[:64]:
|
|
70
|
+
try:
|
|
71
|
+
return raw.decode("utf-16-le")
|
|
72
|
+
except UnicodeDecodeError:
|
|
73
|
+
pass
|
|
74
|
+
try:
|
|
75
|
+
return raw.decode("cp1252")
|
|
76
|
+
except UnicodeDecodeError:
|
|
77
|
+
raise EnvFileError("não consegui ler o arquivo .env da skill. Salve o .env como UTF-8 "
|
|
78
|
+
"(no Bloco de Notas: Salvar como > Codificação: UTF-8) e tente de novo.")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_env_key(name: str) -> str | None:
|
|
82
|
+
"""Key from the environment, else from .env in the skill root (never other folders).
|
|
83
|
+
|
|
84
|
+
Accepts UTF-8 (with or without BOM) and UTF-16 files, `export KEY=...`, quotes and an inline
|
|
85
|
+
` # comment` after an unquoted value. Raises EnvFileError when the file cannot be decoded."""
|
|
86
|
+
global _ENV_TIP_SHOWN
|
|
87
|
+
val = os.environ.get(name)
|
|
88
|
+
if val and val.strip():
|
|
89
|
+
return val.strip()
|
|
90
|
+
env_file = SKILL_ROOT / ".env"
|
|
91
|
+
if not env_file.is_file():
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
raw = env_file.read_bytes()
|
|
95
|
+
except OSError:
|
|
96
|
+
return None
|
|
97
|
+
for line in _decode_env(raw).splitlines():
|
|
98
|
+
line = line.strip().lstrip("\ufeff")
|
|
99
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
100
|
+
continue
|
|
101
|
+
k, v = line.split("=", 1)
|
|
102
|
+
if k.strip().removeprefix("export ").strip() != name:
|
|
103
|
+
continue
|
|
104
|
+
v = v.strip()
|
|
105
|
+
if v[:1] in ("'", '"'):
|
|
106
|
+
end = v.find(v[0], 1)
|
|
107
|
+
v = v[1:end] if end > 0 else v[1:]
|
|
108
|
+
else:
|
|
109
|
+
v = re.split(r"\s+#", v, maxsplit=1)[0].strip()
|
|
110
|
+
if os.name == "posix" and not _ENV_TIP_SHOWN:
|
|
111
|
+
try:
|
|
112
|
+
if env_file.stat().st_mode & 0o077:
|
|
113
|
+
print(f"dica: outros usuários deste computador conseguem ler o .env com a sua chave. "
|
|
114
|
+
f"Para proteger: chmod 600 \"{env_file}\"", file=sys.stderr)
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
_ENV_TIP_SHOWN = True
|
|
118
|
+
return v or None
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def slugify(text: str, limit: int = 40) -> str:
|
|
123
|
+
t = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii").lower()
|
|
124
|
+
t = re.sub(r"[^a-z0-9]+", "-", t).strip("-")
|
|
125
|
+
return (t[:limit].rstrip("-")) or "imagem"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def strip_html(value: str | None) -> str:
|
|
129
|
+
if not value:
|
|
130
|
+
return ""
|
|
131
|
+
text = re.sub(r"<[^>]+>", " ", str(value))
|
|
132
|
+
return " ".join(html.unescape(text).split())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def orientation_ok(w: int, h: int, orientation: str | None) -> bool:
|
|
136
|
+
if not orientation or not w or not h:
|
|
137
|
+
return True
|
|
138
|
+
if orientation == "portrait":
|
|
139
|
+
return h > w * 1.05
|
|
140
|
+
if orientation == "landscape":
|
|
141
|
+
return w > h * 1.05
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
LICENSE_RANK = {"free": 0, "attribution": 1, "sharealike": 2}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def license_class(short: str) -> str | None:
|
|
149
|
+
""""free" (CC0, public domain), "attribution" (CC BY), "sharealike" (CC BY-SA) or None (no
|
|
150
|
+
commercial use, no derivatives, GFDL, fair use, "No restrictions", unknown)."""
|
|
151
|
+
s = short.lower().strip()
|
|
152
|
+
if not s:
|
|
153
|
+
return None
|
|
154
|
+
parts = set(re.split(r"[\s\-/_.,]+", s))
|
|
155
|
+
if parts & {"nc", "nd"} or "fair use" in s or "commercial" in s or "gfdl" in s:
|
|
156
|
+
return None
|
|
157
|
+
if s.startswith("cc0") or "public domain" in s or s.startswith("pd") or s.startswith("cc-pd"):
|
|
158
|
+
return "free"
|
|
159
|
+
if s.startswith(("cc by", "cc-by")) or "attribution" in s:
|
|
160
|
+
return "sharealike" if ("sa" in parts or "sharealike" in s or "share alike" in s) else "attribution"
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def license_ok(short: str, allow_attribution: bool = False, allow_sharealike: bool = False) -> bool:
|
|
165
|
+
"""Whether a license may go into a paid ad: CC0/PD always; CC BY and CC BY-SA only when allowed."""
|
|
166
|
+
cls = license_class(short)
|
|
167
|
+
return cls == "free" or (cls == "attribution" and (allow_attribution or allow_sharealike)) \
|
|
168
|
+
or (cls == "sharealike" and allow_sharealike)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def credit_line(item: dict) -> str:
|
|
172
|
+
return f"Foto: {item['author']}, {item['license']}, {item['url']}"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load_credits(path: Path) -> list:
|
|
176
|
+
data = load_json(path, default=[])
|
|
177
|
+
if isinstance(data, dict):
|
|
178
|
+
data = data.get("items", [])
|
|
179
|
+
return data if isinstance(data, list) else []
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------- sources
|
|
183
|
+
|
|
184
|
+
def search_wikimedia(query: str, want: int, orientation: str | None, min_width: int,
|
|
185
|
+
allow_attribution: bool = False, allow_sharealike: bool = False) -> list[dict]:
|
|
186
|
+
params = {
|
|
187
|
+
"action": "query", "format": "json", "generator": "search",
|
|
188
|
+
"gsrsearch": f"{query} filetype:bitmap", "gsrnamespace": 6, "gsrlimit": 50,
|
|
189
|
+
"prop": "imageinfo", "iiprop": "url|size|mime|extmetadata", "iiurlwidth": 1920,
|
|
190
|
+
"iiextmetadatafilter": "LicenseShortName|Artist|LicenseUrl|UsageTerms|AttributionRequired|Restrictions",
|
|
191
|
+
}
|
|
192
|
+
try:
|
|
193
|
+
r = requests.get(COMMONS_API, params=params, headers={"User-Agent": USER_AGENT}, timeout=TIMEOUT)
|
|
194
|
+
r.raise_for_status()
|
|
195
|
+
data = r.json()
|
|
196
|
+
except (requests.RequestException, ValueError) as e:
|
|
197
|
+
raise FetchError(f"não consegui falar com o Wikimedia Commons ({e.__class__.__name__}). Confira a internet.")
|
|
198
|
+
pages = sorted((data.get("query") or {}).get("pages", {}).values(), key=lambda p: p.get("index", 0))
|
|
199
|
+
out = []
|
|
200
|
+
for p in pages:
|
|
201
|
+
info = (p.get("imageinfo") or [{}])[0]
|
|
202
|
+
meta = {k: (v or {}).get("value", "") for k, v in (info.get("extmetadata") or {}).items()}
|
|
203
|
+
w, h = int(info.get("width") or 0), int(info.get("height") or 0)
|
|
204
|
+
lic = strip_html(meta.get("LicenseShortName"))
|
|
205
|
+
if info.get("mime") not in ALLOWED_MIME or w < min_width:
|
|
206
|
+
continue
|
|
207
|
+
if not orientation_ok(w, h, orientation) or not license_ok(lic, allow_attribution, allow_sharealike):
|
|
208
|
+
continue
|
|
209
|
+
if strip_html(meta.get("Restrictions")): # trademarked, personality, insignia...
|
|
210
|
+
continue
|
|
211
|
+
url = info.get("thumburl") or info.get("url")
|
|
212
|
+
if not url:
|
|
213
|
+
continue
|
|
214
|
+
cls = license_class(lic)
|
|
215
|
+
author = strip_html(meta.get("Artist"))
|
|
216
|
+
if cls != "free" and not author:
|
|
217
|
+
continue # credit required but nobody to credit
|
|
218
|
+
lic_url = strip_html(meta.get("LicenseUrl"))
|
|
219
|
+
if lic_url.startswith("//"):
|
|
220
|
+
lic_url = "https:" + lic_url
|
|
221
|
+
out.append({
|
|
222
|
+
"source": "wikimedia",
|
|
223
|
+
"id": f"commons-{p.get('pageid')}",
|
|
224
|
+
"title": str(p.get("title", "")).removeprefix("File:"),
|
|
225
|
+
"download_url": url,
|
|
226
|
+
"url": info.get("descriptionurl") or url,
|
|
227
|
+
"author": author or "desconhecido",
|
|
228
|
+
"license": lic,
|
|
229
|
+
"license_url": lic_url,
|
|
230
|
+
"license_class": cls,
|
|
231
|
+
"attribution_required": cls != "free" or str(meta.get("AttributionRequired", "")).lower() == "true",
|
|
232
|
+
"share_alike": cls == "sharealike",
|
|
233
|
+
"orig_width": w, "orig_height": h,
|
|
234
|
+
})
|
|
235
|
+
out.sort(key=lambda it: LICENSE_RANK.get(it["license_class"], 9)) # stable: search order inside each class
|
|
236
|
+
return out[:max(want * 3, want)]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def search_pexels(query: str, want: int, orientation: str | None, key: str) -> list[dict]:
|
|
240
|
+
params = {"query": query, "per_page": min(80, max(want * 3, 10)), "locale": "pt-BR"}
|
|
241
|
+
if orientation in ("portrait", "landscape"):
|
|
242
|
+
params["orientation"] = orientation
|
|
243
|
+
try:
|
|
244
|
+
r = requests.get(PEXELS_API, params=params, timeout=TIMEOUT,
|
|
245
|
+
headers={"Authorization": key, "User-Agent": USER_AGENT})
|
|
246
|
+
except requests.RequestException as e:
|
|
247
|
+
raise FetchError(f"não consegui falar com o Pexels ({e.__class__.__name__}). Confira a internet.")
|
|
248
|
+
if r.status_code in (401, 403):
|
|
249
|
+
raise FetchError("o Pexels recusou a chave PEXELS_API_KEY. Confira se ela está certa.")
|
|
250
|
+
if r.status_code == 429:
|
|
251
|
+
raise FetchError("limite de buscas do Pexels atingido. Tente mais tarde ou use --source wikimedia.")
|
|
252
|
+
try:
|
|
253
|
+
r.raise_for_status()
|
|
254
|
+
photos = r.json().get("photos", [])
|
|
255
|
+
except (requests.RequestException, ValueError) as e:
|
|
256
|
+
raise FetchError(f"resposta inválida do Pexels ({e.__class__.__name__}).")
|
|
257
|
+
out = []
|
|
258
|
+
for ph in photos:
|
|
259
|
+
src = ph.get("src") or {}
|
|
260
|
+
url = src.get("large2x") or src.get("large") or src.get("original")
|
|
261
|
+
if not url:
|
|
262
|
+
continue
|
|
263
|
+
out.append({
|
|
264
|
+
"source": "pexels", "id": f"pexels-{ph.get('id')}", "title": ph.get("alt") or "",
|
|
265
|
+
"download_url": url, "url": ph.get("url") or url,
|
|
266
|
+
"author": ph.get("photographer") or "desconhecido",
|
|
267
|
+
"license": "Pexels License", "license_url": "https://www.pexels.com/license/",
|
|
268
|
+
"license_class": "free", "attribution_required": False, "share_alike": False,
|
|
269
|
+
"orig_width": int(ph.get("width") or 0), "orig_height": int(ph.get("height") or 0),
|
|
270
|
+
})
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------- download
|
|
275
|
+
|
|
276
|
+
def download(item: dict, dest_dir: Path, base_name: str) -> dict:
|
|
277
|
+
try:
|
|
278
|
+
r = requests.get(item["download_url"], headers={"User-Agent": USER_AGENT}, timeout=TIMEOUT)
|
|
279
|
+
r.raise_for_status()
|
|
280
|
+
except requests.RequestException as e:
|
|
281
|
+
raise FetchError(f"falha ao baixar {item['url']} ({e.__class__.__name__})")
|
|
282
|
+
try:
|
|
283
|
+
img = Image.open(io.BytesIO(r.content))
|
|
284
|
+
img.load()
|
|
285
|
+
except Exception:
|
|
286
|
+
raise FetchError(f"o arquivo baixado não é uma imagem válida: {item['url']}")
|
|
287
|
+
fmt = (img.format or "").upper()
|
|
288
|
+
if fmt == "PNG":
|
|
289
|
+
ext, data = ".png", r.content
|
|
290
|
+
elif fmt == "JPEG" and img.mode in ("RGB", "L"):
|
|
291
|
+
ext, data = ".jpg", r.content
|
|
292
|
+
else:
|
|
293
|
+
buf = io.BytesIO()
|
|
294
|
+
rgb = img.convert("RGBA")
|
|
295
|
+
bg = Image.new("RGB", rgb.size, (255, 255, 255))
|
|
296
|
+
bg.paste(rgb, mask=rgb.getchannel("A"))
|
|
297
|
+
bg.save(buf, "JPEG", quality=93)
|
|
298
|
+
ext, data = ".jpg", buf.getvalue()
|
|
299
|
+
path = dest_dir / f"{base_name}{ext}"
|
|
300
|
+
tmp = path.with_name(".tmp-" + path.name)
|
|
301
|
+
tmp.write_bytes(data)
|
|
302
|
+
tmp.replace(path)
|
|
303
|
+
return {"file": path.name, "width": img.width, "height": img.height}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def main(argv: list[str] | None = None) -> int:
|
|
307
|
+
ap = argparse.ArgumentParser(description="Busca e baixa imagens livres para os inserts (Wikimedia Commons ou Pexels).")
|
|
308
|
+
ap.add_argument("query", help="o que buscar (em inglês costuma achar mais, ex: \"leather handbag\")")
|
|
309
|
+
ap.add_argument("--edit-dir", required=True, help="pasta edit da sessão")
|
|
310
|
+
ap.add_argument("--source", choices=("wikimedia", "pexels"), default="wikimedia")
|
|
311
|
+
ap.add_argument("--count", type=int, default=3, help="quantas imagens baixar (padrão 3)")
|
|
312
|
+
ap.add_argument("--orientation", choices=("portrait", "landscape"), help="retrato ou paisagem")
|
|
313
|
+
ap.add_argument("--min-width", type=int, default=800, help="largura mínima da original (padrão 800)")
|
|
314
|
+
ap.add_argument("--allow-attribution", action="store_true",
|
|
315
|
+
help="aceita também CC BY (o autor precisa ser creditado no texto do post)")
|
|
316
|
+
ap.add_argument("--allow-sharealike", action="store_true",
|
|
317
|
+
help="aceita também CC BY-SA (crédito e a peça fica sob a mesma licença; evite em anúncio)")
|
|
318
|
+
ap.add_argument("--json", action="store_true", help="imprime o resultado em JSON")
|
|
319
|
+
args = ap.parse_args(argv)
|
|
320
|
+
|
|
321
|
+
if not args.query.strip():
|
|
322
|
+
print("erro: a busca está vazia", file=sys.stderr)
|
|
323
|
+
return 2
|
|
324
|
+
if not 1 <= args.count <= 20:
|
|
325
|
+
print("erro: --count precisa ficar entre 1 e 20", file=sys.stderr)
|
|
326
|
+
return 2
|
|
327
|
+
edit = Path(args.edit_dir).resolve()
|
|
328
|
+
if not edit.is_dir():
|
|
329
|
+
print(f"erro: pasta edit não encontrada: {edit}", file=sys.stderr)
|
|
330
|
+
return 2
|
|
331
|
+
assets = edit / "assets"
|
|
332
|
+
try:
|
|
333
|
+
assets.mkdir(exist_ok=True)
|
|
334
|
+
except OSError as e:
|
|
335
|
+
print(f"erro: não consegui criar {assets}: {e}", file=sys.stderr)
|
|
336
|
+
return 2
|
|
337
|
+
credits_path = assets / "credits.json"
|
|
338
|
+
credits = load_credits(credits_path)
|
|
339
|
+
|
|
340
|
+
source = args.source
|
|
341
|
+
key = None
|
|
342
|
+
if source == "pexels":
|
|
343
|
+
try:
|
|
344
|
+
key = read_env_key("PEXELS_API_KEY")
|
|
345
|
+
except EnvFileError as e:
|
|
346
|
+
print(f"erro: {e}", file=sys.stderr)
|
|
347
|
+
return 2
|
|
348
|
+
if not key:
|
|
349
|
+
print("aviso: PEXELS_API_KEY não configurada (é opcional). Buscando no Wikimedia Commons.")
|
|
350
|
+
source = "wikimedia"
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
if source == "pexels":
|
|
354
|
+
found = search_pexels(args.query, args.count, args.orientation, key or "")
|
|
355
|
+
else:
|
|
356
|
+
found = search_wikimedia(args.query, args.count, args.orientation, args.min_width,
|
|
357
|
+
args.allow_attribution, args.allow_sharealike)
|
|
358
|
+
except FetchError as e:
|
|
359
|
+
print(f"erro: {e}", file=sys.stderr)
|
|
360
|
+
return 1
|
|
361
|
+
|
|
362
|
+
if not found:
|
|
363
|
+
hint = "" if (args.allow_attribution or source == "pexels") else \
|
|
364
|
+
" Se aceitar dar crédito ao autor no texto do post, rode de novo com --allow-attribution."
|
|
365
|
+
print(f"nenhuma imagem livre (CC0 ou domínio público) para \"{args.query}\". Tente outras palavras (em inglês "
|
|
366
|
+
f"costuma achar mais)" + (" ou tire o --orientation." if args.orientation else ".") + hint)
|
|
367
|
+
return 1
|
|
368
|
+
|
|
369
|
+
by_url = {c.get("url"): c for c in credits if isinstance(c, dict)}
|
|
370
|
+
results, errors = [], []
|
|
371
|
+
slug = slugify(args.query)
|
|
372
|
+
for item in found:
|
|
373
|
+
if len(results) >= args.count:
|
|
374
|
+
break
|
|
375
|
+
known = by_url.get(item["url"])
|
|
376
|
+
if known and (assets / str(known.get("file", "")).removeprefix("assets/")).exists():
|
|
377
|
+
results.append(dict(known, reused=True))
|
|
378
|
+
continue
|
|
379
|
+
base = f"{slug}-{re.sub(r'[^a-z0-9]+', '', item['id'].lower())}"
|
|
380
|
+
try:
|
|
381
|
+
got = download(item, assets, base)
|
|
382
|
+
except FetchError as e:
|
|
383
|
+
errors.append(str(e))
|
|
384
|
+
continue
|
|
385
|
+
entry = {
|
|
386
|
+
"file": f"assets/{got['file']}", "source": item["source"], "query": args.query,
|
|
387
|
+
"title": item["title"], "url": item["url"], "download_url": item["download_url"],
|
|
388
|
+
"author": item["author"], "license": item["license"], "license_url": item["license_url"],
|
|
389
|
+
"attribution_required": item["attribution_required"], "share_alike": item.get("share_alike", False),
|
|
390
|
+
"credit_line": credit_line(item) if item["attribution_required"] else None,
|
|
391
|
+
"width": got["width"], "height": got["height"], "added_at": now_iso(),
|
|
392
|
+
}
|
|
393
|
+
credits.append(entry)
|
|
394
|
+
by_url[item["url"]] = entry
|
|
395
|
+
save_json(credits_path, credits)
|
|
396
|
+
results.append(entry)
|
|
397
|
+
|
|
398
|
+
if args.json:
|
|
399
|
+
print(json.dumps({"query": args.query, "source": source, "images": results, "errors": errors},
|
|
400
|
+
ensure_ascii=False, indent=2))
|
|
401
|
+
else:
|
|
402
|
+
for e in results:
|
|
403
|
+
tag = " (já estava baixada)" if e.get("reused") else ""
|
|
404
|
+
print(f"ok: {e['file']} {e['width']}x{e['height']} {e['license']} por {e['author']}{tag}")
|
|
405
|
+
if e.get("share_alike"):
|
|
406
|
+
print(f" exige crédito e a peça fica sob a mesma licença (ShareAlike). Crédito: {e.get('credit_line') or credit_line(e)}")
|
|
407
|
+
elif e.get("attribution_required"):
|
|
408
|
+
print(f" exige crédito no texto do post: {e.get('credit_line') or credit_line(e)}")
|
|
409
|
+
for msg in errors:
|
|
410
|
+
print(f"aviso: {msg}")
|
|
411
|
+
if results and len(results) < args.count and not (args.allow_attribution or args.allow_sharealike) and source == "wikimedia":
|
|
412
|
+
print(f"aviso: só {len(results)} de {args.count} com licença livre (CC0 ou domínio público). Para ver mais, "
|
|
413
|
+
f"--allow-attribution aceita CC BY, com crédito ao autor no texto do post.")
|
|
414
|
+
if results:
|
|
415
|
+
print(f"créditos em {credits_path}")
|
|
416
|
+
if not results:
|
|
417
|
+
print("erro: nenhuma imagem baixada", file=sys.stderr)
|
|
418
|
+
return 1
|
|
419
|
+
return 0
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
if __name__ == "__main__":
|
|
423
|
+
sys.exit(main())
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Ganho por trecho medido, ANTES do render_cut: a voz chega perto do alvo e o loudnorm fica linear.
|
|
2
|
+
|
|
3
|
+
Escrito pelo PrimoCode em cima do EDIT IA PRO (SHK Soluções Digitais, com autorização por escrito, 19/10/2026).
|
|
4
|
+
|
|
5
|
+
O problema que resolve. Uma gravação de celular com a voz em -38 LUFS precisa de +24 dB para chegar em -14. O
|
|
6
|
+
loudnorm do render_cut (duas passadas, `linear=true`) só fica linear quando o pico real medido mais o ganho que ele
|
|
7
|
+
aplica não passa de -1 dBTP; com a voz baixa e os picos soltos, ele cai sozinho no modo DINÂMICO (um compressor que
|
|
8
|
+
bombeia a voz) sem avisar. Antes isso era um `gain_db` posto à mão em cada trecho do EDL.
|
|
9
|
+
|
|
10
|
+
O que faz:
|
|
11
|
+
1. mede cada trecho do EDL (ffmpeg ebur128 com passa-alta de 80 Hz: o ronco da sala não conta como voz), com o
|
|
12
|
+
`gain_db` que o trecho já tem (o relativo que o voice_levels/montar_corte pôs continua valendo);
|
|
13
|
+
2. mede o corte inteiro do jeito que o render_cut vai juntar (os trechos em sequência, com o ganho atual) e acha a
|
|
14
|
+
referência integrada;
|
|
15
|
+
3. soma a todos os trechos o mesmo ganho base = ALVO_PRE - referência. Assim a voz chega uns 1,5 dB ACIMA de -14 e
|
|
16
|
+
o loudnorm só abaixa (abaixar é sempre linear). O limitador por trecho do render_cut (alimiter em -1 dBFS, ataque
|
|
17
|
+
5 ms, liga quando gain_db > 0) segura os picos que o ganho empurra;
|
|
18
|
+
4. trecho que ficou 3 dB ou mais fora da referência ganha a diferença (até 9 dB), para a voz não pular de volume
|
|
19
|
+
entre tomadas; trechos que continuam a mesma fala (mesma fonte, encostados) ficam com o MESMO ganho (senão o
|
|
20
|
+
render_cut põe um fade de 30 ms no meio da palavra);
|
|
21
|
+
5. grava o `gain_db` em cada trecho (com `ganho_auto` e `nivel_lufs`, só para ler) e um relatório em
|
|
22
|
+
<edit>/verify/ganho_por_trecho.json.
|
|
23
|
+
|
|
24
|
+
Uso:
|
|
25
|
+
python helpers/ganho_por_trecho.py <edit>/edl_manual.json [--alvo -12.5] [--dry-run] [--json]
|
|
26
|
+
|
|
27
|
+
Rodar de novo é seguro: o ganho é recalculado a partir do nível medido com o ganho que já está lá.
|
|
28
|
+
Saída 0 quando gravou (ou nada a mudar), 2 em erro de entrada.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import re
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
40
|
+
from common import probe_video # noqa: E402
|
|
41
|
+
import render_cut as rc # noqa: E402
|
|
42
|
+
|
|
43
|
+
ALVO_PRE = -12.5 # LUFS integrado do corte antes do loudnorm (o loudnorm de -14 só abaixa)
|
|
44
|
+
DESVIO_MIN = 3.0 # dB: trecho mais longe que isso da referência é corrigido sozinho
|
|
45
|
+
DESVIO_MAX = 9.0 # dB: teto da correção relativa
|
|
46
|
+
GANHO_MAX = 32.0
|
|
47
|
+
GANHO_MIN = -12.0
|
|
48
|
+
CURTO_S = 1.0 # trecho mais curto que isso não tem leitura integrada confiável: fica com o ganho base
|
|
49
|
+
GRUDADO_S = 0.02 # dois trechos da mesma fonte a menos disso são a mesma fala
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def erro(msg: str, code: int = 2):
|
|
53
|
+
print(f"erro: {msg}", file=sys.stderr)
|
|
54
|
+
sys.exit(code)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolver_fonte(edl_path: Path, edl: dict, chave: str) -> Path:
|
|
58
|
+
bruto = (edl.get("sources") or {}).get(chave)
|
|
59
|
+
if not bruto:
|
|
60
|
+
erro(f"a fonte '{chave}' não está em sources")
|
|
61
|
+
p = Path(bruto)
|
|
62
|
+
for c in (p, edl_path.parent / p, edl_path.parent.parent / p):
|
|
63
|
+
if c.exists():
|
|
64
|
+
return c.resolve()
|
|
65
|
+
erro(f"não achei o arquivo da fonte '{chave}': {bruto}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ebur128(args: list[str]) -> tuple[float | None, float | None]:
|
|
69
|
+
r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-nostdin", *args, "-f", "null", "-"],
|
|
70
|
+
capture_output=True, text=True)
|
|
71
|
+
txt = r.stderr
|
|
72
|
+
resumo = txt[txt.rfind("Summary:"):] if "Summary:" in txt else txt
|
|
73
|
+
mi = re.search(r"I:\s+(-?[\d.]+|-inf)\s+LUFS", resumo)
|
|
74
|
+
mp = re.search(r"Peak:\s+(-?[\d.]+|-inf)\s+dBFS", resumo)
|
|
75
|
+
def num(m):
|
|
76
|
+
if not m or "inf" in m.group(1):
|
|
77
|
+
return None
|
|
78
|
+
v = float(m.group(1))
|
|
79
|
+
return None if v <= -69.9 else v
|
|
80
|
+
return num(mi), num(mp)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_CANAIS: dict[str, str] = {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def filtro(src: Path) -> str:
|
|
89
|
+
"""Os canais viram estéreo do mesmo jeito que o render_cut faz (mono vai cheio nos dois lados: +3 LU sobre o
|
|
90
|
+
mono), e o passa-alta de 80 Hz tira o ronco da sala da leitura."""
|
|
91
|
+
k = str(src)
|
|
92
|
+
if k not in _CANAIS:
|
|
93
|
+
_CANAIS[k] = rc.channel_filter(rc.channel_mode(src, probe_video(src)))
|
|
94
|
+
return f"aresample=48000,{_CANAIS[k]},highpass=f=80"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def medir_trecho(src: Path, ini: float, fim: float, ganho: float) -> tuple[float | None, float | None]:
|
|
98
|
+
af = f"{filtro(src)},volume={ganho:.2f}dB,ebur128=peak=true"
|
|
99
|
+
return ebur128(["-ss", f"{ini:.3f}", "-t", f"{max(0.05, fim - ini):.3f}", "-i", str(src), "-vn", "-af", af])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def medir_corte(trechos: list[dict]) -> tuple[float | None, float | None]:
|
|
103
|
+
"""O corte como o render_cut junta: cada trecho com o seu ganho, em sequência."""
|
|
104
|
+
entradas, cadeias, rotulos = [], [], []
|
|
105
|
+
fontes = {}
|
|
106
|
+
for t in trechos:
|
|
107
|
+
if str(t["src"]) not in fontes:
|
|
108
|
+
fontes[str(t["src"])] = len(fontes)
|
|
109
|
+
entradas += ["-i", str(t["src"])]
|
|
110
|
+
for i, t in enumerate(trechos):
|
|
111
|
+
k = fontes[str(t["src"])]
|
|
112
|
+
cadeias.append(f"[{k}:a]atrim=start={t['start']:.3f}:end={t['end']:.3f},asetpts=PTS-STARTPTS,"
|
|
113
|
+
f"{filtro(t['src'])},volume={t['ganho']:.2f}dB[a{i}]")
|
|
114
|
+
rotulos.append(f"[a{i}]")
|
|
115
|
+
grafo = ";".join(cadeias) + ";" + "".join(rotulos) + f"concat=n={len(trechos)}:v=0:a=1,ebur128=peak=true[o]"
|
|
116
|
+
return ebur128([*entradas, "-filter_complex", grafo, "-map", "[o]"])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> int:
|
|
120
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
121
|
+
ap.add_argument("edl", type=Path)
|
|
122
|
+
ap.add_argument("--alvo", type=float, default=ALVO_PRE, help="LUFS do corte antes do loudnorm (padrão -12.5)")
|
|
123
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
124
|
+
ap.add_argument("--json", action="store_true")
|
|
125
|
+
a = ap.parse_args()
|
|
126
|
+
if not a.edl.exists():
|
|
127
|
+
erro(f"não achei {a.edl}")
|
|
128
|
+
edl = json.loads(a.edl.read_text(encoding="utf-8"))
|
|
129
|
+
ranges = edl.get("ranges") or []
|
|
130
|
+
if not ranges:
|
|
131
|
+
erro("o EDL não tem trechos")
|
|
132
|
+
|
|
133
|
+
trechos = []
|
|
134
|
+
for r in ranges:
|
|
135
|
+
src = resolver_fonte(a.edl, edl, r["source"])
|
|
136
|
+
ganho = float(r.get("gain_db") or 0.0)
|
|
137
|
+
if r.get("ganho_auto") is not None: # rodada anterior: volta ao ganho relativo que havia antes
|
|
138
|
+
ganho -= float(r["ganho_auto"])
|
|
139
|
+
nivel, pico = medir_trecho(src, float(r["start"]), float(r["end"]), ganho)
|
|
140
|
+
trechos.append(dict(id=r["id"], src=src, start=float(r["start"]), end=float(r["end"]), ganho=ganho,
|
|
141
|
+
nivel=nivel, pico=pico, dur=float(r["end"]) - float(r["start"])))
|
|
142
|
+
|
|
143
|
+
ref, pico_ref = medir_corte(trechos)
|
|
144
|
+
if ref is None:
|
|
145
|
+
validos = sorted((t["nivel"], t["dur"]) for t in trechos if t["nivel"] is not None)
|
|
146
|
+
if not validos:
|
|
147
|
+
erro("não consegui medir a voz de nenhum trecho (áudio mudo?)")
|
|
148
|
+
meio, acc = sum(d for _, d in validos) / 2, 0.0
|
|
149
|
+
for n, d in validos:
|
|
150
|
+
acc += d
|
|
151
|
+
if acc >= meio:
|
|
152
|
+
ref = n
|
|
153
|
+
break
|
|
154
|
+
base = a.alvo - ref
|
|
155
|
+
|
|
156
|
+
for t in trechos:
|
|
157
|
+
rel = 0.0
|
|
158
|
+
if t["nivel"] is not None and t["dur"] >= CURTO_S:
|
|
159
|
+
d = ref - t["nivel"]
|
|
160
|
+
if abs(d) >= DESVIO_MIN:
|
|
161
|
+
rel = max(-DESVIO_MAX, min(DESVIO_MAX, d))
|
|
162
|
+
t["rel"] = rel
|
|
163
|
+
t["novo"] = max(GANHO_MIN, min(GANHO_MAX, t["ganho"] + base + rel))
|
|
164
|
+
|
|
165
|
+
# a mesma fala (mesma fonte, encostada) leva o mesmo ganho: a média ponderada pela duração
|
|
166
|
+
grupos, atual = [], [trechos[0]]
|
|
167
|
+
for t in trechos[1:]:
|
|
168
|
+
ant = atual[-1]
|
|
169
|
+
if t["src"] == ant["src"] and abs(t["start"] - ant["end"]) <= GRUDADO_S:
|
|
170
|
+
atual.append(t)
|
|
171
|
+
else:
|
|
172
|
+
grupos.append(atual)
|
|
173
|
+
atual = [t]
|
|
174
|
+
grupos.append(atual)
|
|
175
|
+
for g in grupos:
|
|
176
|
+
if len(g) > 1:
|
|
177
|
+
m = sum(t["novo"] * t["dur"] for t in g) / max(1e-6, sum(t["dur"] for t in g))
|
|
178
|
+
for t in g:
|
|
179
|
+
t["novo"] = m
|
|
180
|
+
|
|
181
|
+
por_id = {t["id"]: t for t in trechos}
|
|
182
|
+
for r in ranges:
|
|
183
|
+
t = por_id[r["id"]]
|
|
184
|
+
r["gain_db"] = round(t["novo"], 1)
|
|
185
|
+
r["ganho_auto"] = round(t["novo"] - t["ganho"], 1)
|
|
186
|
+
r["nivel_lufs"] = None if t["nivel"] is None else round(t["nivel"], 1)
|
|
187
|
+
|
|
188
|
+
rel = {"alvo_pre_lufs": a.alvo, "referencia_lufs": round(ref, 2),
|
|
189
|
+
"pico_ref_dbfs": None if pico_ref is None else round(pico_ref, 2),
|
|
190
|
+
"ganho_base_db": round(base, 2),
|
|
191
|
+
"trechos": [{"id": t["id"], "nivel_lufs": None if t["nivel"] is None else round(t["nivel"], 1),
|
|
192
|
+
"pico_dbfs": None if t["pico"] is None else round(t["pico"], 1),
|
|
193
|
+
"ganho_antes": round(t["ganho"], 1), "correcao_relativa": round(t["rel"], 1),
|
|
194
|
+
"gain_db": round(t["novo"], 1)} for t in trechos]}
|
|
195
|
+
if not a.dry_run:
|
|
196
|
+
a.edl.write_text(json.dumps(edl, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
197
|
+
ver = a.edl.parent / "verify"
|
|
198
|
+
ver.mkdir(exist_ok=True)
|
|
199
|
+
(ver / "ganho_por_trecho.json").write_text(json.dumps(rel, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
200
|
+
if a.json:
|
|
201
|
+
print(json.dumps(rel, ensure_ascii=False))
|
|
202
|
+
else:
|
|
203
|
+
print(f"voz do corte em {ref:.1f} LUFS; ganho base {base:+.1f} dB para chegar em {a.alvo:.1f} antes do loudnorm")
|
|
204
|
+
for t in trechos:
|
|
205
|
+
extra = f" (fora da referência: {t['rel']:+.1f} dB)" if t["rel"] else ""
|
|
206
|
+
nv = "curto" if t["nivel"] is None else f"{t['nivel']:.1f} LUFS"
|
|
207
|
+
print(f" {t['id']}: {nv} -> gain_db {t['novo']:+.1f}{extra}")
|
|
208
|
+
if not a.dry_run:
|
|
209
|
+
print(f"gravado: {a.edl}")
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
sys.exit(main())
|