@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.
- package/MANUAL-INSTALACAO.md +47 -12
- package/README.md +21 -9
- package/app/main.py +397 -193
- package/hermes_ui/api_key_tests.py +33 -1
- package/hermes_ui/creative_generation.py +18 -26
- package/hermes_ui/domain.py +15 -6
- package/hermes_ui/languages.py +8 -2
- package/hermes_ui/llm_providers.py +4 -0
- package/hermes_ui/media_generation.py +383 -0
- package/hermes_ui/media_providers.py +282 -0
- package/hermes_ui/pipeline_worker.py +64 -4
- package/hermes_ui/provider_routing.py +457 -0
- package/hermes_ui/storage.py +32 -8
- package/hermes_ui/thumbnail_generation.py +0 -1
- package/hermes_ui/thumbnails.py +42 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
if
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
111
|
-
except
|
|
112
|
-
raise CreativeGenerationError(
|
|
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]:
|
package/hermes_ui/domain.py
CHANGED
|
@@ -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
|
-
|
|
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":
|
|
165
|
-
"thumbnail_variants":
|
|
166
|
-
"thumbnail_prompt":
|
|
167
|
-
"thumbnail_text":
|
|
168
|
-
"thumbnail_status":
|
|
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",
|
package/hermes_ui/languages.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
("
|
|
977
|
+
("Imagem e Video", "Imagem e Video", "Image and Video", "图像和视频", "Bild und Video", "Hình ảnh và 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))
|