@danhachuel/thunderbolt 0.2.63 → 0.2.65

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
@@ -22,7 +22,7 @@ except (OSError, json.JSONDecodeError):
22
22
 
23
23
  from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
24
24
  from hermes_ui.automation_worker import load_worker_status
25
- from hermes_ui.storage import BLUEPRINTS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, write_json
25
+ from hermes_ui.storage import BLUEPRINTS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
26
26
  from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
27
27
  from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
28
28
  from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
@@ -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
@@ -204,6 +205,41 @@ def card(label: str, value: str | int, note: str = ""):
204
205
  st.markdown(f'<div class="content-card"><div class="content-label">{label}</div><div class="content-value">{value}</div><div class="small-muted">{note}</div></div>', unsafe_allow_html=True)
205
206
 
206
207
 
208
+ def _library_card_key(kind: str, path: Path) -> str:
209
+ return hashlib.sha1(f"{kind}:{path.resolve()}".encode("utf-8")).hexdigest()[:12]
210
+
211
+
212
+ def _render_library_name_editor(kind: str, path: Path, current_name: str) -> str:
213
+ """Render the inline name editor while keeping the physical filename unchanged."""
214
+ edit_key = f"rename_{kind}_{_library_card_key(kind, path)}"
215
+ if st.session_state.get(edit_key):
216
+ with st.form(f"{edit_key}_form", border=False):
217
+ edited_name = st.text_input("Nome de apresentação", value=current_name, max_chars=120, key=f"{edit_key}_input")
218
+ save_col, cancel_col = st.columns(2)
219
+ with save_col:
220
+ save_name = st.form_submit_button("Guardar nome", type="primary", use_container_width=True)
221
+ with cancel_col:
222
+ cancel_name = st.form_submit_button("Cancelar", use_container_width=True)
223
+ if save_name:
224
+ try:
225
+ set_display_name(kind, path, edited_name)
226
+ st.session_state.pop(edit_key, None)
227
+ st.success("Nome actualizado.")
228
+ st.rerun()
229
+ except ValueError as exc:
230
+ st.error(str(exc))
231
+ if cancel_name:
232
+ st.session_state.pop(edit_key, None)
233
+ st.rerun()
234
+ return edit_key
235
+
236
+
237
+ def _render_card_pencil(edit_key: str) -> None:
238
+ if st.button("✏️", help="Editar nome de apresentação", key=f"pencil_{edit_key}", type="tertiary", use_container_width=True):
239
+ st.session_state[edit_key] = True
240
+ st.rerun()
241
+
242
+
207
243
  def channel_options() -> list[dict]:
208
244
  return [c for c in read_json("channels.json", []) if c.get("active", True)]
209
245
 
@@ -214,7 +250,7 @@ def blueprint_catalog() -> list[tuple[str, str]]:
214
250
  try:
215
251
  data = load_blueprint_file(path)
216
252
  identifier = str(data.get("id") or path.stem)
217
- label = str(data.get("name") or path.stem)
253
+ label = get_display_name("blueprints", path, str(data.get("name") or data.get("title") or path.stem))
218
254
  options.append((identifier, label))
219
255
  except (OSError, ValueError, json.JSONDecodeError):
220
256
  continue
@@ -231,11 +267,11 @@ def blueprint_for_channel(channel: dict) -> dict[str, Any]:
231
267
  data = load_blueprint_file(path)
232
268
  except (OSError, ValueError, json.JSONDecodeError):
233
269
  continue
234
- identifiers = {str(data.get("id") or ""), path.stem, str(data.get("name") or "")}
270
+ identifiers = {str(data.get("id") or ""), path.stem, str(data.get("name") or ""), get_display_name("blueprints", path, str(data.get("name") or path.stem))}
235
271
  if blueprint_id in identifiers:
236
272
  resolved = dict(data)
237
273
  resolved.setdefault("id", blueprint_id)
