@danhachuel/thunderbolt 0.2.64 → 0.2.66

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
@@ -37,6 +37,7 @@ from hermes_ui.music import list_music_files, materialize_suno_audio, request_su
37
37
  from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
38
38
  from hermes_ui.script_generation import generate_script_document
39
39
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
40
+ from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
40
41
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
41
42
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
42
43
  from integrations.postiz import PostizAdapter
@@ -1367,11 +1368,30 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1367
1368
  if variants:
1368
1369
  labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
1369
1370
  selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"new_video_general_thumbnail_{channel['id']}")
1370
- variant = variants[labels.index(selected_variant_label)]
1371
+ variant_index = labels.index(selected_variant_label)
1372
+ variant = variants[variant_index]
1371
1373
  payload["thumbnail_variant"] = variant
1372
1374
  payload["thumbnail_prompt"] = variant.get("image_prompt", "")
1373
1375
  payload["thumbnail_text"] = variant.get("overlay_text", "")
1374
1376
  st.caption(f"{variant.get('composition', '')} · {variant.get('color_palette', '')}")
1377
+ thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
1378
+ if st.button("Gerar imagem com Nano Banana", key=f"new_video_general_generate_thumbnail_{channel['id']}", use_container_width=True):
1379
+ try:
1380
+ thumbnail_path = str(generate_thumbnail_image(read_json("settings.json", {}), variant.get("image_prompt", ""), topic=str(payload.get("topic") or ""), variant_index=variant_index))
1381
+ variant["image_path"] = thumbnail_path
1382
+ payload["thumbnail_path"] = thumbnail_path
1383
+ payload["thumbnail_status"] = "generated"
1384
+ st.session_state["new_video_general_payloads"] = payloads
1385
+ st.success("Thumbnail gerada com Nano Banana.")
1386
+ st.rerun()
1387
+ except ThumbnailGenerationError as exc:
1388
+ st.error(str(exc))
1389
+ if thumbnail_path and Path(thumbnail_path).is_file():
1390
+ st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", use_container_width=True)
1391
+ payload["thumbnail_path"] = thumbnail_path
1392
+ payload["thumbnail_status"] = "generated"
1393
+ else:
1394
+ st.caption("A imagem ainda não foi gerada. Configure a API key em Configurações Técnicas > API Keys.")
1375
1395
  st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
1376
1396
  else:
1377
1397
  topic_for_creative = str(st.session_state.get("new_video_topic", "") or "").strip()
@@ -1401,13 +1421,31 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1401
1421
  if variants:
1402
1422
  labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
1403
1423
  selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key="new_video_thumbnail_choice")
1404
- variant = variants[labels.index(selected_variant_label)]
1424
+ variant_index = labels.index(selected_variant_label)
1425
+ variant = variants[variant_index]
1405
1426
  payload["thumbnail_variant"] = variant
1406
1427
  payload["thumbnail_prompt"] = variant.get("image_prompt", "")
1407
1428
  payload["thumbnail_text"] = variant.get("overlay_text", "")
1408
1429
  st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
1409
1430
  st.code(variant.get("image_prompt", ""), language="text")
1410
- st.info("Prompt de thumbnail pronto — a imagem será criada quando existir um provider de imagem configurado; não é criado um ficheiro falso.")
1431
+ thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
1432
+ if st.button("Gerar imagem da thumbnail com Nano Banana", key="new_video_generate_thumbnail_image", use_container_width=True):
1433
+ try:
1434
+ thumbnail_path = str(generate_thumbnail_image(read_json("settings.json", {}), variant.get("image_prompt", ""), topic=str(payload.get("topic") or ""), variant_index=variant_index))
1435
+ variant["image_path"] = thumbnail_path
1436
+ payload["thumbnail_path"] = thumbnail_path
1437
+ payload["thumbnail_status"] = "generated"
1438
+ st.session_state["new_video_creative_payload"] = payload
1439
+ st.success("Thumbnail gerada com Nano Banana.")
1440
+ st.rerun()
1441
+ except ThumbnailGenerationError as exc:
1442
+ st.error(str(exc))
1443
+ if thumbnail_path and Path(thumbnail_path).is_file():
1444
+ st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", use_container_width=True)
1445
+ payload["thumbnail_path"] = thumbnail_path
1446
+ payload["thumbnail_status"] = "generated"
1447
+ else:
1448
+ st.info("Escolha a variante e clique em **Gerar imagem da thumbnail com Nano Banana**. A API key é configurada em Configurações Técnicas > API Keys.")
1411
1449
  st.session_state["new_video_creative_payload"] = payload
1412
1450
 
1413
1451
  with st.form("new_video_form"):
@@ -2697,8 +2735,6 @@ def render_settings():
2697
2735
  missing_document_parts = list(direct_status.get("missing_cookies", []))
2698
2736
  if not direct_status.get("has_session_info"):
2699
2737
  missing_document_parts.append("sessionInfo")
2700
- if not direct_status.get("has_innertube_api_key"):
2701
- missing_document_parts.append("INNERTUBE_API_KEY")
2702
2738
  if missing_document_parts:
