@danhachuel/thunderbolt 0.3.25 → 0.3.26

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
@@ -2152,7 +2152,16 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2152
2152
  thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
2153
2153
  if st.button("Gerar imagem com Nano Banana", key=f"{prefix}_general_generate_thumbnail_{channel['id']}", use_container_width=True):
2154
2154
  try:
2155
- thumbnail_path = str(generate_thumbnail_image(read_json("settings.json", {}), variant.get("image_prompt", ""), topic=str(payload.get("topic") or ""), variant_index=variant_index))
2155
+ thumbnail_path = str(
2156
+ generate_thumbnail_image(
2157
+ read_json("settings.json", {}),
2158
+ variant.get("image_prompt", ""),
2159
+ topic=str(payload.get("topic") or ""),
2160
+ variant_index=variant_index,
2161
+ lettering_text=str(variant.get("overlay_text") or payload.get("thumbnail_text") or ""),
2162
+ lettering_prompt=str(variant.get("lettering_prompt") or ""),
2163
+ )
2164
+ )
2156
2165
  variant["image_path"] = thumbnail_path
2157
2166
  payload["thumbnail_path"] = thumbnail_path
2158
2167
  payload["thumbnail_status"] = "generated"
@@ -2216,7 +2225,16 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2216
2225
  thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
2217
2226
  if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", use_container_width=True):
2218
2227
  try:
2219
- thumbnail_path = str(generate_thumbnail_image(read_json("settings.json", {}), variant.get("image_prompt", ""), topic=str(payload.get("topic") or ""), variant_index=variant_index))
2228
+ thumbnail_path = str(
2229
+ generate_thumbnail_image(
2230
+ read_json("settings.json", {}),
2231
+ variant.get("image_prompt", ""),
2232
+ topic=str(payload.get("topic") or ""),
2233
+ variant_index=variant_index,
2234
+ lettering_text=str(variant.get("overlay_text") or payload.get("thumbnail_text") or ""),
2235
+ lettering_prompt=str(variant.get("lettering_prompt") or ""),
2236
+ )
2237
+ )
2220
2238
  variant["image_path"] = thumbnail_path
2221
2239
  payload["thumbnail_path"] = thumbnail_path
2222
2240
  payload["thumbnail_status"] = "generated"
@@ -266,7 +266,7 @@ def generate_thumbnail_prompt(
266
266
  "És um director de arte de thumbnails para YouTube. Refaz um prompt de imagem forte e específico "
267
267
  "para o tópico fornecido, mantendo a intenção visual quando já existir um prompt. "
268
268
  "Separa rigorosamente a imagem sem texto do lettering: image_prompt nunca deve pedir texto renderizado, "
269
- "enquanto overlay_text deve ser curto, legível e ter no máximo quatro palavras. "
269
+ "enquanto overlay_text é obrigatório, deve ser curto, legível e ter entre três e quatro palavras. "
270
270
  "Responde apenas com JSON válido nas chaves concept, overlay_text, composition, color_palette, subject, "
271
271
  "image_prompt, title_synergy e lettering_prompt."
272
272
  )
@@ -279,8 +279,8 @@ def generate_thumbnail_prompt(
279
279
  "reference_rules": reference_bundle(),
280
280
  "requirements": {
281
281
  "image_prompt": "cinematic visual direction, no words, letters, logos or watermarks rendered in the image",
282
- "overlay_text": "maximum 4 words, emotionally strong, not the full title",
283
- "lettering_prompt": "instructions for changing only the lettering after the base image exists",
282
+ "overlay_text": "required, 3 to 4 words, emotionally strong, not the full title",
283
+ "lettering_prompt": "required instructions for rendering the exact overlay_text after the base image exists",
284
284
  },
285
285
  },
286
286
  ensure_ascii=False,
