@danhachuel/thunderbolt 0.4.30 → 0.4.32

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
@@ -4199,10 +4199,12 @@ def render_thumbnails():
4199
4199
  ):
4200
4200
  try:
4201
4201
  with st.spinner("A refazer apenas o lettering…"):
4202
+ channel, _blueprint = _thumbnail_editor_context(record)
4202
4203
  _task, generated_path = regenerate_thumbnail_lettering(
4203
4204
  task_id,
4204
4205
  settings,
4205
4206
  lettering_prompt=record.get("lettering_prompt") or "",
4207
+ language=str(record.get("language") or channel.get("language") or current_ui_language()),
4206
4208
  )
4207
4209
  record_notification(
4208
4210
  "thumbnail_generation_completed",
@@ -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="1280x720 minimum",
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, aspect ratio, 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. 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.'''
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
  ]
@@ -7,7 +7,7 @@ import uuid
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
- from .creative_generation import generate_thumbnail_prompt
10
+ from .creative_generation import _language_instruction, generate_thumbnail_prompt
11
11
  from .media_generation import generate_image_from_pool
12
12
  from .media_providers import media_cards_for_pool
13
13
  from .storage import STORAGE, ensure_storage, now, read_json, update_json
@@ -96,6 +96,7 @@ def normalize_thumbnail_task(task: dict[str, Any]) -> dict[str, Any]:
96
96
  "topic": str(task.get("topic") or "").strip(),
97
97
  "channel_id": str(task.get("channel_id") or "").strip(),
98
98
  "channel_name": str(task.get("channel_name") or "Canal sem nome").strip(),
99
+ "language": str(task.get("language") or "").strip(),
99
100
  "blueprint_id": str(task.get("blueprint_id") or "").strip(),
100
101
  "blueprint_name": str(task.get("blueprint_name") or "").strip(),
101
102
  "thumbnail_blueprint_id": str(task.get("thumbnail_blueprint_id") or "").strip(),
@@ -348,6 +349,7 @@ def regenerate_thumbnail_lettering(
348
349
  task_id: str,
349
350
  settings: dict[str, Any],
350
351
  lettering_prompt: str = "",
352
+ language: str = "",
351
353
  ) -> tuple[dict[str, Any], Path]:
352
354
  """Edit only the lettering while sending the existing image as a Nano Banana reference."""
353
355
  tasks = read_json("tasks.json", [])
@@ -357,9 +359,12 @@ def regenerate_thumbnail_lettering(
357
359
  previous_image = record.get("image_path")
358
360
  if not previous_image or not previous_image.is_file():
359
361
  raise ThumbnailGenerationError("A thumbnail precisa de uma imagem existente para refazer o lettering.")
362
+ requested_language = str(language or record.get("language") or "Português").strip()
363
+ language_instruction = _language_instruction(requested_language)
360
364
  base_prompt = record["prompt"] or "Cria uma thumbnail de YouTube cinematográfica e de alto contraste."
361
365
  edit_prompt = str(lettering_prompt or "").strip() or (
362
366
  f"Refaz apenas o lettering da thumbnail para o vídeo {record['title']!r}. "
367
+ f"Escreve obrigatoriamente em {language_instruction}. "
363
368
  "Escolhe uma frase curta e forte, com no máximo quatro palavras, relacionada com o título."
364
369
  )
365
370
  combined_prompt = (
@@ -368,6 +373,8 @@ def regenerate_thumbnail_lettering(
368
373
  f"{base_prompt}\n\n"
369
374
  "LETTERING EDIT LAYER — altera exclusivamente o texto/lettering visível da thumbnail. "
370
375
  "Mantém tudo o que pertence à BASE IMAGE LAYER pixelmente tão próximo quanto possível, sem mudar a imagem.\n"
376
+ f"LANGUAGE LOCK — todo o texto visível novo deve estar obrigatoriamente em {language_instruction}. "
377
+ "Não uses inglês nem traduzas para outro idioma.\n"
371
378
  f"{edit_prompt}\n"
372
379
  "Não adicionar logótipos, marcas de água ou outros elementos."
373
380
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.30",
3
+ "version": "0.4.32",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",
package/requirements.txt CHANGED
@@ -1,5 +1,6 @@
1
1
  streamlit>=1.37,<2
2
2
  requests[socks]>=2.31,<3
3
+ Pillow>=10,<12
3
4
  pandas>=2.0,<3
4
5
  imageio-ffmpeg>=0.6,<1
5
6
  toml>=0.10,<1
@@ -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
- Resolution:
149
- 1280 × 720 minimum
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
- *Resolution:
78
- 1280 × 720 minimum
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 (minimum 1280×720) 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".]
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
- *Resolution:
189
- 1280 × 720 minimum
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