238
- resolved.setdefault("name", str(data.get("name") or path.stem))
274
+ resolved["name"] = get_display_name("blueprints", path, str(data.get("name") or path.stem))
239
275
  return resolved
240
276
  return {"id": blueprint_id, "name": blueprint_id}
241
277
 
@@ -665,14 +701,21 @@ def render_blueprints():
665
701
  if not files:
666
702
  st.info("Ainda não existem blueprints na pasta local.")
667
703
  for path in files:
668
- if search and search.lower() not in path.name.lower():
669
- continue
670
704
  try:
671
705
  data = load_blueprint_file(path)
672
- title = data.get("channel_name") or data.get("name") or data.get("title") or path.stem
673
- with st.expander(f"{title} {path.relative_to(BLUEPRINTS)}"):
674
- st.caption(f"Ficheiro: {path}")
675
- st.json(data)
706
+ fallback_title = str(data.get("channel_name") or data.get("name") or data.get("title") or path.stem)
707
+ title = get_display_name("blueprints", path, fallback_title)
708
+ if search and search.lower() not in f"{title}\n{path.name}".lower():
709
+ continue
710
+ card_key = _library_card_key("blueprints", path)
711
+ header_cols = st.columns([0.93, 0.07], vertical_alignment="center")
712
+ with header_cols[0]:
713
+ with st.expander(f"{title} — {path.relative_to(BLUEPRINTS)}"):
714
+ st.caption(f"Ficheiro: {path}")
715
+ st.json(data)
716
+ with header_cols[1]:
717
+ _render_card_pencil(f"rename_blueprints_{card_key}")
718
+ _render_library_name_editor("blueprints", path, title)
676
719
  except Exception as exc:
677
720
  with st.expander(f"Inválido — {path.name}"):
678
721
  st.error(str(exc))
@@ -752,7 +795,8 @@ def render_tiktok_prompt_masters():
752
795
  content = load_prompt_master_file(path)
753
796
  except (OSError, ValueError):
754
797
  content = ""
755
- if search and search.lower() not in f"{path.name}\n{content}".lower():
798
+ display_heading = get_display_name("prompt_masters", path, next((line.lstrip("#").strip() for line in content.splitlines() if line.startswith("#")), path.stem))
799
+ if search and search.lower() not in f"{display_heading}\n{path.name}\n{content}".lower():
756
800
  continue
757
801
  visible_files.append(path)
758
802
  if not visible_files:
@@ -760,24 +804,31 @@ def render_tiktok_prompt_masters():
760
804
  for path in visible_files:
761
805
  try:
762
806
  content = load_prompt_master_file(path)
