@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 +131 -32
- package/hermes_ui/storage.py +52 -0
- package/hermes_ui/thumbnail_generation.py +158 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
with
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
st.
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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