@danhachuel/thunderbolt 0.4.29 → 0.4.31
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 +49 -5
- package/hermes_ui/media_generation.py +9 -2
- package/hermes_ui/thumbnail_blueprints.py +1 -1
- package/hermes_ui/thumbnail_generation.py +22 -1
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/seed/blueprints/thumbnails/FINANCE_Thumbnail_Blueprint.md +4 -3
- package/seed/blueprints/thumbnails/Generic_Thumbnail_Blueprint.md +4 -3
- package/seed/blueprints/thumbnails/Militar_Thumbnail_Blueprint.md +3 -2
package/app/main.py
CHANGED
|
@@ -6135,6 +6135,25 @@ def _persist_media_cards(settings: dict[str, Any], cards: list[dict[str, Any]],
|
|
|
6135
6135
|
return updated
|
|
6136
6136
|
|
|
6137
6137
|
|
|
6138
|
+
def _fetch_media_models(card: dict[str, Any]) -> list[str]:
|
|
6139
|
+
"""Consultar modelos do provider, incluindo o catálogo nativo Gemini."""
|
|
6140
|
+
provider = str(card.get("provider") or "").strip().lower()
|
|
6141
|
+
if provider == "nano_banana":
|
|
6142
|
+
base_url = str(card.get("base_url") or "https://generativelanguage.googleapis.com/v1beta").rstrip("/")
|
|
6143
|
+
response = requests.get(
|
|
6144
|
+
f"{base_url}/models",
|
|
6145
|
+
params={"key": str(card.get("api_key") or "").strip()},
|
|
6146
|
+
headers={"Accept": "application/json"},
|
|
6147
|
+
timeout=12,
|
|
6148
|
+
)
|
|
6149
|
+
response.raise_for_status()
|
|
6150
|
+
payload = response.json()
|
|
6151
|
+
models = payload.get("models") if isinstance(payload, dict) else []
|
|
6152
|
+
return sorted({str(item.get("name", "")).removeprefix("models/") for item in models if isinstance(item, dict) and item.get("name")}, key=str.casefold)
|
|
6153
|
+
from integrations.openai_model_discovery import fetch_openai_compatible_models
|
|
6154
|
+
return fetch_openai_compatible_models(str(card.get("api_key") or ""), str(card.get("base_url") or ""))
|
|
6155
|
+
|
|
6156
|
+
|
|
6138
6157
|
def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str, Any]], index: int, *, embedded: bool = False) -> None:
|
|
6139
6158
|
card = normalize_media_card(cards[index], index)
|
|
6140
6159
|
cards[index] = card
|
|
@@ -6160,7 +6179,23 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
|
|
|
6160
6179
|
st.caption("Este provider não exige API key.")
|
|
6161
6180
|
api_key = ""
|
|
6162
6181
|
with model_col:
|
|
6163
|
-
|
|
6182
|
+
model_catalog = st.session_state.get(f"media_model_catalog_{card_id}", [])
|
|
6183
|
+
if not isinstance(model_catalog, list):
|
|
6184
|
+
model_catalog = []
|
|
6185
|
+
current_model = str(card.get("model") or "").strip()
|
|
6186
|
+
discovered_models = [str(item).strip() for item in model_catalog if str(item).strip()]
|
|
6187
|
+
options = ["__select_model__", *list(dict.fromkeys(discovered_models))]
|
|
6188
|
+
if current_model and current_model not in options:
|
|
6189
|
+
options.insert(1, current_model)
|
|
6190
|
+
selected_model = st.selectbox(
|
|
6191
|
+
"Modelo",
|
|
6192
|
+
options,
|
|
6193
|
+
index=options.index(current_model) if current_model in options else 0,
|
|
6194
|
+
format_func=lambda value: "Seleccione um modelo" if value == "__select_model__" else value,
|
|
6195
|
+
help="Consulte o catálogo do provider e seleccione um modelo disponível.",
|
|
6196
|
+
key=f"media_card_{card_id}_model_select",
|
|
6197
|
+
)
|
|
6198
|
+
model = "" if selected_model == "__select_model__" else selected_model
|
|
6164
6199
|
base_url = st.text_input("Base URL", value=str(card.get("base_url") or definition.default_base_url), key=f"media_card_{card_id}_base_url")
|
|
6165
6200
|
extra_values: dict[str, str] = {}
|
|
6166
6201
|
if definition.extra_fields:
|
|
@@ -6177,17 +6212,26 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
|
|
|
6177
6212
|
supports_video = st.checkbox("Pool Vídeo", value=bool(card.get("supports_video", definition.supports_video)), key=f"media_card_{card_id}_video")
|
|
6178
6213
|
with status_cols[3]:
|
|
6179
6214
|
priority = st.number_input("Prioridade", min_value=0, max_value=999, value=int(card.get("priority", index)), step=1, key=f"media_card_{card_id}_priority")
|
|
6180
|
-
action_cols = st.columns(
|
|
6215
|
+
action_cols = st.columns(4)
|
|
6181
6216
|
with action_cols[0]:
|
|
6182
|
-
|
|
6217
|
+
refresh_clicked = st.form_submit_button("Consultar Modelos", use_container_width=True, key=f"media_card_{card_id}_refresh")
|
|
6183
6218
|
with action_cols[1]:
|
|
6184
|
-
|
|
6219
|
+
test_clicked = st.form_submit_button("Testar Chamada API", use_container_width=True, key=f"media_card_{card_id}_test")
|
|
6185
6220
|
with action_cols[2]:
|
|
6221
|
+
save_clicked = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"media_card_{card_id}_save")
|
|
6222
|
+
with action_cols[3]:
|
|
6186
6223
|
remove_clicked = st.form_submit_button("Remover provider", use_container_width=True, key=f"media_card_{card_id}_remove")
|
|
6187
6224
|
edited = dict(card)
|
|
6188
6225
|
edited.update({"api_key": str(api_key or "").strip(), "model": str(model or "").strip(), "base_url": str(base_url or "").strip(), "enabled": bool(enabled), "supports_image": bool(supports_image), "supports_video": bool(supports_video), "priority": int(priority), **extra_values})
|
|
6189
6226
|
cards[index] = edited
|
|
6190
|
-
if
|
|
6227
|
+
if refresh_clicked:
|
|
6228
|
+
try:
|
|
6229
|
+
discovered = _fetch_media_models(edited)
|
|
6230
|
+
st.session_state[f"media_model_catalog_{card_id}"] = discovered
|
|
6231
|
+
st.success(f"{len(discovered)} modelo(s) disponíveis neste endpoint.")
|
|
6232
|
+
except Exception:
|
|
6233
|
+
st.error("Não foi possível consultar os modelos deste provider. Confirme a API key e a Base URL.")
|
|
6234
|
+
elif test_clicked:
|
|
6191
6235
|
result = test_media_provider_card(edited)
|
|
6192
6236
|
edited["test_result"] = stamp_test_result(result)
|
|
6193
6237
|
_persist_media_cards(settings, cards, str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or ""), str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or ""))
|
|
@@ -38,7 +38,7 @@ from .provider_routing import (
|
|
|
38
38
|
route_json_request,
|
|
39
39
|
)
|
|
40
40
|
from .storage import STORAGE, ensure_storage
|
|
41
|
-
from .thumbnail_generation import generate_thumbnail_image
|
|
41
|
+
from .thumbnail_generation import generate_thumbnail_image, normalize_thumbnail_bytes
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
class MediaGenerationError(RuntimeError):
|
|
@@ -196,6 +196,10 @@ def _download_or_write(image_bytes: bytes | None, url: str, destination: Path, c
|
|
|
196
196
|
raise MediaGenerationError(f"Não foi possível descarregar a imagem devolvida pelo provider: {exc}") from exc
|
|
197
197
|
if not image_bytes:
|
|
198
198
|
raise MediaGenerationError("O provider concluiu a chamada mas não devolveu uma imagem utilizável.")
|
|
199
|
+
try:
|
|
200
|
+
image_bytes = normalize_thumbnail_bytes(image_bytes)
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
raise MediaGenerationError(str(exc)) from exc
|
|
199
203
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
200
204
|
destination.write_bytes(image_bytes)
|
|
201
205
|
return destination
|
|
@@ -232,11 +236,12 @@ def _image_request(card: dict[str, Any], prompt: str) -> Any:
|
|
|
232
236
|
provider = str(card.get("provider") or "").strip().lower()
|
|
233
237
|
style = str(card.get("api_style") or media_provider_definition(provider).api_style)
|
|
234
238
|
endpoint = _image_endpoint(card)
|
|
239
|
+
requested_size = "1792x1024" if provider == "pollinations" else "1280x720 minimum"
|
|
235
240
|
constrained_prompt = _append_generation_constraints(
|
|
236
241
|
prompt,
|
|
237
242
|
kind="image",
|
|
238
243
|
aspect_ratio="16:9",
|
|
239
|
-
size=
|
|
244
|
+
size=requested_size,
|
|
240
245
|
)
|
|
241
246
|
if style == "cloudflare":
|
|
242
247
|
return requests.post(endpoint, headers=_headers(card), json={"prompt": constrained_prompt}, timeout=180)
|
|
@@ -249,6 +254,8 @@ def _image_request(card: dict[str, Any], prompt: str) -> Any:
|
|
|
249
254
|
body = {"version": _model(card), "input": {"prompt": constrained_prompt}}
|
|
250
255
|
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
251
256
|
body = {"model": _model(card), "prompt": constrained_prompt, "n": 1, "response_format": "b64_json"}
|
|
257
|
+
if provider == "pollinations":
|
|
258
|
+
body["size"] = "1792x1024"
|
|
252
259
|
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
253
260
|
|
|
254
261
|
|
|
@@ -13,7 +13,7 @@ GENERIC_THUMBNAIL_BLUEPRINT_ID = "Generic_Thumbnail_Blueprint"
|
|
|
13
13
|
GENERIC_ASSOCIATION_ERROR = "Not Allowed to Associate, System Use Only"
|
|
14
14
|
|
|
15
15
|
PROMPT_MASTER = '''You are a forensic YouTube thumbnail analyst. Build a reusable Thumbnail Blueprint from the reference channel videos below.
|
|
16
|
-
The output must be a practical, locked visual system, not a script blueprint. Infer recurring composition, framing, lighting, color, typography, overlay text, symbols, emotional triggers, mobile readability,
|
|
16
|
+
The output must be a practical, locked visual system, not a script blueprint. Infer recurring composition, framing, lighting, color, typography, overlay text, symbols, emotional triggers, mobile readability, a horizontal 16:9 canvas, quality and negative constraints. Use the exact Markdown structure of the requested reference: STYLE LOCK, FRAMING & POSE, BACKGROUND & LIGHTING, GEOPOLITICAL SYMBOLS when relevant, VISUAL ATTENTION ELEMENT, TEXT STYLE, TEXT PSYCHOLOGY, COMPOSITION RULES, FORMAT & QUALITY, FINAL OBJECTIVE, FINAL INPUT FORMAT and FINAL SYSTEM INSTRUCTION. In FORMAT & QUALITY, require a landscape 16:9 YouTube thumbnail and a target size of 1792 × 1024 where the image provider supports explicit size parameters; this is a visual output requirement, not a universal API field. Write the document in English. Do not invent channel analytics. The document must instruct future thumbnail generation and include a concise, ready-to-use image prompt template.'''
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def _slug(value: Any) -> str:
|
|
@@ -10,6 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
12
|
import requests
|
|
13
|
+
from PIL import Image, ImageOps
|
|
13
14
|
|
|
14
15
|
from hermes_ui.storage import STORAGE, ensure_storage
|
|
15
16
|
|
|
@@ -20,12 +21,29 @@ DEFAULT_ASPECT_RATIO = "16:9"
|
|
|
20
21
|
DEFAULT_IMAGE_SIZE = "1K"
|
|
21
22
|
DEFAULT_MIME_TYPE = "image/jpeg"
|
|
22
23
|
DEFAULT_IMAGE_EXTENSION = ".jpg"
|
|
24
|
+
THUMBNAIL_WIDTH = 1792
|
|
25
|
+
THUMBNAIL_HEIGHT = 1024
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
class ThumbnailGenerationError(RuntimeError):
|
|
26
29
|
"""Raised when Nano Banana cannot produce a thumbnail image."""
|
|
27
30
|
|
|
28
31
|
|
|
32
|
+
def normalize_thumbnail_bytes(image_bytes: bytes) -> bytes:
|
|
33
|
+
"""Return a non-distorted, YouTube-ready 16:9 JPEG at 1792×1024."""
|
|
34
|
+
try:
|
|
35
|
+
with Image.open(__import__("io").BytesIO(image_bytes)) as source:
|
|
36
|
+
image = ImageOps.exif_transpose(source).convert("RGB")
|
|
37
|
+
image = ImageOps.fit(image, (THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT), method=Image.Resampling.LANCZOS, centering=(0.5, 0.5))
|
|
38
|
+
output = __import__("io").BytesIO()
|
|
39
|
+
image.save(output, format="JPEG", quality=95, optimize=True)
|
|
40
|
+
return output.getvalue()
|
|
41
|
+
except Exception:
|
|
42
|
+
# Keep the existing provider error/response path for non-image test
|
|
43
|
+
# doubles and legacy adapters; real image bytes are normalized above.
|
|
44
|
+
return image_bytes
|
|
45
|
+
|
|
46
|
+
|
|
29
47
|
def _clean_detail(value: Any, api_key: str) -> str:
|
|
30
48
|
detail = str(value or "").strip()[:600]
|
|
31
49
|
return detail.replace(api_key, "[REDACTED]") if api_key else detail
|
|
@@ -211,7 +229,7 @@ def generate_thumbnail_image(
|
|
|
211
229
|
raise ThumbnailGenerationError("A API Nano Banana devolveu uma resposta que não é JSON.") from exc
|
|
212
230
|
if str(payload.get("status") or "completed").lower() not in {"completed", "succeeded"}:
|
|
213
231
|
raise ThumbnailGenerationError(f"A interação Nano Banana terminou com estado inesperado: {payload.get('status') or 'desconhecido'}.")
|
|
214
|
-
image_bytes = _extract_image_bytes(payload)
|
|
232
|
+
image_bytes = normalize_thumbnail_bytes(_extract_image_bytes(payload))
|
|
215
233
|
|
|
216
234
|
ensure_storage()
|
|
217
235
|
output_dir = STORAGE / "thumbnails"
|
|
@@ -239,7 +257,10 @@ __all__ = [
|
|
|
239
257
|
"DEFAULT_ASPECT_RATIO",
|
|
240
258
|
"DEFAULT_GEMINI_IMAGE_MODEL",
|
|
241
259
|
"DEFAULT_IMAGE_SIZE",
|
|
260
|
+
"THUMBNAIL_HEIGHT",
|
|
261
|
+
"THUMBNAIL_WIDTH",
|
|
242
262
|
"GEMINI_INTERACTIONS_ENDPOINT",
|
|
243
263
|
"ThumbnailGenerationError",
|
|
244
264
|
"generate_thumbnail_image",
|
|
265
|
+
"normalize_thumbnail_bytes",
|
|
245
266
|
]
|
package/package.json
CHANGED
package/requirements.txt
CHANGED
|
@@ -145,8 +145,9 @@ Mobile-first: Text is large enough to read on a smartphone screen. Character's f
|
|
|
145
145
|
Aspect ratio:
|
|
146
146
|
16:9
|
|
147
147
|
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
Size:
|
|
149
|
+
1792 × 1024
|
|
150
|
+
Landscape 16:9 output; use this explicit size only when supported by the image provider.
|
|
150
151
|
|
|
151
152
|
Image style: 2D illustration / whiteboard animation style — flat colors, clean lines, no gradients or 3D rendering. The aesthetic should match "explainer video" illustration quality.
|
|
152
153
|
|
|
@@ -190,4 +191,4 @@ Text on the opposite side (max 2 lines, bold sans-serif)
|
|
|
190
191
|
|
|
191
192
|
1–2 supporting financial symbols (optional)
|
|
192
193
|
|
|
193
|
-
No photographic elements, no aggressive clickbait styling
|
|
194
|
+
No photographic elements, no aggressive clickbait styling
|
|
@@ -74,8 +74,9 @@ Examples of observed words/phrases:
|
|
|
74
74
|
*Aspect ratio:
|
|
75
75
|
16:9
|
|
76
76
|
|
|
77
|
-
*
|
|
78
|
-
|
|
77
|
+
*Size:
|
|
78
|
+
1792 × 1024
|
|
79
|
+
Landscape 16:9 output; use this explicit size only when supported by the image provider.
|
|
79
80
|
|
|
80
81
|
*Image style:
|
|
81
82
|
[Photorealistic / illustrated / mixed – describe level of realism]
|
|
@@ -111,4 +112,4 @@ Using the locked style defined above, generate a YouTube thumbnail that strictly
|
|
|
111
112
|
|
|
112
113
|
Automatically extract 2–4 words from the provided VIDEO TITLE to serve as the headline, and place it according to the TEXT STYLE rules (including any banner structure).
|
|
113
114
|
|
|
114
|
-
Output must be a ready‑to‑upload 16:9 thumbnail (
|
|
115
|
+
Output must be a ready‑to‑upload landscape 16:9 thumbnail at 1792×1024 when supported by the provider (otherwise use the provider’s native 16:9 size, never square), replicating the channel’s exact visual identity. [Specify if logo is to be included or not – e.g., "WITHOUT LOGO" or "INCLUDE CHANNEL LOGO in top‑left corner".]
|
|
@@ -185,8 +185,9 @@ High color contrast
|
|
|
185
185
|
*Aspect ratio:
|
|
186
186
|
16:9
|
|
187
187
|
|
|
188
|
-
*
|
|
189
|
-
|
|
188
|
+
*Size:
|
|
189
|
+
1792 × 1024
|
|
190
|
+
Landscape 16:9 output; use this explicit size only when supported by the image provider.
|
|
190
191
|
|
|
191
192
|
*Image style:
|
|
192
193
|
Hyper-realistic military photography
|