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,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())
|