@danhachuel/thunderbolt 0.3.42 → 0.3.44
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 +42 -7
- package/README.md +17 -9
- package/app/main.py +159 -27
- package/hermes_ui/api_key_tests.py +33 -1
- package/hermes_ui/creative_generation.py +18 -26
- 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 +42 -2
- 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
|
@@ -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))
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Configuration schema for independent image and video provider pools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
MEDIA_CARDS_KEY = "media_provider_cards"
|
|
10
|
+
MEDIA_IMAGE_ACTIVE_CARD_KEY = "media_image_active_card_id"
|
|
11
|
+
MEDIA_VIDEO_ACTIVE_CARD_KEY = "media_video_active_card_id"
|
|
12
|
+
MEDIA_IMAGE_ACTIVE_PROVIDER_KEY = "media_image_provider"
|
|
13
|
+
MEDIA_VIDEO_ACTIVE_PROVIDER_KEY = "media_video_provider"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class MediaProviderDefinition:
|
|
18
|
+
code: str
|
|
19
|
+
label: str
|
|
20
|
+
default_base_url: str = ""
|
|
21
|
+
requires_api_key: bool = True
|
|
22
|
+
supports_image: bool = False
|
|
23
|
+
supports_video: bool = False
|
|
24
|
+
supports_text: bool = False
|
|
25
|
+
local: bool = False
|
|
26
|
+
api_style: str = "custom"
|
|
27
|
+
extra_fields: tuple[str, ...] = ()
|
|
28
|
+
description: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
MEDIA_PROVIDER_CATALOG: tuple[MediaProviderDefinition, ...] = (
|
|
32
|
+
MediaProviderDefinition(
|
|
33
|
+
"nano_banana",
|
|
34
|
+
"Nano Banana",
|
|
35
|
+
default_base_url="https://generativelanguage.googleapis.com/v1beta",
|
|
36
|
+
supports_image=True,
|
|
37
|
+
api_style="gemini_interactions",
|
|
38
|
+
extra_fields=("aspect_ratio", "image_size"),
|
|
39
|
+
description="Geração de imagem nativa Gemini para thumbnails e artes.",
|
|
40
|
+
),
|
|
41
|
+
MediaProviderDefinition(
|
|
42
|
+
"pollinations",
|
|
43
|
+
"Pollinations.ai",
|
|
44
|
+
default_base_url="https://gen.pollinations.ai/v1",
|
|
45
|
+
supports_image=True,
|
|
46
|
+
supports_video=True,
|
|
47
|
+
supports_text=True,
|
|
48
|
+
api_style="openai_compatible",
|
|
49
|
+
description="Gateway multimodal com endpoints compatíveis e catálogo próprio.",
|
|
50
|
+
),
|
|
51
|
+
MediaProviderDefinition(
|
|
52
|
+
"agnes",
|
|
53
|
+
"Agnes AI",
|
|
54
|
+
default_base_url="https://apihub.agnes-ai.com/v1",
|
|
55
|
+
supports_image=True,
|
|
56
|
+
supports_video=True,
|
|
57
|
+
supports_text=True,
|
|
58
|
+
api_style="agnes",
|
|
59
|
+
description="Gateway multimodal Agnes para texto, imagem e vídeo.",
|
|
60
|
+
),
|
|
61
|
+
MediaProviderDefinition(
|
|
62
|
+
"huggingface",
|
|
63
|
+
"Hugging Face Inference API",
|
|
64
|
+
default_base_url="https://router.huggingface.co/v1",
|
|
65
|
+
supports_image=True,
|
|
66
|
+
supports_text=True,
|
|
67
|
+
api_style="huggingface",
|
|
68
|
+
description="Inference Providers; a capacidade depende do modelo seleccionado.",
|
|
69
|
+
),
|
|
70
|
+
MediaProviderDefinition(
|
|
71
|
+
"cloudflare_workers_ai",
|
|
72
|
+
"Cloudflare Workers AI",
|
|
73
|
+
default_base_url="https://api.cloudflare.com/client/v4",
|
|
74
|
+
supports_image=True,
|
|
75
|
+
api_style="cloudflare",
|
|
76
|
+
extra_fields=("account_id",),
|
|
77
|
+
description="Workers AI com Account ID e API token; a rota de modelo é derivada.",
|
|
78
|
+
),
|
|
79
|
+
MediaProviderDefinition(
|
|
80
|
+
"inferenceport",
|
|
81
|
+
"InferencePort Proxy",
|
|
82
|
+
default_base_url="http://localhost:8080/v1",
|
|
83
|
+
requires_api_key=False,
|
|
84
|
+
supports_image=True,
|
|
85
|
+
supports_video=True,
|
|
86
|
+
supports_text=True,
|
|
87
|
+
local=True,
|
|
88
|
+
api_style="openai_compatible",
|
|
89
|
+
description="Proxy local OpenAI-compatible; não exige API key por defeito.",
|
|
90
|
+
),
|
|
91
|
+
MediaProviderDefinition(
|
|
92
|
+
"alibaba_cloud",
|
|
93
|
+
"阿里云 (Alibaba Cloud Model Studio)",
|
|
94
|
+
default_base_url="https://dashscope-intl.aliyuncs.com/api/v1",
|
|
95
|
+
supports_image=True,
|
|
96
|
+
supports_video=True,
|
|
97
|
+
supports_text=True,
|
|
98
|
+
api_style="dashscope",
|
|
99
|
+
extra_fields=("region",),
|
|
100
|
+
description="DashScope/Model Studio; tarefas de imagem e vídeo podem ser assíncronas.",
|
|
101
|
+
),
|
|
102
|
+
MediaProviderDefinition(
|
|
103
|
+
"kie_ai",
|
|
104
|
+
"KIE AI",
|
|
105
|
+
default_base_url="https://api.kie.ai/api/v1",
|
|
106
|
+
supports_image=True,
|
|
107
|
+
supports_video=True,
|
|
108
|
+
supports_text=True,
|
|
109
|
+
api_style="kie",
|
|
110
|
+
description="Gateway multimodal com tarefas e consulta de resultados.",
|
|
111
|
+
),
|
|
112
|
+
MediaProviderDefinition(
|
|
113
|
+
"fal_ai",
|
|
114
|
+
"FAL AI",
|
|
115
|
+
default_base_url="https://queue.fal.run",
|
|
116
|
+
supports_image=True,
|
|
117
|
+
supports_video=True,
|
|
118
|
+
api_style="fal_queue",
|
|
119
|
+
description="Model APIs da FAL com queue, status e resultado.",
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
_MEDIA_BY_CODE = {item.code: item for item in MEDIA_PROVIDER_CATALOG}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def media_provider_catalog() -> list[dict[str, Any]]:
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
"code": item.code,
|
|
130
|
+
"label": item.label,
|
|
131
|
+
"default_base_url": item.default_base_url,
|
|
132
|
+
"requires_api_key": item.requires_api_key,
|
|
133
|
+
"supports_image": item.supports_image,
|
|
134
|
+
"supports_video": item.supports_video,
|
|
135
|
+
"supports_text": item.supports_text,
|
|
136
|
+
"local": item.local,
|
|
137
|
+
"api_style": item.api_style,
|
|
138
|
+
"extra_fields": item.extra_fields,
|
|
139
|
+
"description": item.description,
|
|
140
|
+
}
|
|
141
|
+
for item in MEDIA_PROVIDER_CATALOG
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def media_provider_definition(provider: Any) -> MediaProviderDefinition:
|
|
146
|
+
code = str(provider or "").strip().lower()
|
|
147
|
+
return _MEDIA_BY_CODE.get(code, _MEDIA_BY_CODE["inferenceport"])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def normalize_media_card(card: Any, index: int = 0) -> dict[str, Any]:
|
|
151
|
+
source = dict(card) if isinstance(card, Mapping) else {}
|
|
152
|
+
provider = str(source.get("provider") or "nano_banana").strip().lower()
|
|
153
|
+
definition = media_provider_definition(provider)
|
|
154
|
+
if provider not in _MEDIA_BY_CODE:
|
|
155
|
+
provider = definition.code
|
|
156
|
+
card_id = str(source.get("id") or f"media-{provider}-{index + 1}").strip()
|
|
157
|
+
result: dict[str, Any] = {
|
|
158
|
+
"id": card_id,
|
|
159
|
+
"provider": provider,
|
|
160
|
+
"api_key": str(source.get("api_key") or source.get("key") or "").strip(),
|
|
161
|
+
"model": str(source.get("model") or source.get("model_name") or "").strip(),
|
|
162
|
+
"base_url": str(source.get("base_url") or "").strip() or definition.default_base_url,
|
|
163
|
+
"enabled": bool(source.get("enabled", True)),
|
|
164
|
+
"priority": max(0, int(source.get("priority", index)) if str(source.get("priority", index)).strip().lstrip("-").isdigit() else index),
|
|
165
|
+
"supports_image": bool(source.get("supports_image", definition.supports_image)),
|
|
166
|
+
"supports_video": bool(source.get("supports_video", definition.supports_video)),
|
|
167
|
+
"supports_text": bool(source.get("supports_text", definition.supports_text)),
|
|
168
|
+
"api_style": definition.api_style,
|
|
169
|
+
"local": definition.local,
|
|
170
|
+
}
|
|
171
|
+
for field in definition.extra_fields:
|
|
172
|
+
result[field] = str(source.get(field) or "").strip()
|
|
173
|
+
test_result = source.get("test_result")
|
|
174
|
+
if isinstance(test_result, Mapping) and str(test_result.get("status") or "") in {"success", "error"}:
|
|
175
|
+
result["test_result"] = {
|
|
176
|
+
"status": str(test_result.get("status")),
|
|
177
|
+
"message": str(test_result.get("message") or "")[:240],
|
|
178
|
+
"tested_at": str(test_result.get("tested_at") or "")[:64],
|
|
179
|
+
}
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def new_media_card(provider: Any, *, card_id: str | None = None) -> dict[str, Any]:
|
|
184
|
+
code = str(provider or "").strip().lower()
|
|
185
|
+
if code not in _MEDIA_BY_CODE:
|
|
186
|
+
raise ValueError("Provider de imagem/vídeo inválido.")
|
|
187
|
+
definition = _MEDIA_BY_CODE[code]
|
|
188
|
+
return normalize_media_card(
|
|
189
|
+
{
|
|
190
|
+
"id": card_id or f"media-{code}-1",
|
|
191
|
+
"provider": code,
|
|
192
|
+
"model": "gemini-3.1-flash-image" if code == "nano_banana" else "",
|
|
193
|
+
"base_url": definition.default_base_url,
|
|
194
|
+
"enabled": True,
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def ensure_media_provider_cards(settings: Mapping[str, Any]) -> tuple[dict[str, Any], bool]:
|
|
200
|
+
result = dict(settings)
|
|
201
|
+
raw_cards = result.get(MEDIA_CARDS_KEY)
|
|
202
|
+
changed = False
|
|
203
|
+
if isinstance(raw_cards, list) and raw_cards:
|
|
204
|
+
cards = [normalize_media_card(item, index) for index, item in enumerate(raw_cards)]
|
|
205
|
+
changed = cards != raw_cards
|
|
206
|
+
else:
|
|
207
|
+
cards: list[dict[str, Any]] = []
|
|
208
|
+
legacy_key = str(result.get("gemini_image_api_key") or "").strip()
|
|
209
|
+
legacy_model = str(result.get("gemini_image_model") or "gemini-3.1-flash-image").strip()
|
|
210
|
+
cards.append(
|
|
211
|
+
normalize_media_card(
|
|
212
|
+
{
|
|
213
|
+
"id": "media-nano-banana-default",
|
|
214
|
+
"provider": "nano_banana",
|
|
215
|
+
"api_key": legacy_key,
|
|
216
|
+
"model": legacy_model,
|
|
217
|
+
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
|
218
|
+
"aspect_ratio": str(result.get("gemini_image_aspect_ratio") or "16:9"),
|
|
219
|
+
"image_size": str(result.get("gemini_image_size") or "1K"),
|
|
220
|
+
"enabled": True,
|
|
221
|
+
},
|
|
222
|
+
0,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
changed = True
|
|
226
|
+
result[MEDIA_CARDS_KEY] = cards
|
|
227
|
+
|
|
228
|
+
for pool, key, capability in (
|
|
229
|
+
("image", MEDIA_IMAGE_ACTIVE_CARD_KEY, "supports_image"),
|
|
230
|
+
("video", MEDIA_VIDEO_ACTIVE_CARD_KEY, "supports_video"),
|
|
231
|
+
):
|
|
232
|
+
active_id = str(result.get(key) or "").strip()
|
|
233
|
+
valid = [card for card in cards if card.get(capability) and card.get("enabled", True)]
|
|
234
|
+
if active_id not in {str(card.get("id")) for card in cards} or not any(str(card.get("id")) == active_id and card.get(capability) and card.get("enabled", True) for card in cards):
|
|
235
|
+
active_id = str(valid[0].get("id")) if valid else ""
|
|
236
|
+
if result.get(key) != active_id:
|
|
237
|
+
result[key] = active_id
|
|
238
|
+
changed = True
|
|
239
|
+
legacy_provider_key = MEDIA_IMAGE_ACTIVE_PROVIDER_KEY if pool == "image" else MEDIA_VIDEO_ACTIVE_PROVIDER_KEY
|
|
240
|
+
active_card = next((card for card in cards if str(card.get("id")) == active_id), None)
|
|
241
|
+
active_provider = str(active_card.get("provider") or "") if active_card else ""
|
|
242
|
+
if result.get(legacy_provider_key) != active_provider:
|
|
243
|
+
result[legacy_provider_key] = active_provider
|
|
244
|
+
changed = True
|
|
245
|
+
return result, changed
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def apply_media_provider_cards_to_settings(settings: Mapping[str, Any], cards: list[Mapping[str, Any]], image_active_id: str = "", video_active_id: str = "") -> dict[str, Any]:
|
|
249
|
+
result = dict(settings)
|
|
250
|
+
normalized = [normalize_media_card(item, index) for index, item in enumerate(cards)]
|
|
251
|
+
if not normalized:
|
|
252
|
+
normalized = [new_media_card("nano_banana", card_id="media-nano-banana-default")]
|
|
253
|
+
result[MEDIA_CARDS_KEY] = normalized
|
|
254
|
+
for key, wanted, capability in (
|
|
255
|
+
(MEDIA_IMAGE_ACTIVE_CARD_KEY, image_active_id, "supports_image"),
|
|
256
|
+
(MEDIA_VIDEO_ACTIVE_CARD_KEY, video_active_id, "supports_video"),
|
|
257
|
+
):
|
|
258
|
+
selected = next((card for card in normalized if str(card.get("id")) == str(wanted) and card.get(capability) and card.get("enabled", True)), None)
|
|
259
|
+
if selected is None:
|
|
260
|
+
selected = next((card for card in normalized if card.get(capability) and card.get("enabled", True)), None)
|
|
261
|
+
result[key] = str(selected.get("id")) if selected else ""
|
|
262
|
+
image_card = next((card for card in normalized if str(card.get("id")) == result[MEDIA_IMAGE_ACTIVE_CARD_KEY]), None)
|
|
263
|
+
video_card = next((card for card in normalized if str(card.get("id")) == result[MEDIA_VIDEO_ACTIVE_CARD_KEY]), None)
|
|
264
|
+
result[MEDIA_IMAGE_ACTIVE_PROVIDER_KEY] = str(image_card.get("provider") or "") if image_card else ""
|
|
265
|
+
result[MEDIA_VIDEO_ACTIVE_PROVIDER_KEY] = str(video_card.get("provider") or "") if video_card else ""
|
|
266
|
+
nano_card = next((card for card in normalized if card.get("provider") == "nano_banana"), None)
|
|
267
|
+
if nano_card:
|
|
268
|
+
result["gemini_image_api_key"] = str(nano_card.get("api_key") or "")
|
|
269
|
+
result["gemini_image_model"] = str(nano_card.get("model") or "gemini-3.1-flash-image")
|
|
270
|
+
result["gemini_image_aspect_ratio"] = str(nano_card.get("aspect_ratio") or result.get("gemini_image_aspect_ratio") or "16:9")
|
|
271
|
+
result["gemini_image_size"] = str(nano_card.get("image_size") or result.get("gemini_image_size") or "1K")
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def media_cards_for_pool(settings: Mapping[str, Any], pool: str) -> list[dict[str, Any]]:
|
|
276
|
+
migrated, _ = ensure_media_provider_cards(settings)
|
|
277
|
+
capability = "supports_image" if pool == "image" else "supports_video" if pool == "video" else "supports_text"
|
|
278
|
+
active_key = MEDIA_IMAGE_ACTIVE_CARD_KEY if pool == "image" else MEDIA_VIDEO_ACTIVE_CARD_KEY if pool == "video" else ""
|
|
279
|
+
active_id = str(migrated.get(active_key) or "")
|
|
280
|
+
cards = [dict(item) for item in migrated.get(MEDIA_CARDS_KEY, []) if item.get("enabled", True) and item.get(capability)]
|
|
281
|
+
cards.sort(key=lambda card: (0 if str(card.get("id")) == active_id else 1, int(card.get("priority", 0))))
|
|
282
|
+
return cards
|