@danhachuel/thunderbolt 0.5.59 → 0.5.61

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/app/main.py CHANGED
@@ -6729,7 +6729,11 @@ def render_google_accounts(*, include_innertube: bool = True):
6729
6729
 
6730
6730
  st.divider()
6731
6731
  add_account = False
6732
- with st.expander("Adicionar conta Google/YouTube", expanded=False):
6732
+ add_form_open = bool(st.session_state.get("add_batch_account_form_open", False))
6733
+ if not add_form_open and st.button("Adicionar conta Google/YouTube", type="primary", use_container_width=True, key="open_add_batch_account_form"):
6734
+ st.session_state["add_batch_account_form_open"] = True
6735
+ st.rerun()
6736
+ if add_form_open:
6733
6737
  st.markdown("### Nova conta Gmail")
6734
6738
  st.caption("Este formulário fica fora dos cartões das contas existentes. A associação de canais não depende da completude deste documento; ela apenas ficará pendente para Upload directo até os campos serem preenchidos.")
6735
6739
  with st.form("add_batch_account_form"):
@@ -6742,7 +6746,10 @@ def render_google_accounts(*, include_innertube: bool = True):
6742
6746
  new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
6743
6747
  new_account_session_info = st.text_input("sessionInfo token desta conta Google", type="password", key="new_batch_account_session_info", help="Token sessionInfo desta conta. Os cookies e delegated_session_ids ficam no documento; a INNERTUBE_API_KEY é configurada no bloco próprio acima.")
6744
6748
  new_account_document = st.file_uploader("Documento de cookies/credenciais opcional", type=["json"], key="new_batch_account_credentials_document", help="Pode subir agora um JSON completo ou apenas o documento de cookies. Se não subir, será criado um credentials.json padrão vazio.")
6745
- add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", use_container_width=True)
6749
+ add_account = st.form_submit_button("Guardar conta Google/YouTube", type="primary", use_container_width=True)
6750
+ if st.button("Cancelar", key="cancel_add_batch_account_form"):
6751
+ st.session_state["add_batch_account_form_open"] = False
6752
+ st.rerun()
6746
6753
  if add_account:
6747
6754
  document_error = ""
6748
6755
  if "@" not in new_account_email.strip():
@@ -6772,6 +6779,7 @@ def render_google_accounts(*, include_innertube: bool = True):
6772
6779
  st.success(f"Conta {new_account['email']} adicionada e documento incorporado.")
6773
6780
  else:
6774
6781
  st.warning(f"Conta {new_account['email']} adicionada. Foi criado um credentials.json padrão; suba o documento de cookies nesta conta quando estiver pronto.")
6782
+ st.session_state["add_batch_account_form_open"] = False
6775
6783
  st.rerun()
6776
6784
 
6777
6785
  st.divider()
