@danhachuel/thunderbolt 0.3.41 → 0.3.43

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.
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import os
11
11
  from datetime import datetime, timezone
12
- from typing import Any
12
+ from typing import Any, Mapping
13
13
  from urllib.parse import quote, urlsplit, urlunsplit
14
14
 
15
15
  import requests
@@ -110,6 +110,38 @@ def test_nano_banana_credentials(api_key: str, model: str) -> dict[str, Any]:
110
110
  )
111
111
 
112
112
 
113
+ def test_media_provider_card(card: Mapping[str, Any]) -> dict[str, Any]:
114
+ """Run a bounded, non-generative check for an image/video provider card."""
115
+ source = dict(card) if isinstance(card, Mapping) else {}
116
+ provider = str(source.get("provider") or "").strip().lower()
117
+ api_key = str(source.get("api_key") or "").strip()
118
+ model = str(source.get("model") or "").strip()
119
+ base_url = str(source.get("base_url") or "").strip().rstrip("/")
120
+ api_style = str(source.get("api_style") or "").strip().lower()
121
+ if provider == "nano_banana":
122
+ return test_nano_banana_credentials(api_key, model)
123
+ if not base_url:
124
+ return _result("missing", "Complete a Base URL antes de testar.")
125
+ if provider not in {"inferenceport", "ollama", "lmstudio"} and not api_key:
126
+ return _missing("Introduza a API key/token antes de testar este provider.")
127
+ if not model and provider not in {"inferenceport", "cloudflare_workers_ai"}:
128
+ return _result("missing", "Complete o modelo antes de testar.")
129
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
130
+ if provider == "cloudflare_workers_ai":
131
+ account_id = str(source.get("account_id") or "").strip()
132
+ if not account_id:
133
+ return _result("missing", "Complete o Account ID do Cloudflare antes de testar.")
134
+ endpoint = f"{base_url}/accounts/{quote(account_id, safe='')}/ai/models/search"
135
+ return _get(endpoint, headers=headers, params={"search": model or "stable-diffusion"})
136
+ if api_style in {"openai_compatible", "huggingface", "agnes", "kie"} or provider in {"pollinations", "huggingface", "agnes", "kie_ai", "inferenceport"}:
137
+ endpoint = _models_endpoint(base_url)
138
+ elif provider == "fal_ai":
139
+ endpoint = f"{base_url}/models" if base_url.endswith("/v1") else base_url
140
+ else:
141
+ endpoint = base_url
142
+ return _get(endpoint, headers=headers)
143
+
144
+
113
145
  def test_azure_speech_credentials(api_key: str, region: str) -> dict[str, Any]:
114
146
  """Validate Azure Speech by listing voices; this does not synthesize audio."""
115
147
  api_key = str(api_key or "").strip()
@@ -3,12 +3,13 @@ from __future__ import annotations
3
3
  import json
4
4
  import re
5
5
  from functools import lru_cache
6
- from pathlib import Path
7
- from typing import Any
8
6
 
9
7
  import requests
8
+ from pathlib import Path
9
+ from typing import Any
10
10
 
11
11
  from .llm_providers import active_llm_card, provider_definition
12
+ from .provider_routing import ProviderRoutingError, route_llm_json
12
13
 
13
14
 
14
15
  ROOT = Path(__file__).resolve().parents[1]
@@ -86,31 +87,22 @@ def _json_content(payload: dict[str, Any]) -> dict[str, Any]:
86
87
 
87
88
 
88
89
  def _chat_json(settings: dict[str, Any], system_prompt: str, user_prompt: str) -> dict[str, Any]:
