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.
Files changed (237) 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 +28 -8
  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/mcp.js +105 -7
  222. package/lib/nuvem.js +11 -1
  223. package/lib/outra-pasta.js +1 -1
  224. package/lib/passagem.js +50 -0
  225. package/lib/perigo.js +29 -12
  226. package/lib/protecao.js +134 -0
  227. package/lib/sandbox.js +11 -4
  228. package/lib/titulo-conversa.js +46 -0
  229. package/lib/tools.js +3 -0
  230. package/lib/ui.js +1 -1
  231. package/package.json +3 -2
  232. package/skills/editar-meu-video/SKILL.md +6 -2
  233. package/skills/editar-video/SKILL.md +113 -0
  234. package/studio/pessoa.py +52 -0
  235. package/voz/primo_voz/app.py +43 -4
  236. package/voz/primo_voz/chat.html +17 -6
  237. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,312 @@
1
+ """Optional AI image for inserts, through OpenRouter (Contract 8).
2
+
3
+ The key comes from OPENROUTER_API_KEY in the environment or from `.env` in the skill root.
4
+ Without a key the helper exits 2 and explains that this step is optional (fetch_images.py finds
5
+ free photos with no key).
6
+
7
+ The model is picked from OpenRouter's public model list (models whose output includes images),
8
+ preferring a fast, cheap, current one; `--model` forces another. The image is resized to cover
9
+ `--size` and saved to <edit>/assets/, with an entry in <edit>/assets/credits.json.
10
+
11
+ Before decoding, the answer (image link or data) is saved to <edit>/assets/.ia-ultima-resposta.json.
12
+ If saving the image fails after a paid generation (link expired, network, bad data), the error
13
+ says so and `--reuse-last` tries again from that file without calling the model (no new cost).
14
+
15
+ Usage:
16
+ uv run python helpers/gen_image.py "<prompt>" --edit-dir DIR [--size 1080x900] [-o name.png]
17
+ [--model ID] [--raw] [--dry-run] [--reuse-last]
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import base64
24
+ import binascii
25
+ import hashlib
26
+ import io
27
+ import json
28
+ import re
29
+ import sys
30
+ import unicodedata
31
+ from pathlib import Path
32
+
33
+ import requests
34
+ from PIL import Image
35
+
36
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
37
+ from common import load_json, now_iso, save_json # noqa: E402
38
+ from fetch_images import EnvFileError, read_env_key # noqa: E402 (one .env reader for both helpers)
39
+
40
+ USER_AGENT = "editor-video/0.1 (suporte@sistemajarvis.com)"
41
+ MODELS_URL = "https://openrouter.ai/api/v1/models"
42
+ CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
43
+ # Preferred order: current, fast and cheap first. The live list decides what exists.
44
+ PREFERRED_MODELS = (
45
+ "google/gemini-3.1-flash-image",
46
+ "google/gemini-3.1-flash-image-preview",
47
+ "google/gemini-2.5-flash-image",
48
+ "openai/gpt-5-image-mini",
49
+ "google/gemini-3-pro-image",
50
+ "openai/gpt-5-image",
51
+ )
52
+ FALLBACK_MODEL = PREFERRED_MODELS[0]
53
+ ASPECTS = ("1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9")
54
+ PROMPT_SUFFIX = ("Photorealistic, natural light, sharp focus. No text, letters, logos, brand marks "
55
+ "or watermarks anywhere in the image.")
56
+
57
+
58
+ class GenError(Exception):
59
+ def __init__(self, message: str, code: int = 1):
60
+ super().__init__(message)
61
+ self.code = code
62
+
63
+
64
+ def parse_size(value: str) -> tuple[int, int]:
65
+ m = re.fullmatch(r"\s*(\d{2,5})\s*[xX]\s*(\d{2,5})\s*", value or "")
66
+ if not m:
67
+ raise GenError(f"tamanho inválido: {value!r}. Use LARGURAxALTURA, ex: 1080x900", 2)
68
+ w, h = int(m.group(1)), int(m.group(2))
69
+ if not (64 <= w <= 4096 and 64 <= h <= 4096):
70
+ raise GenError("o tamanho precisa ficar entre 64 e 4096 px de cada lado", 2)
71
+ return w, h
72
+
73
+
74
+ def nearest_aspect(w: int, h: int) -> str:
75
+ target = w / h
76
+
77
+ def ratio(a: str) -> float:
78
+ x, y = a.split(":")
79
+ return int(x) / int(y)
80
+
81
+ return min(ASPECTS, key=lambda a: abs(ratio(a) - target) / target)
82
+
83
+
84
+ def slugify(text: str, limit: int = 32) -> str:
85
+ t = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii").lower()
86
+ t = re.sub(r"[^a-z0-9]+", "-", t).strip("-")
87
+ return (t[:limit].rstrip("-")) or "imagem"
88
+
89
+
90
+ def image_models(timeout: float = 20) -> list[dict]:
91
+ """Public model list filtered to models that can output images (no key needed)."""
92
+ r = requests.get(MODELS_URL, headers={"User-Agent": USER_AGENT}, timeout=timeout)
93
+ r.raise_for_status()
94
+ out = []
95
+ for m in r.json().get("data", []):
96
+ arch = m.get("architecture") or {}
97
+ if "image" in (arch.get("output_modalities") or []) and not str(m.get("id", "")).startswith("openrouter/"):
98
+ out.append(m)
99
+ return out
100
+
101
+
102
+ def pick_model(requested: str | None) -> tuple[str, str]:
103
+ """-> (model id, note). Uses the live list; falls back to a known id when offline."""
104
+ try:
105
+ models = image_models()
106
+ except (requests.RequestException, ValueError):
107
+ if requested:
108
+ return requested, "lista de modelos indisponível; usando o modelo pedido"
109
+ return FALLBACK_MODEL, "lista de modelos indisponível; usando o padrão"
110
+ ids = {m["id"] for m in models}
111
+ if requested:
112
+ if requested not in ids:
113
+ raise GenError(f"o modelo '{requested}' não gera imagem no OpenRouter agora. Opções: {', '.join(sorted(ids))}", 2)
114
+ return requested, "modelo pedido"
115
+ for mid in PREFERRED_MODELS:
116
+ if mid in ids:
117
+ return mid, "modelo preferido disponível"
118
+ if not models:
119
+ return FALLBACK_MODEL, "nenhum modelo de imagem listado; usando o padrão"
120
+
121
+ def price(m: dict) -> float:
122
+ try:
123
+ return float((m.get("pricing") or {}).get("image_output") or 1e9)
124
+ except ValueError:
125
+ return 1e9
126
+
127
+ best = sorted(models, key=lambda m: (price(m), -int(m.get("created") or 0)))[0]
128
+ return best["id"], "modelo de imagem mais barato da lista"
129
+
130
+
131
+ def build_request(prompt: str, model: str, size: tuple[int, int], raw: bool) -> dict:
132
+ text = prompt.strip() if raw else f"{prompt.strip()}\n\n{PROMPT_SUFFIX}"
133
+ return {
134
+ "model": model,
135
+ "messages": [{"role": "user", "content": text}],
136
+ "modalities": ["image", "text"],
137
+ "image_config": {"aspect_ratio": nearest_aspect(*size)},
138
+ }
139
+
140
+
141
+ LAST_RESPONSE = ".ia-ultima-resposta.json"
142
+
143
+
144
+ def image_from_url(url: str, saved: str) -> bytes:
145
+ """Bytes of an image the model returned (data: URL or link). `saved` names where the answer is kept."""
146
+ if not isinstance(url, str) or not url:
147
+ raise GenError(f"o modelo devolveu um endereço de imagem vazio. A resposta ficou em {saved}.")
148
+ if url.startswith("data:"):
149
+ if "," not in url:
150
+ raise GenError(f"a imagem veio num formato que não consegui ler. A resposta ficou em {saved}.")
151
+ try:
152
+ return base64.b64decode(url.split(",", 1)[1])
153
+ except (binascii.Error, ValueError):
154
+ raise GenError(f"a imagem veio com dados inválidos (base64). A resposta ficou em {saved}; rode de novo "
155
+ f"com --reuse-last se quiser tentar outra vez sem pagar.")
156
+ if url.startswith("http"):
157
+ try:
158
+ got = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=60)
159
+ got.raise_for_status()
160
+ return got.content
161
+ except requests.RequestException as e:
162
+ code = getattr(getattr(e, "response", None), "status_code", None)
163
+ why = f"erro {code}" if code else e.__class__.__name__
164
+ raise GenError(f"a imagem foi gerada, mas não consegui baixar ({why}). O link ficou em {saved}: rode de novo "
165
+ f"com --reuse-last para tentar baixar sem pagar outra vez.")
166
+ raise GenError(f"o modelo devolveu um endereço de imagem que não reconheço. A resposta ficou em {saved}.")
167
+
168
+
169
+ def request_image(payload: dict, key: str, timeout: float = 180, save_to: Path | None = None) -> bytes:
170
+ headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json",
171
+ "User-Agent": USER_AGENT, "X-Title": "editor-video"}
172
+ try:
173
+ r = requests.post(CHAT_URL, headers=headers, data=json.dumps(payload), timeout=timeout)
174
+ except requests.RequestException as e:
175
+ raise GenError(f"não consegui falar com o OpenRouter ({e.__class__.__name__}). Confira a internet.")
176
+ if r.status_code == 401:
177
+ raise GenError("o OpenRouter recusou a chave OPENROUTER_API_KEY. Confira se ela está certa.", 2)
178
+ if r.status_code == 402:
179
+ raise GenError("a conta do OpenRouter está sem crédito. Adicione crédito ou use fetch_images.py.")
180
+ if r.status_code == 429:
181
+ raise GenError("limite de uso do OpenRouter atingido. Tente de novo em alguns minutos.")
182
+ if r.status_code >= 400:
183
+ raise GenError(f"o OpenRouter respondeu erro {r.status_code}: {r.text[:300]}")
184
+ try:
185
+ data = r.json()
186
+ message = data["choices"][0]["message"]
187
+ except (ValueError, KeyError, IndexError, TypeError):
188
+ raise GenError("resposta inesperada do OpenRouter (sem mensagem).")
189
+ if not isinstance(message, dict):
190
+ raise GenError("resposta inesperada do OpenRouter (mensagem vazia). Nada foi salvo; tente de novo.")
191
+ urls = []
192
+ for img in message.get("images") or []:
193
+ url = ((img or {}).get("image_url") or {}).get("url", "") if isinstance(img, dict) else ""
194
+ if isinstance(url, str) and url.startswith(("data:", "http")):
195
+ urls.append(url)
196
+ if not urls:
197
+ raise GenError("o modelo não devolveu imagem. Tente reescrever o pedido de forma mais visual.")
198
+ saved = "a resposta"
199
+ if save_to is not None:
200
+ try:
201
+ save_json(save_to, {"model": payload.get("model"), "urls": urls, "saved_at": now_iso()})
202
+ saved = f"assets/{save_to.name}"
203
+ except OSError:
204
+ pass
205
+ return image_from_url(urls[0], saved)
206
+
207
+
208
+ def cover(img: Image.Image, size: tuple[int, int]) -> Image.Image:
209
+ w, h = size
210
+ s = max(w / img.width, h / img.height)
211
+ nw, nh = max(1, round(img.width * s)), max(1, round(img.height * s))
212
+ r = img.convert("RGB").resize((nw, nh), Image.LANCZOS)
213
+ x, y = (nw - w) // 2, (nh - h) // 2
214
+ return r.crop((x, y, x + w, y + h))
215
+
216
+
217
+ def main(argv: list[str] | None = None) -> int:
218
+ ap = argparse.ArgumentParser(description="Gera uma imagem com IA pelo OpenRouter (opcional) para usar nos inserts.")
219
+ ap.add_argument("prompt", help="descrição da imagem")
220
+ ap.add_argument("--edit-dir", required=True, help="pasta edit da sessão")
221
+ ap.add_argument("--size", default="1080x900", help="tamanho final LARGURAxALTURA (padrão 1080x900)")
222
+ ap.add_argument("-o", "--output", help="nome do arquivo em assets/ (padrão: gerado do texto)")
223
+ ap.add_argument("--model", help="força um modelo do OpenRouter")
224
+ ap.add_argument("--raw", action="store_true", help="manda o texto exatamente como está (sem o complemento sem texto/logo)")
225
+ ap.add_argument("--dry-run", action="store_true", help="só mostra o pedido que seria enviado, sem gerar")
226
+ ap.add_argument("--reuse-last", action="store_true",
227
+ help="não gera de novo: salva a imagem da última resposta guardada (depois de um erro ao baixar)")
228
+ args = ap.parse_args(argv)
229
+
230
+ try:
231
+ if not args.prompt.strip():
232
+ raise GenError("o pedido está vazio", 2)
233
+ size = parse_size(args.size)
234
+ edit = Path(args.edit_dir).resolve()
235
+ if not edit.is_dir():
236
+ raise GenError(f"pasta edit não encontrada: {edit}", 2)
237
+ try:
238
+ key = read_env_key("OPENROUTER_API_KEY")
239
+ except EnvFileError as e:
240
+ raise GenError(str(e), 2)
241
+ last_path = edit / "assets" / LAST_RESPONSE
242
+ if args.reuse_last:
243
+ last = load_json(last_path) if last_path.exists() else None
244
+ if not isinstance(last, dict) or not last.get("urls"):
245
+ raise GenError("não há resposta guardada para reaproveitar (assets/.ia-ultima-resposta.json). "
246
+ "Gere a imagem normalmente.", 2)
247
+ key = key or "reuse"
248
+ if not key and not args.dry_run:
249
+ raise GenError(
250
+ "OPENROUTER_API_KEY não configurada. Gerar imagem com IA é opcional: dá para seguir sem ela "
251
+ "usando fetch_images.py (fotos livres). Para ativar, crie uma chave em openrouter.ai e coloque "
252
+ "OPENROUTER_API_KEY=... no ambiente ou no arquivo .env da skill.", 2)
253
+
254
+ if args.reuse_last:
255
+ model, note = str(last.get("model") or args.model or FALLBACK_MODEL), "resposta guardada"
256
+ else:
257
+ model, note = pick_model(args.model)
258
+ payload = build_request(args.prompt, model, size, args.raw)
259
+ name = args.output or f"ia-{slugify(args.prompt)}-{hashlib.sha1((args.prompt + model).encode()).hexdigest()[:6]}.png"
260
+ name = Path(name).name
261
+ if not name.lower().endswith((".png", ".jpg", ".jpeg")):
262
+ name += ".png"
263
+ out_path = edit / "assets" / name
264
+
265
+ if args.dry_run:
266
+ print(f"modelo: {model} ({note})")
267
+ print(f"arquivo: {out_path}")
268
+ print("pedido (não enviado):")
269
+ print(json.dumps({"url": CHAT_URL, "payload": payload, "key": "configurada" if key else "ausente"},
270
+ ensure_ascii=False, indent=2))
271
+ return 0
272
+
273
+ if args.reuse_last:
274
+ print(f"usando a última resposta guardada ({model}), sem gerar de novo...")
275
+ raw = image_from_url(str(last["urls"][0]), f"assets/{LAST_RESPONSE}")
276
+ else:
277
+ print(f"gerando com {model}...")
278
+ (edit / "assets").mkdir(parents=True, exist_ok=True)
279
+ raw = request_image(payload, key or "", save_to=last_path)
280
+ try:
281
+ img = Image.open(io.BytesIO(raw))
282
+ img.load()
283
+ except Exception:
284
+ raise GenError(f"o OpenRouter devolveu um arquivo que não é imagem. A resposta ficou em assets/{LAST_RESPONSE}.")
285
+ final = cover(img, size)
286
+ out_path.parent.mkdir(parents=True, exist_ok=True)
287
+ tmp = out_path.with_name(".tmp-" + out_path.name)
288
+ if name.lower().endswith(".png"):
289
+ final.save(tmp, "PNG")
290
+ else:
291
+ final.save(tmp, "JPEG", quality=93)
292
+ tmp.replace(out_path)
293
+
294
+ credits_path = edit / "assets" / "credits.json"
295
+ credits = load_json(credits_path, default=[])
296
+ if not isinstance(credits, list):
297
+ credits = []
298
+ credits.append({
299
+ "file": f"assets/{name}", "source": "openrouter", "model": model, "prompt": args.prompt,
300
+ "url": None, "author": f"imagem gerada por IA ({model})", "license": "gerada por IA",
301
+ "width": size[0], "height": size[1], "added_at": now_iso(),
302
+ })
303
+ save_json(credits_path, credits)
304
+ print(f"ok: assets/{name} {size[0]}x{size[1]} modelo {model}")
305
+ return 0
306
+ except GenError as e:
307
+ print(f"erro: {e}", file=sys.stderr)
308
+ return e.code
309
+
310
+
311
+ if __name__ == "__main__":
312
+ sys.exit(main())
@@ -0,0 +1,316 @@
1
+ """Optional soundtrack made in the buyer's OWN ElevenLabs account (Eleven Music, paid per use).
2
+
3
+ Each buyer uses their own key (ELEVENLABS_API_KEY in the environment or in `.env` in the skill root) and
4
+ their own plan: nothing generated ships with the product. Without the key the helper exits 2 and tells the
5
+ agent to ask the person to paste the key in the chat (the agent writes the .env line itself). The key is
6
+ never printed, logged or saved (see elevenlabs_api.redact).
7
+
8
+ The track is instrumental by default (a sung voice fights the speaker) and lasts the cut duration from
9
+ <edit>/cut_timeline.json rounded up plus 2 s, kept between 3 s and 300 s. Each variation is one request.
10
+ Files go to <edit>/assets/trilha_<n>.mp3 (or --out) and every file gets an entry in
11
+ <edit>/assets/credits.json. Prompts that name real artists, songs, soundtracks or brand sounds are refused
12
+ before any request. `--dry-run` prints the request and the estimated credits without touching the network.
13
+
14
+ Moods (helpers/trilhas_humores.json): `--humor triste` fills the prompt with the mood's ready English prompt;
15
+ words given as the prompt are appended to it. `--biblioteca` (needs --humor) also puts every generated file in
16
+ the person's personal soundtrack library (helpers/trilhas.py, outside the skill folder), with the source,
17
+ prompt, model and usage note recorded. With --biblioteca the edit folder is optional: without it the file
18
+ waits in <library>/_recebidas/ until it is indexed, and the length defaults to 60 s (with an edit folder:
19
+ the longer of 60 s and the cut + 2 s), so one track covers most ads without repeating.
20
+
21
+ Usage:
22
+ uv run python helpers/gen_music.py "<prompt in English>" --edit-dir DIR [--seconds S]
23
+ [--no-instrumental] [--out NAME.mp3] [--variations N] [--model ID] [--dry-run]
24
+ uv run python helpers/gen_music.py [extra words] --humor ID [--biblioteca] [--titulo T] [--edit-dir DIR] ...
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import math
31
+ import sys
32
+ from datetime import datetime
33
+ from pathlib import Path
34
+
35
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
36
+ import elevenlabs_api as el # noqa: E402
37
+ from common import load_json, save_json # noqa: E402
38
+
39
+ MODELS = ("music_v2_5", "music_v2", "music_v1")
40
+ DEFAULT_MODEL = MODELS[0] # the "latest, most advanced" model in the ElevenLabs model list
41
+ MIN_SECONDS = 3.0 # API: music_length_ms from 3000
42
+ MAX_SECONDS = 300.0 # API accepts up to 600000 ms; the product page says 5 minutes
43
+ EXTRA_SECONDS = 2
44
+ LIBRARY_SECONDS = 60.0 # a library track should cover most ads (20 to 45 s) without looping
45
+ MAX_VARIATIONS = 3
46
+ SOURCE = "ElevenLabs Music (conta do usuário)"
47
+ LIBRARY_NOTE = (f"{el.COMMERCIAL_NOTE}; gerada na conta da própria pessoa, só para os vídeos dela "
48
+ "(não repassar, não vender, não juntar em pacote)")
49
+ EXAMPLE = "upbeat modern pop instrumental, 110-120 bpm, positive, no vocals, leaves room for voice"
50
+ TIMEOUT = 300
51
+ MUSIC_LINE = '"volume_db": -15, "duck": true, "fade_out": 1.5}'
52
+
53
+
54
+ def cut_duration(edit: Path) -> float | None:
55
+ try:
56
+ timeline = load_json(edit / "cut_timeline.json")
57
+ duration = float((timeline or {}).get("duration") or 0)
58
+ except (ValueError, TypeError, AttributeError, OSError):
59
+ return None
60
+ return duration if duration > 0 and math.isfinite(duration) else None
61
+
62
+
63
+ def pick_seconds(edit: Path | None, asked: float | None, library: bool = False) -> tuple[float, str, list[str]]:
64
+ """-> (seconds, where it came from, warnings)."""
65
+ notes = []
66
+ if asked is None:
67
+ duration = cut_duration(edit) if edit else None
68
+ if library:
69
+ if duration is not None and math.ceil(duration - 1e-6) + EXTRA_SECONDS > LIBRARY_SECONDS:
70
+ seconds = float(math.ceil(duration - 1e-6) + EXTRA_SECONDS)
71
+ origin = f"corte de {duration:.2f} s arredondado para cima + {EXTRA_SECONDS} s"
72
+ else:
73
+ seconds = LIBRARY_SECONDS
74
+ origin = f"padrão da biblioteca ({LIBRARY_SECONDS:g} s, cobre a maioria dos anúncios sem repetir)"
75
+ elif duration is None:
76
+ raise el.ElevenError("não achei a duração do corte (cut_timeline.json na pasta edit). Renderize o corte "
77
+ "antes ou diga a duração com --seconds.", 2)
78
+ else:
79
+ seconds = float(math.ceil(duration - 1e-6) + EXTRA_SECONDS)
80
+ origin = f"corte de {duration:.2f} s arredondado para cima + {EXTRA_SECONDS} s"
81
+ else:
82
+ if not math.isfinite(asked) or asked <= 0:
83
+ raise el.ElevenError("--seconds precisa ser um número maior que zero (ex.: 32)", 2)
84
+ seconds, origin = float(asked), "pedido em --seconds"
85
+ if seconds < MIN_SECONDS:
86
+ notes.append(f"a ElevenLabs faz trilha de {MIN_SECONDS:g} s no mínimo: ajustei {seconds:g} s "
87
+ f"para {MIN_SECONDS:g} s")
88
+ seconds = MIN_SECONDS
89
+ elif seconds > MAX_SECONDS:
90
+ notes.append(f"trilha limitada a {MAX_SECONDS:g} s: ajustei {seconds:g} s para {MAX_SECONDS:g} s")
91
+ seconds = MAX_SECONDS
92
+ return round(seconds, 3), origin, notes
93
+
94
+
95
+ def output_names(edit: Path, out: str | None, count: int) -> list[Path]:
96
+ assets = edit / "assets"
97
+ if out:
98
+ stem = el.output_stem(out)
99
+ names = [f"{stem}.mp3"] if count == 1 else [f"{stem}_{i}.mp3" for i in range(1, count + 1)]
100
+ else:
101
+ first = el.next_number(assets, "trilha_")
102
+ names = [f"trilha_{first + i}.mp3" for i in range(count)]
103
+ return el.free_paths(assets, names)
104
+
105
+
106
+ def inbox_names(lib: Path, mood_id: str, count: int) -> list[Path]:
107
+ """Where generated files wait (inside the library) until they are indexed."""
108
+ import trilhas
109
+
110
+ inbox = lib / trilhas.INBOX_DIR
111
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
112
+ names = [f"{mood_id}_{stamp}.mp3"] if count == 1 else [f"{mood_id}_{stamp}_{i}.mp3" for i in range(1, count + 1)]
113
+ return el.free_paths(inbox, names)
114
+
115
+
116
+ def resolve_mood(text: str, library: bool):
117
+ """(mood dict, library folder) for --humor; the library is only read.
118
+
119
+ With --biblioteca the library must be usable before anything is paid: a bad location, an unreadable index
120
+ or a missing ffmpeg stop here. Without it, an unreadable index only hides the person's own moods."""
121
+ import trilhas
122
+
123
+ try:
124
+ lib = trilhas.check_location()
125
+ try:
126
+ index = trilhas.load_index(lib, required=False)
127
+ except trilhas.TrilhaError:
128
+ if library:
129
+ raise
130
+ index = None
131
+ if library:
132
+ trilhas.need_ffmpeg()
133
+ mood = trilhas.find_mood(index, text)
134
+ except trilhas.TrilhaError as e:
135
+ raise el.ElevenError(str(e), e.code) from None
136
+ if mood is None:
137
+ raise el.ElevenError(trilhas.unknown_mood_message(text, index), 2)
138
+ return mood, lib
139
+
140
+
141
+ def to_library(lib: Path, path: Path, mood: dict, title: str, prompt: str, seconds: float, args,
142
+ from_inbox: bool) -> dict:
143
+ import trilhas
144
+
145
+ extra = {"model": args.model, "instrumental": bool(args.instrumental), "generated_seconds": seconds}
146
+ try:
147
+ return trilhas.add_track(path, mood["id"], title=title, source=SOURCE, prompt=prompt,
148
+ license_note=LIBRARY_NOTE, lib=lib, move_source=from_inbox, extra=extra)
149
+ except Exception as e: # noqa: BLE001 (a paid file: keep it and its note whatever went wrong)
150
+ if from_inbox:
151
+ # trilhas.py adicionar reads this note back (source, prompt, title, usage rule, model)
152
+ try:
153
+ save_json(path.with_suffix(".json"), {"source": SOURCE, "prompt": prompt, "mood": mood["id"],
154
+ "title": title, "license": LIBRARY_NOTE, **extra})
155
+ except OSError:
156
+ pass
157
+ why = e if isinstance(e, trilhas.TrilhaError) else f"{e.__class__.__name__}: {e}"
158
+ retry = trilhas.skill_cmd("trilhas.py", f"adicionar '{path}' --humor {mood['id']}")
159
+ raise el.ElevenError(f"a trilha foi gerada e salva em {path}, mas não entrou na biblioteca ({why}). Para "
160
+ f"pôr na biblioteca sem gerar de novo (a origem e a regra de uso vão junto): {retry}",
161
+ 1) from None
162
+
163
+
164
+ def main(argv: list[str] | None = None) -> int:
165
+ ap = argparse.ArgumentParser(description="Gera uma trilha com IA na conta ElevenLabs da própria pessoa "
166
+ "(opcional, paga). O arquivo vai para assets/ da pasta edit e, com "
167
+ "--biblioteca, para a biblioteca de trilhas por humor.")
168
+ ap.add_argument("prompt", nargs="?", default=None,
169
+ help="descrição da música, de preferência em inglês (com --humor, palavras a mais no pedido pronto)")
170
+ ap.add_argument("--edit-dir", default=None, help="pasta edit da sessão (obrigatória sem --biblioteca)")
171
+ ap.add_argument("--humor", help="usa o pedido pronto de um humor (animada, inspiradora, triste, tensa, calma, "
172
+ "epica, divertida, luxo)")
173
+ ap.add_argument("--biblioteca", action="store_true",
174
+ help="guarda cada trilha gerada na biblioteca pessoal, no humor de --humor")
175
+ ap.add_argument("--titulo", help="nome da trilha na biblioteca (padrão: '<Humor> IA <data>')")
176
+ ap.add_argument("--seconds", type=float, default=None,
177
+ help=f"duração em segundos (padrão: o corte arredondado para cima + {EXTRA_SECONDS} s; com "
178
+ f"--biblioteca, pelo menos {LIBRARY_SECONDS:g} s; de {MIN_SECONDS:g} a {MAX_SECONDS:g})")
179
+ ap.add_argument("--instrumental", action=argparse.BooleanOptionalAction, default=True,
180
+ help="só instrumental, sem voz cantada (padrão: ligado; --no-instrumental desliga)")
181
+ ap.add_argument("--out", help="nome do arquivo em assets/ (padrão: trilha_<n>.mp3)")
182
+ ap.add_argument("--variations", type=int, default=1,
183
+ help=f"quantas versões gerar, uma requisição cada (padrão 1, no máximo {MAX_VARIATIONS})")
184
+ ap.add_argument("--model", default=DEFAULT_MODEL, choices=MODELS,
185
+ help=f"modelo de música (padrão {DEFAULT_MODEL})")
186
+ ap.add_argument("--dry-run", action="store_true",
187
+ help="só mostra o pedido e o custo estimado, sem gerar e sem internet")
188
+ args = ap.parse_args(argv)
189
+
190
+ key = None
191
+ saved: list[str] = []
192
+
193
+ def say(*parts: str, flush: bool = False) -> None:
194
+ print(el.redact(" ".join(parts), key), flush=flush)
195
+
196
+ try:
197
+ extra_words = (args.prompt or "").strip()
198
+ mood = lib = None
199
+ if args.humor is not None:
200
+ if not args.humor.strip():
201
+ raise el.ElevenError("--humor está vazio (ex.: --humor triste)", 2)
202
+ mood, lib = resolve_mood(args.humor, args.biblioteca and not args.dry_run)
203
+ if not mood.get("prompt") and not extra_words:
204
+ raise el.ElevenError(f"o humor '{mood['id']}' é seu e não tem pedido pronto. Escreva o pedido em "
205
+ f"inglês junto (ex.: '{EXAMPLE}').", 2)
206
+ prompt = ", ".join(p for p in (mood.get("prompt") or "", extra_words) if p)
207
+ else:
208
+ prompt = extra_words
209
+ if not prompt:
210
+ raise el.ElevenError("o pedido está vazio", 2)
211
+ if args.biblioteca and mood is None:
212
+ raise el.ElevenError("--biblioteca precisa de --humor (ex.: --humor triste --biblioteca): a trilha entra "
213
+ "na biblioteca dentro de um humor.", 2)
214
+ if not args.edit_dir and not args.biblioteca:
215
+ raise el.ElevenError("falta --edit-dir (a pasta edit do vídeo). Para gerar só para a biblioteca, use "
216
+ "--humor e --biblioteca.", 2)
217
+ if args.out and not args.edit_dir:
218
+ raise el.ElevenError("--out só vale com --edit-dir (na biblioteca o nome é dado pelo humor).", 2)
219
+ el.check_prompt(prompt, EXAMPLE)
220
+ if not 1 <= args.variations <= MAX_VARIATIONS:
221
+ raise el.ElevenError(f"--variations vai de 1 a {MAX_VARIATIONS} (cada uma é uma cobrança)", 2)
222
+ edit = el.edit_folder(args.edit_dir) if args.edit_dir else None
223
+ seconds, origin, notes = pick_seconds(edit, args.seconds, library=args.biblioteca)
224
+ key = el.api_key()
225
+ el.check_no_key(prompt, key)
226
+ if args.titulo is not None:
227
+ el.check_no_key(args.titulo, key)
228
+ if not key and not args.dry_run:
229
+ raise el.ElevenError(el.missing_key_message(
230
+ "Gerar trilha", "com um arquivo de música que a pessoa tem direito de usar, ou sem trilha"), 2)
231
+ paths = output_names(edit, args.out, args.variations) if edit else \
232
+ inbox_names(lib, mood["id"], args.variations)
233
+ payload = {"prompt": prompt, "music_length_ms": int(round(seconds * 1000)),
234
+ "force_instrumental": bool(args.instrumental), "model_id": args.model}
235
+ for note in notes:
236
+ say(f"aviso: {note}")
237
+ if el.looks_portuguese(extra_words if mood else prompt):
238
+ say("aviso: o pedido parece estar em português. A ElevenLabs entende melhor em inglês "
239
+ f"(ex.: '{EXAMPLE}').")
240
+ if not args.instrumental:
241
+ say("aviso: trilha com voz cantada briga com a fala do vídeo. Use só se a pessoa pediu.")
242
+ base_title = " ".join((args.titulo or "").split())[:70] or \
243
+ f"{mood['label'] if mood else 'Trilha'} IA {datetime.now().strftime('%d/%m/%Y')}"
244
+
245
+ each = el.credits_estimate("music", seconds)
246
+ if args.dry_run:
247
+ say(f"trilha: {seconds:g} s ({origin}), {'instrumental' if args.instrumental else 'pode ter voz'}, "
248
+ f"modelo {args.model}")
249
+ if mood:
250
+ how = "pedido pronto + as suas palavras" if extra_words else "pedido pronto"
251
+ say(f"humor: {mood['id']} ({mood['label']}), {how}")
252
+ if edit:
253
+ say("arquivos: " + ", ".join(f"assets/{p.name}" for p in paths))
254
+ if args.biblioteca:
255
+ say(f"biblioteca: {lib} (cada versão entra no humor {mood['id']} com o nome '{base_title}'; a "
256
+ "biblioteca fica fora da skill e é só sua)")
257
+ say(f"custo estimado: cerca de {each} créditos por versão, {each * len(paths)} no total "
258
+ f"(a ElevenLabs cobra cerca de {el.MUSIC_CREDITS_PER_MINUTE} créditos por minuto de música; "
259
+ "o valor certo aparece no painel dela)")
260
+ say(el.PAID_PLAN_LINE)
261
+ if not key:
262
+ say(f"chave: ausente. Antes de gerar, peça a {el.KEY_NAME} "
263
+ "(veja a mensagem que sai sem --dry-run).")
264
+ say("pedido (não enviado):")
265
+ say(el.dry_run_request(el.MUSIC_URL, payload, key))
266
+ return 0
267
+
268
+ entries = []
269
+ for i, path in enumerate(paths, 1):
270
+ say(f"gerando trilha {i}/{len(paths)} ({seconds:g} s) na conta ElevenLabs da pessoa...", flush=True)
271
+ data = el.post_audio(el.MUSIC_URL, payload, key or "", "Music", TIMEOUT)
272
+ title = base_title if len(paths) == 1 else f"{base_title} v{i}"
273
+ if edit:
274
+ rel = f"assets/{path.name}"
275
+ el.store(edit, path, data, el.new_credit(rel, SOURCE, prompt, seconds,
276
+ instrumental=bool(args.instrumental), model=args.model))
277
+ saved.append(rel)
278
+ say(f"ok: {rel} {seconds:g} s {'instrumental' if args.instrumental else 'com voz'} "
279
+ f"{len(data) // 1024} KB", flush=True)
280
+ else:
281
+ try:
282
+ el.save_audio(path, data)
283
+ except OSError as e:
284
+ raise el.ElevenError(f"a ElevenLabs gerou o áudio, mas não consegui salvar em {path} "
285
+ f"({e.__class__.__name__}). Confira o espaço e a permissão da pasta da "
286
+ "biblioteca antes de gerar de novo (a geração já foi cobrada).") from None
287
+ saved.append(str(path))
288
+ if args.biblioteca:
289
+ entry = to_library(lib, path, mood, title, prompt, seconds, args, from_inbox=edit is None)
290
+ entries.append(entry)
291
+ if not edit:
292
+ saved[-1] = f"{entry['id']} na biblioteca"
293
+ say(f"na biblioteca: {entry['id']} \"{entry['title']}\" (humor {entry['mood']}) · "
294
+ f"{entry['energy']} · {round(entry['voice_band_share'] * 100)}% da energia na faixa da voz",
295
+ flush=True)
296
+ if edit:
297
+ say(f"créditos anotados em assets/credits.json ({el.COMMERCIAL_NOTE})")
298
+ if entries:
299
+ say(f"biblioteca: {lib} (origem, pedido e regra de uso anotados no índice)")
300
+ if edit:
301
+ say(f'no edit_data.json: "music": {{"enabled": true, "file": "{saved[0]}", {MUSIC_LINE}')
302
+ else:
303
+ say(f'no edit_data.json: "music": {{"enabled": true, "mood": "{mood["id"]}", {MUSIC_LINE}')
304
+ return 0
305
+ except Exception as e: # noqa: BLE001 (never a traceback: its text could carry the key)
306
+ if not isinstance(e, el.ElevenError):
307
+ e = el.unexpected(e)
308
+ if saved:
309
+ e = el.ElevenError(f"{e} Já ficaram salvas: {', '.join(saved)}.", e.code)
310
+ el.print_error(e, key)
311
+ return e.code
312
+
313
+
314
+ if __name__ == "__main__":
315
+ el.utf8_console()
316
+ sys.exit(main())