763
- heading = next((line.lstrip("#").strip() for line in content.splitlines() if line.startswith("#")), path.stem)
764
- with st.expander(f"{heading} — {path.name}", expanded=False):
765
- st.caption(f"Ficheiro TikTok: `{path}`")
766
- edited_content = st.text_area("Conteúdo Markdown", value=content, height=360, key=f"tiktok_prompt_master_editor_{path.stem}")
767
- prompt_cols = st.columns(3)
768
- with prompt_cols[0]:
769
- if st.button("Guardar alterações", type="primary", use_container_width=True, key=f"save_prompt_master_{path.stem}"):
770
- path.write_text(edited_content.rstrip() + "\n", encoding="utf-8")
771
- st.success("Prompt Master actualizado.")
772
- st.rerun()
773
- with prompt_cols[1]:
774
- st.download_button("Descarregar", data=content.encode("utf-8"), file_name=path.name, mime="text/markdown", use_container_width=True, key=f"download_prompt_master_{path.stem}")
775
- with prompt_cols[2]:
776
- if st.button("Apagar", use_container_width=True, key=f"delete_prompt_master_{path.stem}"):
777
- path.unlink(missing_ok=True)
778
- st.success("Prompt Master removido da biblioteca TikTok.")
779
- st.rerun()
780
- st.markdown(content)
807
+ fallback_heading = next((line.lstrip("#").strip() for line in content.splitlines() if line.startswith("#")), path.stem)
808
+ heading = get_display_name("prompt_masters", path, fallback_heading)
809
+ card_key = _library_card_key("prompt_masters", path)
810
+ header_cols = st.columns([0.93, 0.07], vertical_alignment="center")
811
+ with header_cols[0]:
812
+ with st.expander(f"{heading} — {path.name}", expanded=False):
813
+ st.caption(f"Ficheiro TikTok: `{path}`")
814
+ edited_content = st.text_area("Conteúdo Markdown", value=content, height=360, key=f"tiktok_prompt_master_editor_{path.stem}")
815
+ prompt_cols = st.columns(3)
816
+ with prompt_cols[0]:
817
+ if st.button("Guardar alterações", type="primary", use_container_width=True, key=f"save_prompt_master_{path.stem}"):
818
+ path.write_text(edited_content.rstrip() + "\n", encoding="utf-8")
819
+ st.success("Prompt Master actualizado.")
820
+ st.rerun()
821
+ with prompt_cols[1]:
822
+ st.download_button("Descarregar", data=content.encode("utf-8"), file_name=path.name, mime="text/markdown", use_container_width=True, key=f"download_prompt_master_{path.stem}")
823
+ with prompt_cols[2]:
824
+ if st.button("Apagar", use_container_width=True, key=f"delete_prompt_master_{path.stem}"):
825
+ path.unlink(missing_ok=True)
826
+ st.success("Prompt Master removido da biblioteca TikTok.")
827
+ st.rerun()
828
+ st.markdown(content)
829
+ with header_cols[1]:
830
+ _render_card_pencil(f"rename_prompt_masters_{card_key}")
831
+ _render_library_name_editor("prompt_masters", path, heading)
781
832
  except (OSError, ValueError) as exc:
782
833
  with st.expander(f"Ficheiro inválido — {path.name}"):
783
834
  st.error(str(exc))
@@ -1317,11 +1368,30 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1317
1368
  if variants:
1318
1369
  labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
1319
1370
  selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"new_video_general_thumbnail_{channel['id']}")
1320
- variant = variants[labels.index(selected_variant_label)]
1371
+ variant_index = labels.index(selected_variant_label)
1372
+ variant = variants[variant_index]
1321
1373
  payload["thumbnail_variant"] = variant
1322
1374
  payload["thumbnail_prompt"] = variant.get("image_prompt", "")
1323
1375
  payload["thumbnail_text"] = variant.get("overlay_text", "")
1324
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.")
1325
1395
  st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
1326
1396
  else:
1327
1397
  topic_for_creative = str(st.session_state.get("new_video_topic", "") or "").strip()
@@ -1351,13 +1421,31 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1351
1421
  if variants:
1352
1422
  labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
1353
1423
  selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key="new_video_thumbnail_choice")
1354
- variant = variants[labels.index(selected_variant_label)]
1424
+ variant_index = labels.index(selected_variant_label)
1425
+ variant = variants[variant_index]
1355
1426
  payload["thumbnail_variant"] = variant
1356
1427
  payload["thumbnail_prompt"] = variant.get("image_prompt", "")
1357
1428
  payload["thumbnail_text"] = variant.get("overlay_text", "")
1358
1429
  st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
1359
1430
  st.code(variant.get("image_prompt", ""), language="text")
1360
- 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.")
1361
1449
  st.session_state["new_video_creative_payload"] = payload
1362
1450
 
1363
1451
  with st.form("new_video_form"):
@@ -2873,6 +2961,16 @@ def render_settings():
2873
2961
  proxy_https = text_setting("Proxy HTTPS", "proxy_https")
2874
2962
  match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
2875
2963
 
2964
+ with st.expander("Nano Banana — geração de thumbnails", expanded=True):
2965
+ 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.")
2966
+ nano_cols = st.columns(2)
2967
+ with nano_cols[0]:
2968
+ 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.")
2969
+ 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)
2970
+ with nano_cols[1]:
2971
+ 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)
2972
+ 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)
2973
+
2876
2974
  with st.expander("LLM — providers e modelos", expanded=True):