@@ -7273,6 +7281,9 @@ def _persist_media_cards(settings: dict[str, Any], cards: list[dict[str, Any]],
7273
7281
  def _fetch_media_models(card: dict[str, Any]) -> list[str]:
7274
7282
  """Consultar modelos do provider, incluindo o catálogo nativo Gemini."""
7275
7283
  provider = str(card.get("provider") or "").strip().lower()
7284
+ if provider == "huggingface":
7285
+ from integrations.openai_model_discovery import fetch_huggingface_text_to_image_models
7286
+ return fetch_huggingface_text_to_image_models(str(card.get("api_key") or ""))
7276
7287
  if provider == "replicate":
7277
7288
  from integrations.openai_model_discovery import fetch_replicate_models
7278
7289
  return fetch_replicate_models(str(card.get("api_key") or ""), str(card.get("base_url") or ""))
@@ -169,6 +169,14 @@ def test_media_provider_card(card: Mapping[str, Any]) -> dict[str, Any]:
169
169
  api_style = str(source.get("api_style") or "").strip().lower()
170
170
  if provider == "nano_banana":
171
171
  return test_nano_banana_credentials(api_key, model)
172
+ if provider == "huggingface":
173
+ if not api_key:
174
+ return _missing("Introduza o token Hugging Face antes de testar.")
175
+ return _get(
176
+ "https://huggingface.co/api/models",
177
+ headers={"Authorization": f"Bearer {api_key}"},
178
+ params={"pipeline_tag": "text-to-image", "limit": 1},
179
+ )
172
180
  if not base_url:
173
181
  return _result("missing", "Complete a Base URL antes de testar.")
174
182
  if provider not in {"inferenceport", "ollama", "lmstudio"} and not api_key:
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import base64
11
11
  import binascii
12
+ import io
12
13
  import json
13
14
  import mimetypes
14
15
  import os
@@ -453,6 +454,35 @@ def generate_image_for_card(
453
454
  ensure_storage()
454
455
  destination = STORAGE / "thumbnails" / f"media-{provider}-{abs(hash((topic, prompt, variant_index))) & 0xffffffffffffffff:x}.jpg"
455
456
 
457
+ if provider == "huggingface":
458
+ model = _model(card)
459
+ token = _api_key(card)
460
+ if not model:
461
+ raise MediaGenerationError("Hugging Face requer a selecção de um modelo text-to-image.")
462
+ try:
463
+ image_prompt, _headline = _compose_thumbnail_prompt(
464
+ prompt,
465
+ topic=topic,
466
+ lettering_text=lettering_text,
467
+ lettering_prompt=lettering_prompt,
468
+ )
469
+ constrained_prompt = _append_generation_constraints(
470
+ image_prompt,
471
+ kind="image",
472
+ aspect_ratio="16:9",
473
+ size="1280x720 minimum",
474
+ )
475
+ from huggingface_hub import InferenceClient
476
+ image = InferenceClient(token=token or None, provider="hf-inference").text_to_image(
477
+ prompt=constrained_prompt,
478
+ model=model,
479
+ )
480
+ buffer = io.BytesIO()
481
+ image.save(buffer, format="PNG")
482
+ return _download_or_write(buffer.getvalue(), "", destination, card)
483
+ except Exception as exc:
484
+ raise MediaGenerationError(f"Hugging Face text-to-image falhou: {str(exc)[:240]}") from exc
485
+
456
486
  def request(current: dict[str, Any]) -> Any:
457
487
  return _image_request(current, prompt, topic=topic, lettering_text=lettering_text, lettering_prompt=lettering_prompt)
458
488
 
@@ -113,11 +113,11 @@ MEDIA_PROVIDER_CATALOG: tuple[MediaProviderDefinition, ...] = (
113
113
  MediaProviderDefinition(
114
114
  "huggingface",
115
115
  "Hugging Face Inference API",
116
- default_base_url="https://router.huggingface.co/v1",
116
+ default_base_url="https://huggingface.co",
117
117
  supports_image=True,
118
- supports_text=True,
118
+ supports_text=False,
119
119
  api_style="huggingface",
120
- description="Inference Providers; a capacidade depende do modelo seleccionado.",
120
+ description="Hugging Face Inference API para imagens via InferenceClient com provider hf-inference; não usa endpoint OpenAI-compatible.",
121
121
  ),
122
122
  MediaProviderDefinition(
123
123
  "cloudflare_workers_ai",
@@ -230,7 +230,7 @@ def normalize_media_card(card: Any, index: int = 0) -> dict[str, Any]:
230
230
  "provider": provider,
231
231
  "api_key": str(source.get("api_key") or source.get("key") or "").strip(),
232
232
  "model": str(source.get("model") or source.get("model_name") or "").strip(),
233
- "base_url": str(source.get("base_url") or "").strip() or definition.default_base_url,
233
+ "base_url": definition.default_base_url if provider == "huggingface" else (str(source.get("base_url") or "").strip() or definition.default_base_url),
234
234
  "enabled": bool(source.get("enabled", True)),
235
235
  "priority": max(0, int(source.get("priority", index)) if str(source.get("priority", index)).strip().lstrip("-").isdigit() else index),
236
236
  "supports_image": bool(source.get("supports_image", definition.supports_image)),
@@ -210,6 +210,40 @@ def fetch_openai_compatible_models(
210
210
  return normalize_model_ids(payload)
211
211
 
212
212
 
213
+ def fetch_huggingface_text_to_image_models(
214
+ api_key: str,
215
+ *,
216
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
217
+ limit: int = 2000,
218
+ ) -> list[str]:
219
+ """List only public Hugging Face models tagged with ``text-to-image``."""
220
+ token = str(api_key or "").strip()
221
+ headers = {"Accept": "application/json"}
222
+ if token:
223
+ headers["Authorization"] = f"Bearer {token}"
224
+ try:
225
+ response = requests.get(
226
+ "https://huggingface.co/api/models",
227
+ params={"pipeline_tag": "text-to-image", "limit": min(max(int(limit), 1), MAX_MODEL_IDS), "sort": "downloads", "direction": -1},
228
+ headers=headers,
229
+ timeout=timeout,
230
+ )
231
+ except requests.RequestException as exc:
232
+ raise ModelDiscoveryError(f"Não foi possível consultar o catálogo text-to-image da Hugging Face: {exc}") from exc
233
+ if response.status_code >= 400:
234
+ raise ModelDiscoveryError(f"O catálogo text-to-image da Hugging Face devolveu HTTP {response.status_code}.")
235
+ try:
236
+ payload = response.json()
237
+ except ValueError as exc:
238
+ raise ModelDiscoveryError("O catálogo Hugging Face devolveu uma resposta que não é JSON.") from exc
239
+ if not isinstance(payload, list):
240
+ raise ModelDiscoveryError("O catálogo Hugging Face não devolveu uma lista de modelos.")
241
+ models = sorted({str(item.get("id") or "").strip() for item in payload if isinstance(item, dict) and str(item.get("id") or "").strip()}, key=str.casefold)
242
+ if not models:
243
+ raise ModelDiscoveryError("A Hugging Face não devolveu modelos com pipeline text-to-image.")
244
+ return models[:MAX_MODEL_IDS]
245
+
246
+
213
247
  def fetch_replicate_models(
214
248
  api_key: str,
215
249
  base_url: str,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.5.59",
3
+ "version": "0.5.61",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",
package/requirements.txt CHANGED
@@ -1,6 +1,7 @@
1
1
  streamlit>=1.37,<2
2
2
  requests[socks]>=2.31,<3
3
3
  Pillow>=10,<12
4
+ huggingface_hub>=0.30,<1
4
5
  pandas>=2.0,<3
5
6
  openpyxl>=3.1,<4
6
7
  xlrd>=2.0,<3