2703
2739
  youtube_accounts_missing_document.append(account_email_snapshot)
2704
2740
 
@@ -2923,6 +2959,16 @@ def render_settings():
2923
2959
  proxy_https = text_setting("Proxy HTTPS", "proxy_https")
2924
2960
  match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
2925
2961
 
2962
+ with st.expander("Nano Banana — geração de thumbnails", expanded=True):
2963
+ st.caption("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.")
2964
+ nano_cols = st.columns(2)
2965
+ with nano_cols[0]:
2966
+ gemini_image_api_key = text_setting("Nano Banana API key", "gemini_image_api_key", secret=True, help_text="Chave criada no Google AI Studio para a API Gemini. Nunca é incluída no código, logs ou pacote.")
2967
+ gemini_image_model = st.selectbox("Modelo Nano Banana", ["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"], index=["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"].index(str(settings.get("gemini_image_model") or "gemini-3.1-flash-image")) if str(settings.get("gemini_image_model") or "gemini-3.1-flash-image") in {"gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"} else 0)
2968
+ with nano_cols[1]:
2969
+ gemini_image_aspect_ratio = st.selectbox("Proporção da thumbnail", ["16:9", "9:16", "1:1", "4:5"], index=["16:9", "9:16", "1:1", "4:5"].index(str(settings.get("gemini_image_aspect_ratio") or "16:9")) if str(settings.get("gemini_image_aspect_ratio") or "16:9") in {"16:9", "9:16", "1:1", "4:5"} else 0)
2970
+ gemini_image_size = st.selectbox("Tamanho da imagem", ["1K", "2K", "4K"], index=["1K", "2K", "4K"].index(str(settings.get("gemini_image_size") or "1K")) if str(settings.get("gemini_image_size") or "1K") in {"1K", "2K", "4K"} else 0)
2971
+
2926
2972
  with st.expander("LLM — providers e modelos", expanded=True):
2927
2973
  provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
2928
2974
  llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(settings.get("llm_provider", "moonshot")) if settings.get("llm_provider", "moonshot") in provider_options else 0)
@@ -3063,6 +3109,7 @@ def render_settings():
3063
3109
  "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
3064
3110
  "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
3065
3111
  "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
3112
+ "gemini_image_api_key": gemini_image_api_key, "gemini_image_model": gemini_image_model, "gemini_image_aspect_ratio": gemini_image_aspect_ratio, "gemini_image_size": gemini_image_size,
3066
3113
  "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
3067
3114
  "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
3068
3115
  "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