2877
2975
  provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
2878
2976
  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)
@@ -3013,6 +3111,7 @@ def render_settings():
3013
3111
  "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
3014
3112
  "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
3015
3113
  "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
3114
+ "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,
3016
3115
  "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
3017
3116
  "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
3018
3117
  "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
@@ -24,6 +24,7 @@ DEFAULTS: dict[str, Any] = {
24
24
  "queues.json": {"niche": [], "blueprint": [], "brand": [], "script": [], "title": [], "thumbnail": [], "video": [], "edit": [], "upload": []},
25
25
  "batches.json": [],
26
26
  "uploads.json": [],
27
+ "display_names.json": {"blueprints": {}, "prompt_masters": {}},
27
28
  "niche_apify_runs.json": [],
28
29
  "metadata_edits.json": [],
29
30
  "python_editor_edits.json": [],
@@ -108,6 +109,10 @@ DEFAULTS: dict[str, Any] = {
108
109
  "openai_model_name": "",
109
110
  "gemini_api_key": "",
110
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",
111
116
  "deepseek_api_key": "",
112
117
  "deepseek_base_url": "",
113
118
  "deepseek_model_name": "",
@@ -306,6 +311,53 @@ def append_json(name: str, item: dict[str, Any]) -> dict[str, Any]:
306
311
  return item
307
312
 
308
313
 
314
+ def _display_name_key(kind: str, path: Path) -> str:
315
+ """Return a stable storage-relative key without renaming the physical file."""
316
+ resolved = path.resolve()
317
+ if kind == "blueprints":
318
+ root = BLUEPRINTS.resolve()
319
+ try:
320
+ return resolved.relative_to(root).as_posix()
321
+ except ValueError as exc:
322
+ raise ValueError("O ficheiro não pertence ao storage de Blueprints.") from exc
323
+ if kind == "prompt_masters":
324
+ root = TIKTOK_PROMPT_MASTERS.resolve()
325
+ try:
326
+ return resolved.relative_to(root).as_posix()
327
+ except ValueError as exc:
328
+ raise ValueError("O ficheiro não pertence ao storage de Prompt Masters.") from exc
329
+ raise ValueError(f"Tipo de biblioteca inválido: {kind}")
330
+
331
+
332
+ def get_display_name(kind: str, path: Path, fallback: str) -> str:
333
+ names = read_json("display_names.json", {"blueprints": {}, "prompt_masters": {}})
334
+ if not isinstance(names, dict):
335
+ return fallback
336
+ entries = names.get(kind, {})
337
+ if not isinstance(entries, dict):
338
+ return fallback
339
+ value = str(entries.get(_display_name_key(kind, path)) or "").strip()
340
+ return value or fallback
341
+
342
+
343
+ def set_display_name(kind: str, path: Path, name: str) -> str:
344
+ clean_name = " ".join(str(name).split()).strip()
345
+ if not clean_name:
346
+ raise ValueError("Informe um nome para a biblioteca.")
347
+ if len(clean_name) > 120:
348
+ raise ValueError("O nome deve ter no máximo 120 caracteres.")
349
+ names = read_json("display_names.json", {"blueprints": {}, "prompt_masters": {}})
350
+ if not isinstance(names, dict):
351
+ names = {"blueprints": {}, "prompt_masters": {}}
352
+ entries = names.get(kind)
353
+ if not isinstance(entries, dict):
354
+ entries = {}
355
+ names[kind] = entries
356
+ entries[_display_name_key(kind, path)] = clean_name
357
+ write_json("display_names.json", names)
358
+ return clean_name
359
+
360
+
309
361
  def list_blueprint_files() -> list[Path]:
310
362
  ensure_storage()
311
363
  return sorted(BLUEPRINTS.rglob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
@@ -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.63",
3
+ "version": "0.2.65",
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",