@@ -289,7 +289,11 @@ def generate_thumbnail_prompt(
289
289
  image_prompt = str(result.get("image_prompt") or "").strip()
290
290
  if not image_prompt:
291
291
  raise CreativeGenerationError("O provider não devolveu um prompt de imagem válido.")
292
- overlay_text = _short_overlay(result.get("overlay_text"))
292
+ overlay_text = _short_overlay(result.get("overlay_text")) or _short_overlay(topic) or "WATCH NOW"
293
+ lettering_prompt = str(result.get("lettering_prompt") or "").strip() or (
294
+ "Render the exact overlay_text as bold, readable, high-contrast sans-serif lettering with an outline or shadow, "
295
+ "inside a safe zone and away from the main face or subject."
296
+ )
293
297
  return {
294
298
  "concept": str(result.get("concept") or "Thumbnail renovada").strip(),
295
299
  "overlay_text": overlay_text,
@@ -298,7 +302,7 @@ def generate_thumbnail_prompt(
298
302
  "subject": str(result.get("subject") or topic).strip(),
299
303
  "image_prompt": image_prompt,
300
304
  "title_synergy": str(result.get("title_synergy") or "").strip(),
301
- "lettering_prompt": str(result.get("lettering_prompt") or "").strip(),
305
+ "lettering_prompt": lettering_prompt,
302
306
  "status": "prompt_ready",
303
307
  }
304
308
 
@@ -317,7 +321,7 @@ def generate_creative_package(
317
321
  "És director editorial e de thumbnails para YouTube. Cria um pacote coerente de título e thumbnail "
318
322
  "para o tópico fornecido. Gera exactamente pelo menos 20 títulos candidatos e entre 3 e 5 variantes de thumbnail. "
319
323
  "O título deve carregar keywords no início, ter curiosidade, especificidade e emoção, sem clickbait falso. "
320
- "A thumbnail deve ter no máximo três elementos, alto contraste, uma composição clara, texto opcional de até 4 palavras, "
324
+ "A thumbnail deve ter no máximo três elementos, alto contraste, uma composição clara e lettering obrigatório de 3 a 4 palavras, "
321
325
  "safe zones e leitura em 120px. O texto da thumbnail não pode repetir o título integralmente. Remove AI tells. "
322
326
  "Responde apenas com JSON válido nas chaves selected_title, title_candidates, keywords e thumbnail_variants."
323
327
  )
@@ -337,12 +341,13 @@ def generate_creative_package(
337
341
  },
338
342
  "thumbnail_variant_schema": {
339
343
  "concept": "string",
340
- "overlay_text": "string, maximum 4 words",
344
+ "overlay_text": "required string, 3 to 4 words",
341
345
  "composition": "string",
342
346
  "color_palette": "string",
343
347
  "subject": "string",
344
- "image_prompt": "string, no text rendered in the image",
348
+ "image_prompt": "string describing the clean image base, without rendered text; lettering is specified separately",
345
349
  "title_synergy": "string",
350
+ "lettering_prompt": "string describing how to render the exact overlay_text",
346
351
  },
347
352
  },
348
353
  ensure_ascii=False,
@@ -384,15 +389,21 @@ def generate_creative_package(
384
389
  continue
385
390
  if not str(item.get("concept") or "").strip() or not str(item.get("image_prompt") or "").strip():
386
391
  continue
392
+ overlay_text = _short_overlay(item.get("overlay_text")) or _short_overlay(topic) or "WATCH NOW"
393
+ lettering_prompt = str(item.get("lettering_prompt") or "").strip() or (
394
+ "Render the exact overlay_text as bold, readable, high-contrast sans-serif lettering with an outline or shadow, "
395
+ "inside a safe zone and away from the main face or subject."
396
+ )
387
397
  variants.append(
388
398
  {
389
399
  "concept": str(item.get("concept") or "").strip(),
390
- "overlay_text": _short_overlay(item.get("overlay_text")),
400
+ "overlay_text": overlay_text,
391
401
  "composition": str(item.get("composition") or "").strip(),
392
402
  "color_palette": str(item.get("color_palette") or "").strip(),
393
403
  "subject": str(item.get("subject") or "").strip(),
394
404
  "image_prompt": str(item.get("image_prompt") or "").strip(),
395
405
  "title_synergy": str(item.get("title_synergy") or "").strip(),
406
+ "lettering_prompt": lettering_prompt,
396
407
  "status": "prompt_ready",
397
408
  }
398
409
  )
@@ -223,12 +223,33 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
223
223
 
224
224
  _update(task_id, stage="keywords", state="doing", progress=48)
225
225
  variant = creative.get("thumbnail_variant") if isinstance(creative.get("thumbnail_variant"), dict) else {}
226
- prompt_payload = {"topic": topic, "title": title, "keywords": keywords, "thumbnail": variant, "requirements": {"aspect_ratio": "16:9", "resolution": "1920x1080", "max_elements": 3, "max_overlay_words": 4}}
226
+ prompt_payload = {
227
+ "topic": topic,
228
+ "title": title,
229
+ "keywords": keywords,
230
+ "thumbnail": variant,
231
+ "requirements": {
232
+ "aspect_ratio": "16:9",
233
+ "resolution": "1920x1080",
234
+ "max_elements": 3,
235
+ "max_overlay_words": 4,
236
+ "lettering_required": True,
237
+ "lettering_text": str(variant.get("overlay_text") or ""),
238
+ "lettering_prompt": str(variant.get("lettering_prompt") or ""),
239
+ },
240
+ }
227
241
  prompt_artifact = _save_json_artifact(task_id, "thumbnail-prompt", prompt_payload)
228
242
  artifacts = {**artifacts, "thumbnail_prompt_json": prompt_artifact}
229
243
  _update(task_id, stage="thumbnail_prompt", state="doing", progress=52, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
230
244
  _update(task_id, stage="thumbnail", state="doing", progress=56, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
231
- thumbnail_path = generate_thumbnail_image(settings, str(variant.get("image_prompt") or ""), topic=topic, variant_index=0)
245
+ thumbnail_path = generate_thumbnail_image(
246
+ settings,
247
+ str(variant.get("image_prompt") or ""),
248
+ topic=topic,
249
+ variant_index=0,
250
+ lettering_text=str(variant.get("overlay_text") or ""),
251
+ lettering_prompt=str(variant.get("lettering_prompt") or ""),
252
+ )
232
253
  artifacts["thumbnail"] = str(thumbnail_path)
233
254
  _update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=62)
234
255
 
@@ -4,6 +4,7 @@ import hashlib
4
4
  import mimetypes
5
5
  import json
6
6
  import os
7
+ import re
7
8
  import tempfile
8
9
  from pathlib import Path
9
10
  from typing import Any
@@ -30,6 +31,51 @@ def _clean_detail(value: Any, api_key: str) -> str:
30
31
  return detail.replace(api_key, "[REDACTED]") if api_key else detail
31
32
 
32
33
 
34
+ def _fallback_lettering(topic: str) -> str:
35
+ """Return a short headline when an older task has no overlay_text."""
36
+ words = re.findall(r"[\wÀ-ÿ$%'-]+", str(topic or ""), flags=re.UNICODE)
37
+ headline = " ".join(words[:4]).strip()
38
+ return headline.upper() if headline else "WATCH NOW"
39
+
40
+
41
+ def _normalise_lettering_text(value: Any, topic: str) -> str:
42
+ """Keep the required thumbnail headline concise and never empty."""
43
+ words = re.findall(r"[\wÀ-ÿ$%'-]+", str(value or ""), flags=re.UNICODE)
44
+ if not words:
45
+ return _fallback_lettering(topic)
46
+ return " ".join(words[:4]).strip()
47
+
48
+
49
+ def _compose_thumbnail_prompt(
50
+ base_prompt: str,
51
+ *,
52
+ topic: str = "",
53
+ lettering_text: str = "",
54
+ lettering_prompt: str = "",
55
+ ) -> tuple[str, str]:
56
+ """Combine the visual base and a mandatory, model-readable lettering layer."""
57
+ headline = _normalise_lettering_text(lettering_text, topic)
58
+ lettering_guidance = str(lettering_prompt or "").strip() or (
59
+ "Use a bold sans-serif headline with high contrast, a thick outline or shadow, "
60
+ "safe margins and placement that does not cover the face or main subject."
61
+ )
62
+ effective_prompt = (
63
+ "IMAGE BASE LAYER — create the requested cinematic YouTube thumbnail composition, subject, "
64
+ "lighting, colour palette and visual hierarchy. Keep the base image clean and uncluttered.\n"
65
+ f"{str(base_prompt or '').strip()}\n\n"
66
+ "MANDATORY LETTERING LAYER — the final image MUST visibly contain readable lettering. "
67
+ "If the image-base description says no text, no words or no lettering, that restriction is overridden "
68
+ "by this layer. Render the exact headline between the delimiters below; do not omit it, paraphrase it, "
69
+ "translate it, replace it with placeholder text or hide it.\n"
70
+ f"EXACT HEADLINE TO RENDER: <<<{headline}>>>\n"
71
+ f"LETTERING DESIGN: {lettering_guidance}\n"
72
+ "Use no more than three or four words, bold sans-serif typography, strong contrast, outline/shadow, "
73
+ "a safe-zone margin and a position that does not cover the face or main object. Do not add unrelated text, "
74
+ "logos or watermarks. The thumbnail must not be delivered without the exact headline visible."
75
+ )
76
+ return effective_prompt, headline
77
+
78
+
33
79
  def _decode_image_data(value: Any) -> bytes | None:
34
80
  if not isinstance(value, str) or not value.strip():
35
81
  return None
@@ -105,6 +151,8 @@ def generate_thumbnail_image(
105
151
  topic: str = "",
106
152
  variant_index: int = 0,
107
153
  reference_image: str | Path | None = None,
154
+ lettering_text: str = "",
155
+ lettering_prompt: str = "",
108
156
  ) -> Path:
109
157
  api_key = str(settings.get("gemini_image_api_key") or "").strip()
110
158
  if not api_key:
@@ -112,14 +160,20 @@ def generate_thumbnail_image(
112
160
  clean_prompt = str(prompt or "").strip()
113
161
  if not clean_prompt:
114
162
  raise ThumbnailGenerationError("A thumbnail não tem um prompt de imagem para gerar.")
163
+ effective_prompt, headline = _compose_thumbnail_prompt(
164
+ clean_prompt,
165
+ topic=topic,
166
+ lettering_text=lettering_text,
167
+ lettering_prompt=lettering_prompt,
168
+ )
115
169
 
116
170
  model = str(settings.get("gemini_image_model") or DEFAULT_GEMINI_IMAGE_MODEL).strip()
117
171
  aspect_ratio = str(settings.get("gemini_image_aspect_ratio") or DEFAULT_ASPECT_RATIO).strip()
118
172
  image_size = str(settings.get("gemini_image_size") or DEFAULT_IMAGE_SIZE).strip()
119
- request_input: str | list[dict[str, str]] = clean_prompt
173
+ request_input: str | list[dict[str, str]] = effective_prompt
120
174
  if reference_image:
121
175
  request_input = [
122
- {"type": "text", "text": clean_prompt},
176
+ {"type": "text", "text": effective_prompt},
123
177
  _image_input(Path(reference_image)),
124
178
  ]
125
179
  body = {
@@ -161,7 +215,12 @@ def generate_thumbnail_image(
161
215
  ensure_storage()
162
216
  output_dir = STORAGE / "thumbnails"
163
217
  output_dir.mkdir(parents=True, exist_ok=True)
164
- destination = output_dir / _thumbnail_filename(clean_prompt, topic, variant_index, model)
218
+ destination = output_dir / _thumbnail_filename(
219
+ f"{effective_prompt}\nEXACT HEADLINE TO RENDER: {headline}",
220
+ topic,
221
+ variant_index,
222
+ model,
223
+ )
165
224
  fd, temp_name = tempfile.mkstemp(prefix=f".{destination.name}.", dir=output_dir)
166
225
  try:
167
226
  with os.fdopen(fd, "wb") as handle:
@@ -205,6 +205,8 @@ def generate_thumbnail_for_task(task_id: str, settings: dict[str, Any]) -> tuple
205
205
  record["prompt"],
206
206
  topic=record["title"] or record["topic"],
207
207
  variant_index=record["variant_index"],
208
+ lettering_text=record.get("thumbnail_text") or "",
209
+ lettering_prompt=record.get("lettering_prompt") or "",
208
210
  )
209
211
  _persist_thumbnail_result(tasks, task, record, image_path)
210
212
  write_json("tasks.json", tasks)
@@ -258,6 +260,8 @@ def regenerate_thumbnail_prompt_and_image(
258
260
  prompt,
259
261
  topic=record["title"] or record["topic"],
260
262
  variant_index=record["variant_index"],
263
+ lettering_text=str((variant or {}).get("overlay_text") or ""),
264
+ lettering_prompt=str((variant or {}).get("lettering_prompt") or ""),
261
265
  )
262
266
  _persist_thumbnail_result(tasks, task, record, image_path, variant=variant, source="prompt_regenerated")
263
267
  write_json("tasks.json", tasks)
@@ -298,6 +302,7 @@ def regenerate_thumbnail_lettering(
298
302
  topic=record["title"] or record["topic"],
299
303
  variant_index=record["variant_index"],
300
304
  reference_image=previous_image,
305
+ lettering_prompt=edit_prompt,
301
306
  )
302
307
  variant = _variant_for_record(record)
303
308
  variant["image_prompt"] = base_prompt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
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",