89
- _provider, api_key, base_url, model = _provider_config(settings)
90
- headers = {"Content-Type": "application/json"}
91
- if api_key:
92
- headers["Authorization"] = f"Bearer {api_key}"
93
- body = {
94
- "model": model,
95
- "messages": [
96
- {"role": "system", "content": system_prompt},
97
- {"role": "user", "content": user_prompt},
98
- ],
99
- "response_format": {"type": "json_object"},
100
- }
101
- endpoint = f"{base_url}/chat/completions"
102
- try:
103
- response = requests.post(endpoint, headers=headers, json=body, timeout=120)
104
- except requests.RequestException as exc:
105
- raise CreativeGenerationError(f"Não foi possível contactar o provider LLM: {exc}") from exc
106
- if response.status_code >= 400:
107
- detail = response.text[:500].replace(api_key, "[REDACTED]") if api_key else response.text[:500]
108
- raise CreativeGenerationError(f"O provider LLM devolveu HTTP {response.status_code}: {detail}")
90
+ # Keep the actionable legacy validation for an entirely unconfigured setup;
91
+ # the router still skips incomplete cards when a configured fallback exists.
92
+ cards = settings.get("llm_provider_cards") if isinstance(settings, dict) else None
93
+ if not isinstance(cards, list) or not any(
94
+ isinstance(card, dict)
95
+ and bool(card.get("enabled", True))
96
+ and str(card.get("api_key") or "").strip()
97
+ and str(card.get("model") or card.get("model_name") or "").strip()
98
+ for card in cards
99
+ ):
100
+ _provider_config(settings)
109
101
  try:
110
- payload = response.json()
111
- except ValueError as exc:
112
- raise CreativeGenerationError("O provider LLM devolveu uma resposta que não é JSON.") from exc
113
- return _json_content(payload)
102
+ routed = route_llm_json(settings, system_prompt, user_prompt)
103
+ except ProviderRoutingError as exc:
104
+ raise CreativeGenerationError(str(exc)) from exc
105
+ return _json_content(routed.payload)
114
106
 
115
107
 
