primocode 10.15.2 → 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 (245) 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 +74 -9
  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 +38 -8
  198. package/lib/app-chat.js +97 -0
  199. package/lib/catalogo.js +7 -1
  200. package/lib/chamada-torta.js +81 -0
  201. package/lib/editor-pro.js +786 -0
  202. package/lib/entrega.js +58 -0
  203. package/lib/estudio/conferencia-visual.js +76 -0
  204. package/lib/estudio/diretor.js +6 -1
  205. package/lib/estudio/exportar.js +3 -1
  206. package/lib/estudio/gosto/tela.html +1349 -0
  207. package/lib/estudio/gosto.js +305 -0
  208. package/lib/estudio/index.js +12 -1
  209. package/lib/estudio/kit-ae/base.jsx +119 -13
  210. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  211. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  212. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  213. package/lib/estudio/maquina-diretor.js +70 -12
  214. package/lib/estudio/maquina-foto.js +177 -0
  215. package/lib/estudio/maquina-guarda.js +207 -0
  216. package/lib/estudio/maquina-layout.js +22 -1
  217. package/lib/estudio/maquina.js +64 -11
  218. package/lib/estudio/referencias.js +1 -1
  219. package/lib/estudio/visao/visao.swift +97 -0
  220. package/lib/estudio/visao.js +60 -0
  221. package/lib/headless.js +15 -3
  222. package/lib/local/cerebro.json +285 -7
  223. package/lib/local/motor.js +5 -2
  224. package/lib/memoria-usuario.js +164 -0
  225. package/lib/nuvem.js +33 -2
  226. package/lib/passagem.js +50 -0
  227. package/lib/perigo.js +19 -11
  228. package/lib/protecao.js +134 -0
  229. package/lib/repeticao.js +1 -1
  230. package/lib/sandbox.js +57 -9
  231. package/lib/titulo-conversa.js +46 -0
  232. package/lib/tools.js +23 -4
  233. package/lib/ui.js +2 -2
  234. package/package.json +3 -2
  235. package/skills/editar-meu-video/SKILL.md +6 -2
  236. package/skills/editar-video/SKILL.md +113 -0
  237. package/studio/pessoa.py +52 -0
  238. package/voz/primo_voz/__main__.py +55 -40
  239. package/voz/primo_voz/app.py +500 -4
  240. package/voz/primo_voz/chat.html +1773 -0
  241. package/voz/primo_voz/conversa.py +0 -26
  242. package/voz/primo_voz/hud.html +8 -2
  243. package/voz/primo_voz/hud.py +129 -5
  244. package/voz/primo_voz/identidade.py +1 -1
  245. 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())