@@ -109,6 +109,10 @@ DEFAULTS: dict[str, Any] = {
109
109
  "openai_model_name": "",
110
110
  "gemini_api_key": "",
111
111
  "gemini_model_name": "",
112
+ "gemini_image_api_key": "",
113
+ "gemini_image_model": "gemini-3.1-flash-image",
114
+ "gemini_image_aspect_ratio": "16:9",
115
+ "gemini_image_size": "1K",
112
116
  "deepseek_api_key": "",
113
117
  "deepseek_base_url": "",
114
118
  "deepseek_model_name": "",
@@ -0,0 +1,158 @@
1
+ import base64
2
+ import binascii
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import requests
11
+
12
+ from hermes_ui.storage import STORAGE, ensure_storage
13
+
14
+
15
+ GEMINI_INTERACTIONS_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/interactions"
16
+ DEFAULT_GEMINI_IMAGE_MODEL = "gemini-3.1-flash-image"
17
+ DEFAULT_ASPECT_RATIO = "16:9"
18
+ DEFAULT_IMAGE_SIZE = "1K"
19
+ DEFAULT_MIME_TYPE = "image/jpeg"
20
+ DEFAULT_IMAGE_EXTENSION = ".jpg"
21
+
22
+
23
+ class ThumbnailGenerationError(RuntimeError):
24
+ """Raised when Nano Banana cannot produce a thumbnail image."""
25
+
26
+
27
+ def _clean_detail(value: Any, api_key: str) -> str:
28
+ detail = str(value or "").strip()[:600]
29
+ return detail.replace(api_key, "[REDACTED]") if api_key else detail
30
+
31
+
32
+ def _decode_image_data(value: Any) -> bytes | None:
33
+ if not isinstance(value, str) or not value.strip():
34
+ return None
35
+ raw = value.strip()
36
+ if raw.startswith("data:") and "," in raw:
37
+ raw = raw.split(",", 1)[1]
38
+ try:
39
+ data = base64.b64decode(raw, validate=True)
40
+ except (binascii.Error, ValueError):
41
+ return None
42
+ return data or None
43
+
44
+
45
+ def _image_from_content(content: Any) -> bytes | None:
46
+ if not isinstance(content, dict) or content.get("type") != "image":
47
+ return None
48
+ return _decode_image_data(content.get("data"))
49
+
50
+
51
+ def _extract_image_bytes(payload: dict[str, Any]) -> bytes:
52
+ """Extract the last inline image from an Interactions API response."""
53
+ steps = payload.get("steps") or []
54
+ for step in reversed(steps if isinstance(steps, list) else []):
55
+ content = step.get("content") if isinstance(step, dict) else None
56
+ for block in reversed(content if isinstance(content, list) else []):
57
+ image = _image_from_content(block)
58
+ if image:
59
+ return image
60
+
61
+ output_image = payload.get("output_image")
62
+ image = _image_from_content(output_image)
63
+ if image:
64
+ return image
65
+
66
+ outputs = payload.get("outputs")
67
+ for output in reversed(outputs if isinstance(outputs, list) else []):
68
+ image = _image_from_content(output)
69
+ if image:
70
+ return image
71
+
72
+ raise ThumbnailGenerationError("O Gemini concluiu a interação, mas não devolveu uma imagem inline.")
73
+
74
+
75
+ def _thumbnail_filename(prompt: str, topic: str, variant_index: int, model: str) -> str:
76
+ source = f"{model}\n{topic.strip()}\n{variant_index}\n{prompt.strip()}".encode("utf-8")
77
+ digest = hashlib.sha256(source).hexdigest()[:20]
78
+ return f"gemini-thumbnail-{digest}{DEFAULT_IMAGE_EXTENSION}"
79
+
80
+
81
+ def generate_thumbnail_image(
82
+ settings: dict[str, Any],
83
+ prompt: str,
84
+ *,
85
+ topic: str = "",
86
+ variant_index: int = 0,
87
+ ) -> Path:
88
+ api_key = str(settings.get("gemini_image_api_key") or "").strip()
89
+ if not api_key:
90
+ raise ThumbnailGenerationError("Configure a API key Nano Banana em Configurações Técnicas > API Keys.")
91
+ clean_prompt = str(prompt or "").strip()
92
+ if not clean_prompt:
93
+ raise ThumbnailGenerationError("A thumbnail não tem um prompt de imagem para gerar.")
94
+
95
+ model = str(settings.get("gemini_image_model") or DEFAULT_GEMINI_IMAGE_MODEL).strip()
96
+ aspect_ratio = str(settings.get("gemini_image_aspect_ratio") or DEFAULT_ASPECT_RATIO).strip()
97
+ image_size = str(settings.get("gemini_image_size") or DEFAULT_IMAGE_SIZE).strip()
98
+ body = {
99
+ "model": model,
100
+ "input": clean_prompt,
101
+ "response_format": {
102
+ "type": "image",
103
+ "mime_type": DEFAULT_MIME_TYPE,
104
+ "delivery": "inline",
105
+ "aspect_ratio": aspect_ratio,
106
+ "image_size": image_size,
107
+ },
108
+ "store": False,
109
+ }
110
+ headers = {"Content-Type": "application/json", "x-goog-api-key": api_key}
111
+ try:
112
+ response = requests.post(
113
+ GEMINI_INTERACTIONS_ENDPOINT,
114
+ headers=headers,
115
+ json=body,
116
+ timeout=180,
117
+ )
118
+ except requests.RequestException as exc:
119
+ raise ThumbnailGenerationError(f"Não foi possível contactar a API Nano Banana: {exc}") from exc
120
+ if response.status_code >= 400:
121
+ try:
122
+ detail = response.json().get("error", response.text)
123
+ except ValueError:
124
+ detail = response.text
125
+ raise ThumbnailGenerationError(f"A API Nano Banana devolveu HTTP {response.status_code}: {_clean_detail(detail, api_key)}")
126
+ try:
127
+ payload = response.json()
128
+ except ValueError as exc:
129
+ raise ThumbnailGenerationError("A API Nano Banana devolveu uma resposta que não é JSON.") from exc
130
+ if str(payload.get("status") or "completed").lower() not in {"completed", "succeeded"}:
131
+ raise ThumbnailGenerationError(f"A interação Nano Banana terminou com estado inesperado: {payload.get('status') or 'desconhecido'}.")
132
+ image_bytes = _extract_image_bytes(payload)
133
+
134
+ ensure_storage()
135
+ output_dir = STORAGE / "thumbnails"
136
+ output_dir.mkdir(parents=True, exist_ok=True)
137
+ destination = output_dir / _thumbnail_filename(clean_prompt, topic, variant_index, model)
138
+ fd, temp_name = tempfile.mkstemp(prefix=f".{destination.name}.", dir=output_dir)
139
+ try:
140
+ with os.fdopen(fd, "wb") as handle:
141
+ handle.write(image_bytes)
142
+ handle.flush()
143
+ os.fsync(handle.fileno())
144
+ os.replace(temp_name, destination)
145
+ finally:
146
+ if os.path.exists(temp_name):
147
+ os.unlink(temp_name)
148
+ return destination
149
+
150
+
151
+ __all__ = [
152
+ "DEFAULT_ASPECT_RATIO",
153
+ "DEFAULT_GEMINI_IMAGE_MODEL",
154
+ "DEFAULT_IMAGE_SIZE",
155
+ "GEMINI_INTERACTIONS_ENDPOINT",
156
+ "ThumbnailGenerationError",
157
+ "generate_thumbnail_image",
158
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.64",
3
+ "version": "0.2.66",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",