116
108
  def channel_context(channel: dict[str, Any], blueprint: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -135,7 +135,16 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
135
135
  topic = f"Vídeo para {channel.get('name', 'Canal')}"
136
136
  title = str(payload.get("title") or topic).strip()
137
137
  artifacts = dict(payload.get("artifacts") or {})
138
- thumbnail_path = str(payload.get("thumbnail_path") or "").strip()
138
+ thumbnail_variants = payload.get("thumbnail_variants") if isinstance(payload.get("thumbnail_variants"), list) else []
139
+ thumbnail_variant = payload.get("thumbnail_variant") if isinstance(payload.get("thumbnail_variant"), dict) else {}
140
+ if count > 1 and thumbnail_variants:
141
+ candidate = thumbnail_variants[index % len(thumbnail_variants)]
142
+ if isinstance(candidate, dict):
143
+ thumbnail_variant = candidate
144
+ thumbnail_path = str(thumbnail_variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
145
+ thumbnail_prompt = str(thumbnail_variant.get("image_prompt") or payload.get("thumbnail_prompt") or "").strip()
146
+ thumbnail_text = str(thumbnail_variant.get("overlay_text") or payload.get("thumbnail_text") or "").strip()
147
+ thumbnail_status = str(payload.get("thumbnail_status") or ("generated" if thumbnail_path else "not_generated"))
139
148
  if thumbnail_path:
140
149
  artifacts.setdefault("thumbnail", thumbnail_path)
141
150
  task = {
@@ -161,11 +170,11 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
161
170
  "voice": payload.get("voice") or channel.get("default_voice") or channel.get("voice", ""),
162
171
  "automation_on": bool(channel.get("automation_on", False)),
163
172
  "automation_time": channel.get("automation_time", "00:00"),
164
- "thumbnail_variant": payload.get("thumbnail_variant", {}),
165
- "thumbnail_variants": payload.get("thumbnail_variants", []),
166
- "thumbnail_prompt": payload.get("thumbnail_prompt", ""),
167
- "thumbnail_text": payload.get("thumbnail_text", ""),
168
- "thumbnail_status": payload.get("thumbnail_status", "not_generated"),
173
+ "thumbnail_variant": thumbnail_variant,
174
+ "thumbnail_variants": thumbnail_variants,
175
+ "thumbnail_prompt": thumbnail_prompt,
176
+ "thumbnail_text": thumbnail_text,
177
+ "thumbnail_status": thumbnail_status,
169
178
  "title_candidates": payload.get("title_candidates", []),
170
179
  "ai_generation": payload.get("ai_generation", {}),
171
180
  "stage": "script",
@@ -235,8 +235,14 @@ _API_KEY_EXPANDER_TRANSLATIONS: dict[str, dict[str, str]] = {
235
235
  for _language_code, _api_key_values in _API_KEY_EXPANDER_TRANSLATIONS.items():
236
236
  UI_TRANSLATIONS[_language_code].update(_api_key_values)
237
237
 
238
+ # This expander is intentionally kept as a stable product label while the
239
+ # provider cards themselves expose their capability labels in the current UI.
240
+ for _language_code in UI_TRANSLATIONS:
241
+ UI_TRANSLATIONS[_language_code].setdefault("Imagem e Video", "Imagem e Video")
238
242
 
239
- # Labels introduced by the reorganised sidebar. They are merged into the
243
+
244
+ # Labels introduced by the reorganised sidebar.
245
+ # They are merged into the
240
246
  # existing UI translation index so every navigation control uses the same
241
247
  # translation path as the legacy pages.
242
248
  UI_NAV_TRANSLATIONS: dict[str, dict[str, str]] = {
@@ -968,7 +974,7 @@ _CONTENT_TRANSLATION_ROWS = (
968
974
  ("Niche Finder — execução remota no Kaggle", "Niche Finder — execução remota no Kaggle", "Niche Finder — remote execution on Kaggle", "Niche Finder — Kaggle 远程执行", "Niche Finder — Remoteausführung auf Kaggle", "Niche Finder — chạy từ xa trên Kaggle", "Niche Finder — Kaggle'da uzaktan çalıştırma", "Niche Finder — удалённый запуск на Kaggle", "Niche Finder — ejecución remota en Kaggle", "Niche Finder — eksekusi jarak jauh di Kaggle", "Niche Finder — esecuzione remota su Kaggle"),
969
975
  ("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.", "O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.", "The dataset remains on Kaggle. Thunderbolt uses these credentials only to publish/run the kernel and retrieve small analysis results.", "数据集保留在 Kaggle。Thunderbolt 仅使用这些凭证发布/运行 kernel 并获取小型分析结果。", "Der Datensatz bleibt auf Kaggle. Thunderbolt verwendet diese Zugangsdaten nur zum Veröffentlichen/Ausführen des Kernels und zum Abrufen kleiner Analyseergebnisse.", "Dataset vẫn ở Kaggle. Thunderbolt chỉ dùng thông tin xác thực để xuất bản/chạy kernel và lấy kết quả phân tích nhỏ.", "Veri kümesi Kaggle'da kalır. Thunderbolt bu kimlik bilgilerini yalnızca kernel'ı yayınlamak/çalıştırmak ve küçük analiz sonuçlarını almak için kullanır.", "Датасет остаётся на Kaggle. Thunderbolt использует эти данные только для публикации/запуска kernel и получения небольших результатов анализа.", "El dataset permanece en Kaggle. Thunderbolt usa estas credenciales solo para publicar/ejecutar el kernel y obtener pequeños resultados del análisis.", "Dataset tetap di Kaggle. Thunderbolt menggunakan kredensial ini hanya untuk menerbitkan/menjalankan kernel dan mengambil hasil analisis kecil.", "Il dataset resta su Kaggle. Thunderbolt usa queste credenziali solo per pubblicare/eseguire il kernel e ottenere piccoli risultati dell'analisi."),
970
976
  ("Niche Finder — execução através da Apify", "Niche Finder — execução através da Apify", "Niche Finder — execution through Apify", "Niche Finder — 通过 Apify 执行", "Niche Finder — Ausführung über Apify", "Niche Finder — chạy qua Apify", "Niche Finder — Apify üzerinden çalıştırma", "Niche Finder — запуск через Apify", "Niche Finder — ejecución mediante Apify", "Niche Finder — eksekusi melalui Apify", "Niche Finder — esecuzione tramite Apify"),
971
- ("Nano Banana — geração de thumbnails", "Nano Banana — geração de thumbnails", "Nano Banana — thumbnail generation", "Nano Banana — 缩略图生成", "Nano Banana — Thumbnail-Generierung", "Nano Banana tạo thumbnail", "Nano Banana — küçük resim oluşturma", "Nano Banana — создание миниатюр", "Nano Banana — generación de miniaturas", "Nano Banana — pembuatan thumbnail", "Nano Banana — generazione miniature"),
977
+ ("Imagem e Video", "Imagem e Video", "Image and Video", "图像和视频", "Bild und Video", "Hình ảnh video", "Görüntü ve Video", "Изображение и видео", "Imagen y vídeo", "Gambar dan Video", "Immagine e Video"),
972
978
  ("A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.", "A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.", "Nano Banana generates the final thumbnail image from the selected variant. The key is stored locally and is separate from the Gemini key used as a text LLM.", "Nano Banana 根据所选变体生成最终缩略图。密钥仅保存在本地存储中,与用于文本 LLM 的 Gemini 密钥分开。", "Nano Banana erzeugt das endgültige Thumbnail aus der ausgewählten Variante. Der Schlüssel wird nur lokal gespeichert und ist vom Gemini-Schlüssel für das Text-LLM getrennt.", "Nano Banana tạo ảnh thumbnail cuối từ biến thể đã chọn. Key chỉ lưu cục bộ và tách biệt với key Gemini dùng cho LLM văn bản.", "Nano Banana, seçilen varyanttan son küçük resim görselini oluşturur. Anahtar yalnızca yerel depolamada tutulur ve metin LLM'i için kullanılan Gemini anahtarından ayrıdır.", "Nano Banana создаёт финальное изображение миниатюры из выбранного варианта. Ключ хранится локально отдельно от ключа Gemini для текстовой LLM.", "Nano Banana genera la miniatura final a partir de la variante elegida. La clave se guarda localmente y es distinta de la clave de Gemini del LLM de texto.", "Nano Banana menghasilkan gambar thumbnail akhir dari varian yang dipilih. Kunci disimpan secara lokal dan terpisah dari kunci Gemini untuk LLM teks.", "Nano Banana genera l'immagine finale della miniatura dalla variante scelta. La chiave è salvata localmente ed è separata da quella Gemini usata per l'LLM testuale."),
973
979
  ("LLM — providers e modelos", "LLM — providers e modelos", "LLM — providers and models", "LLM — 服务商和模型", "LLM — Anbieter und Modelle", "LLM — provider và mô hình", "LLM — sağlayıcılar ve modeller", "LLM — провайдеры и модели", "LLM — proveedores y modelos", "LLM — penyedia dan model", "LLM — provider e modelli"),
974
980
  ("OpenAI/ NVIDIA NIM — API key, Base URL e modelo", "OpenAI/ NVIDIA NIM — API key, Base URL e modelo", "OpenAI/NVIDIA NIM — API key, Base URL and model", "OpenAI/NVIDIA NIM — API 密钥、基础 URL 和模型", "OpenAI/NVIDIA NIM — API-Schlüssel, Basis-URL und Modell", "OpenAI/NVIDIA NIM — API key, Base URL và mô hình", "OpenAI/NVIDIA NIM — API anahtarı, Temel URL ve model", "OpenAI/NVIDIA NIM — API-ключ, базовый URL и модель", "OpenAI/NVIDIA NIM — clave API, URL base y modelo", "OpenAI/NVIDIA NIM — kunci API, URL Dasar, dan model", "OpenAI/NVIDIA NIM — API key, URL di base e modello"),
@@ -397,6 +397,10 @@ def test_llm_provider_card(card: Mapping[str, Any]) -> dict[str, Any]:
397
397
  }
398
398
 
399
399
 
400
+ # This callable is a production diagnostic helper, not a pytest test function.
401
+ test_llm_provider_card.__test__ = False
402
+
403
+
400
404
  def stamp_test_result(result: Mapping[str, Any]) -> dict[str, Any]:
401
405
  status = "success" if result.get("ok") else "error"
402
406
  return {
@@ -0,0 +1,383 @@
1
+ """Provider adapters for the independent image and video pools.
2
+
3
+ Adapters are intentionally small and response-shape tolerant: providers can return
4
+ base64, data URLs, direct URLs, or asynchronous task identifiers. The router owns
5
+ failover; this module owns provider-specific HTTP contracts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import binascii
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, Mapping
15
+ from urllib.parse import urljoin
16
+
17
+ import requests
18
+
19
+ from .media_providers import media_cards_for_pool, media_provider_definition
20
+ from .provider_routing import (
21
+ POOL_IMAGE,
22
+ POOL_VIDEO,
23
+ ProviderCallError,
24
+ ProviderRoutingError,
25
+ route_json_request,
26
+ )
27
+ from .storage import STORAGE, ensure_storage
28
+ from .thumbnail_generation import generate_thumbnail_image
29
+
30
+
31
+ class MediaGenerationError(RuntimeError):
32
+ """Raised when an image/video adapter cannot produce a usable artifact."""
33
+
34
+
35
+ def _api_key(card: Mapping[str, Any]) -> str:
36
+ return str(card.get("api_key") or "").strip()
37
+
38
+
39
+ def _model(card: Mapping[str, Any]) -> str:
40
+ return str(card.get("model") or "").strip()
41
+
42
+
43
+ def _base_url(card: Mapping[str, Any]) -> str:
44
+ return str(card.get("base_url") or "").strip().rstrip("/")
45
+
46
+
47
+ def _headers(card: Mapping[str, Any], *, fal: bool = False) -> dict[str, str]:
48
+ key = _api_key(card)
49
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
50
+ if key:
51
+ headers["Authorization"] = f"Key {key}" if fal else f"Bearer {key}"
52
+ return headers
53
+
54
+
55
+ def _decode_data(value: Any) -> bytes | None:
56
+ if not isinstance(value, str) or not value.strip():
57
+ return None
58
+ raw = value.strip()
59
+ if raw.startswith("data:") and "," in raw:
60
+ raw = raw.split(",", 1)[1]
61
+ try:
62
+ return base64.b64decode(raw, validate=True)
63
+ except (binascii.Error, ValueError):
64
+ return None
65
+
66
+
67
+ def _image_value(payload: Any) -> tuple[bytes | None, str]:
68
+ if isinstance(payload, (bytes, bytearray)):
69
+ return bytes(payload), ""
70
+ if not isinstance(payload, Mapping):
71
+ return None, ""
72
+ direct = payload.get("image") or payload.get("output_image")
73
+ if isinstance(direct, Mapping):
74
+ direct = direct.get("data") or direct.get("url")
75
+ data = _decode_data(direct)
76
+ if data:
77
+ return data, ""
78
+ if isinstance(direct, str) and direct.startswith(("http://", "https://")):
79
+ return None, direct
80
+ entries = payload.get("data") or payload.get("outputs") or payload.get("images")
81
+ if isinstance(entries, list) and entries:
82
+ first = entries[0]
83
+ if isinstance(first, Mapping):
84
+ data = _decode_data(first.get("b64_json") or first.get("base64") or first.get("data"))
85
+ if data:
86
+ return data, ""
87
+ url = str(first.get("url") or first.get("image_url") or "").strip()
88
+ if url:
89
+ return None, url
90
+ result = payload.get("result")
91
+ if isinstance(result, Mapping):
92
+ data = _decode_data(result.get("image") or result.get("b64_json") or result.get("data"))
93
+ if data:
94
+ return data, ""
95
+ url = str(result.get("url") or result.get("image_url") or "").strip()
96
+ if url:
97
+ return None, url
98
+ return None, ""
99
+
100
+
101
+ def _download_or_write(image_bytes: bytes | None, url: str, destination: Path, card: Mapping[str, Any]) -> Path:
102
+ if image_bytes is None and url:
103
+ try:
104
+ response = requests.get(url, headers={"Authorization": f"Bearer {_api_key(card)}"} if _api_key(card) else {}, timeout=180)
105
+ response.raise_for_status()
106
+ image_bytes = response.content
107
+ except requests.RequestException as exc:
108
+ raise MediaGenerationError(f"Não foi possível descarregar a imagem devolvida pelo provider: {exc}") from exc
109
+ if not image_bytes:
110
+ raise MediaGenerationError("O provider concluiu a chamada mas não devolveu uma imagem utilizável.")
111
+ destination.parent.mkdir(parents=True, exist_ok=True)
112
+ destination.write_bytes(image_bytes)
113
+ return destination
114
+
115
+
116
+ def _image_endpoint(card: Mapping[str, Any]) -> str:
117
+ definition = media_provider_definition(card.get("provider"))
118
+ style = str(card.get("api_style") or definition.api_style)
119
+ base = _base_url(card)
120
+ explicit = str(card.get("image_endpoint") or "").strip()
121
+ if explicit:
122
+ return explicit
123
+ if style in {"openai_compatible", "huggingface", "agnes", "kie"}:
124
+ return f"{base}/images/generations"
125
+ if style == "cloudflare":
126
+ account_id = str(card.get("account_id") or "").strip()
127
+ if not account_id:
128
+ raise MediaGenerationError("Cloudflare Workers AI requer Account ID no cartão de media.")
129
+ model = _model(card) or "@cf/stabilityai/stable-diffusion-xl-base-1.0"
130
+ model = model if model.startswith("@") else f"@{model}"
131
+ return f"{base}/accounts/{account_id}/ai/run/{model}"
132
+ if style == "fal_queue":
133
+ if not _model(card):
134
+ raise MediaGenerationError("FAL AI requer o identificador da rota/modelo para gerar imagem.")
135
+ return f"{base}/{_model(card).lstrip('/')}"
136
+ if style == "dashscope":
137
+ return f"{base}/services/aigc/text2image/image-synthesis"
138
+ raise MediaGenerationError(f"O provider {card.get('provider')} não tem endpoint de imagem configurado.")
139
+
140
+
141
+ def _image_request(card: dict[str, Any], prompt: str) -> Any:
142
+ provider = str(card.get("provider") or "").strip().lower()
143
+ style = str(card.get("api_style") or media_provider_definition(provider).api_style)
144
+ endpoint = _image_endpoint(card)
145
+ if style == "cloudflare":
146
+ return requests.post(endpoint, headers=_headers(card), json={"prompt": prompt}, timeout=180)
147
+ if style == "fal_queue":
148
+ return requests.post(endpoint, headers=_headers(card, fal=True), json={"prompt": prompt, "num_images": 1}, timeout=180)
149
+ if style == "dashscope":
150
+ body = {"model": _model(card), "input": {"prompt": prompt}, "parameters": {"size": "1024*1024", "n": 1}}
151
+ return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
152
+ body = {"model": _model(card), "prompt": prompt, "n": 1, "response_format": "b64_json"}
153
+ return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
154
+
155
+
156
+ def generate_image_for_card(
157
+ settings: Mapping[str, Any],
158
+ card: Mapping[str, Any],
159
+ prompt: str,
160
+ *,
161
+ topic: str = "",
162
+ variant_index: int = 0,
163
+ lettering_text: str = "",
164
+ lettering_prompt: str = "",
165
+ reference_image: Path | None = None,
166
+ ) -> Path:
167
+ """Generate one image with the selected media card."""
168
+ card = dict(card)
169
+ provider = str(card.get("provider") or "").strip().lower()
170
+ if provider == "nano_banana":
171
+ merged = dict(settings)
172
+ merged["gemini_image_api_key"] = _api_key(card)
173
+ merged["gemini_image_model"] = _model(card) or merged.get("gemini_image_model") or "gemini-3.1-flash-image"
174
+ try:
175
+ return generate_thumbnail_image(
176
+ merged,
177
+ prompt,
178
+ topic=topic,
179
+ variant_index=variant_index,
180
+ lettering_text=lettering_text,
181
+ lettering_prompt=lettering_prompt,
182
+ reference_image=reference_image,
183
+ )
184
+ except Exception as exc:
185
+ raise MediaGenerationError(str(exc)) from exc
186
+
187
+ ensure_storage()
188
+ destination = STORAGE / "thumbnails" / f"media-{provider}-{abs(hash((topic, prompt, variant_index))) & 0xffffffffffffffff:x}.jpg"
189
+
190
+ def request(current: dict[str, Any]) -> Any:
191
+ return _image_request(current, prompt)
192
+
193
+ try:
194
+ routed = route_json_request(settings, pool=POOL_IMAGE, cards=[card], request=request)
195
+ except ProviderRoutingError as exc:
196
+ raise MediaGenerationError(str(exc)) from exc
197
+ image_bytes, url = _image_value(routed.payload)
198
+ return _download_or_write(image_bytes, url, destination, routed.card)
199
+
200
+
201
+ def _is_retryable_media_error(exc: BaseException) -> bool:
202
+ text = str(exc).lower()
203
+ if any(marker in text for marker in ("http 400", "http 401", "http 403", "http 404", "invalid request", "missing", "não tem endpoint")):
204
+ return False
205
+ return any(marker in text for marker in ("http 408", "http 425", "http 429", "http 500", "http 502", "http 503", "http 504", "timeout", "timed out", "connection", "temporarily", "cooldown"))
206
+
207
+
208
+ def generate_image_from_pool(
209
+ settings: Mapping[str, Any],
210
+ prompt: str,
211
+ *,
212
+ topic: str = "",
213
+ variant_index: int = 0,
214
+ lettering_text: str = "",
215
+ lettering_prompt: str = "",
216
+ reference_image: Path | None = None,
217
+ ) -> Path:
218
+ """Try eligible image cards in priority order, without cross-pool fallback."""
219
+ cards = media_cards_for_pool(settings, "image")
220
+ if not cards:
221
+ raise MediaGenerationError("Não existem providers activos no pool de imagem.")
222
+ errors: list[str] = []
223
+ for card in cards:
224
+ try:
225
+ return generate_image_for_card(
226
+ settings,
227
+ card,
228
+ prompt,
229
+ topic=topic,
230
+ variant_index=variant_index,
231
+ lettering_text=lettering_text,
232
+ lettering_prompt=lettering_prompt,
233
+ reference_image=reference_image,
234
+ )
235
+ except MediaGenerationError as exc:
236
+ errors.append(f"{card.get('provider')}: {str(exc)[:180]}")
237
+ if not _is_retryable_media_error(exc):
238
+ raise
239
+ raise MediaGenerationError("Todos os providers do pool de imagem falharam: " + " | ".join(errors))
240
+
241
+
242
+ def _video_endpoint(card: Mapping[str, Any]) -> str:
243
+ explicit = str(card.get("video_endpoint") or "").strip()
244
+ if explicit:
245
+ return explicit
246
+ definition = media_provider_definition(card.get("provider"))
247
+ style = str(card.get("api_style") or definition.api_style)
248
+ base = _base_url(card)
249
+ if style == "fal_queue":
250
+ if not _model(card):
251
+ raise MediaGenerationError("FAL AI requer o identificador da rota/modelo para gerar vídeo.")
252
+ return f"{base}/{_model(card).lstrip('/')}"
253
+ if style in {"openai_compatible", "agnes", "kie"}:
254
+ return f"{base}/videos/generations"
255
+ if style == "dashscope":
256
+ return f"{base}/services/aigc/video-generation/video-synthesis"
257
+ raise MediaGenerationError(f"O provider {card.get('provider')} não tem endpoint de vídeo configurado.")
258
+
259
+
260
+ def _video_request(card: dict[str, Any], prompt: str, image_url: str = "") -> Any:
261
+ style = str(card.get("api_style") or media_provider_definition(card.get("provider")).api_style)
262
+ endpoint = _video_endpoint(card)
263
+ body: dict[str, Any] = {"model": _model(card), "prompt": prompt}
264
+ if image_url:
265
+ body["image_url"] = image_url
266
+ if style == "fal_queue":
267
+ body.pop("model", None)
268
+ return requests.post(endpoint, headers=_headers(card, fal=True), json=body, timeout=180)
269
+ return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
270
+
271
+
272
+ def _video_result(payload: Mapping[str, Any]) -> tuple[str, str]:
273
+ direct = str(payload.get("video_url") or payload.get("url") or payload.get("output") or "").strip()
274
+ if direct:
275
+ return direct, ""
276
+ result = payload.get("result")
277
+ if isinstance(result, Mapping):
278
+ direct = str(result.get("video_url") or result.get("url") or result.get("output") or "").strip()
279
+ if direct:
280
+ return direct, ""
281
+ for key in ("request_id", "id", "task_id", "job_id"):
282
+ value = str(payload.get(key) or "").strip()
283
+ if value:
284
+ return "", value
285
+ data = payload.get("data")
286
+ if isinstance(data, list) and data and isinstance(data[0], Mapping):
287
+ first = data[0]
288
+ direct = str(first.get("url") or first.get("video_url") or "").strip()
289
+ if direct:
290
+ return direct, ""
291
+ return "", ""
292
+
293
+
294
+ def _poll_video(card: Mapping[str, Any], request_id: str, *, attempts: int = 24, interval_seconds: float = 5.0) -> str:
295
+ definition = media_provider_definition(card.get("provider"))
296
+ style = str(card.get("api_style") or definition.api_style)
297
+ explicit_status = str(card.get("status_endpoint") or "").strip()
298
+ base = _base_url(card)
299
+ if explicit_status:
300
+ endpoint = explicit_status.replace("{id}", request_id)
301
+ elif style == "fal_queue":
302
+ endpoint = f"{base}/requests/{request_id}/status"
303
+ else:
304
+ endpoint = f"{base}/videos/{request_id}"
305
+ headers = _headers(card, fal=style == "fal_queue")
306
+ for index in range(max(1, attempts)):
307
+ try:
308
+ response = requests.get(endpoint, headers=headers, timeout=60)
309
+ if response.status_code >= 400:
310
+ category = "quota" if response.status_code == 429 else "transient" if response.status_code >= 500 else "endpoint_or_model"
311
+ raise ProviderCallError(f"Consulta de vídeo devolveu HTTP {response.status_code}.", status_code=response.status_code, category=category, retryable=category in {"quota", "transient"})
312
+ payload = response.json()
313
+ except requests.RequestException as exc:
314
+ raise ProviderCallError(f"Falha ao consultar o vídeo: {str(exc)[:180]}", category="transient", retryable=True) from exc
315
+ url, _ = _video_result(payload if isinstance(payload, Mapping) else {})
316
+ if url:
317
+ return url
318
+ status = str((payload or {}).get("status") or "").lower() if isinstance(payload, Mapping) else ""
319
+ if status in {"failed", "error", "cancelled"}:
320
+ raise ProviderCallError("O provider marcou a tarefa de vídeo como falhada.", category="provider", retryable=False)
321
+ if index + 1 < attempts:
322
+ time.sleep(max(0.2, interval_seconds))
323
+ raise ProviderCallError("O provider de vídeo não concluiu dentro do limite de polling.", category="transient", retryable=True)
324
+
325
+
326
+ def generate_video_for_card(
327
+ settings: Mapping[str, Any],
328
+ card: Mapping[str, Any],
329
+ prompt: str,
330
+ *,
331
+ image_url: str = "",
332
+ output_path: Path | None = None,
333
+ ) -> Path:
334
+ """Submit and resolve one video generation request."""
335
+ card = dict(card)
336
+ provider = str(card.get("provider") or "").strip().lower()
337
+
338
+ def request(current: dict[str, Any]) -> Any:
339
+ return _video_request(current, prompt, image_url=image_url)
340
+
341
+ try:
342
+ routed = route_json_request(settings, pool=POOL_VIDEO, cards=[card], request=request)
343
+ except ProviderRoutingError as exc:
344
+ raise MediaGenerationError(str(exc)) from exc
345
+ url, request_id = _video_result(routed.payload)
346
+ if not url and request_id:
347
+ try:
348
+ url = _poll_video(routed.card, request_id)
349
+ except ProviderCallError as exc:
350
+ raise MediaGenerationError(str(exc)) from exc
351
+ if not url:
352
+ raise MediaGenerationError(f"O provider {provider} não devolveu URL nem identificador de vídeo.")
353
+ destination = output_path or (STORAGE / "videos" / f"media-{provider}-{abs(hash((prompt, url))) & 0xffffffffffffffff:x}.mp4")
354
+ try:
355
+ response = requests.get(url, headers={"Authorization": f"Bearer {_api_key(routed.card)}"} if _api_key(routed.card) else {}, timeout=300)
356
+ response.raise_for_status()
357
+ except requests.RequestException as exc:
358
+ raise MediaGenerationError(f"Não foi possível descarregar o vídeo gerado: {exc}") from exc
359
+ destination.parent.mkdir(parents=True, exist_ok=True)
360
+ destination.write_bytes(response.content)
361
+ return destination
362
+
363
+
364
+ def generate_video_from_pool(
365
+ settings: Mapping[str, Any],
366
+ prompt: str,
367
+ *,
368
+ image_url: str = "",
369
+ output_path: Path | None = None,
370
+ ) -> Path:
371
+ """Try eligible video cards in priority order, keeping image and video pools separate."""
372
+ cards = media_cards_for_pool(settings, "video")
373
+ if not cards:
374
+ raise MediaGenerationError("Não existem providers activos no pool de vídeo.")
375
+ errors: list[str] = []
376
+ for card in cards:
377
+ try:
378
+ return generate_video_for_card(settings, card, prompt, image_url=image_url, output_path=output_path)
379
+ except MediaGenerationError as exc:
380
+ errors.append(f"{card.get('provider')}: {str(exc)[:180]}")
381
+ if not _is_retryable_media_error(exc):
382
+ raise
383
+ raise MediaGenerationError("Todos os providers do pool de vídeo falharam: " + " | ".join(errors))