@danhachuel/thunderbolt 0.2.64 → 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 +52 -3
- package/hermes_ui/storage.py +4 -0
- package/hermes_ui/thumbnail_generation.py +158 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"):
|
|
@@ -2923,6 +2961,16 @@ def render_settings():
|
|
|
2923
2961
|
proxy_https = text_setting("Proxy HTTPS", "proxy_https")
|
|
2924
2962
|
match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
|
|
2925
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
|
+
|
|
2926
2974
|
with st.expander("LLM — providers e modelos", expanded=True):
|
|
2927
2975
|
provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
|
|
2928
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)
|
|
@@ -3063,6 +3111,7 @@ def render_settings():
|
|
|
3063
3111
|
"log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
|
|
3064
3112
|
"endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
|
|
3065
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,
|
|
3066
3115
|
"azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
|
|
3067
3116
|
"siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
|
|
3068
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,
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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