@danhachuel/thunderbolt 0.3.24 → 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
@@ -46,9 +46,17 @@ from hermes_ui.script_documents import list_script_documents, read_script_docume
46
46
  from hermes_ui.script_generation import generate_script_document
47
47
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
48
48
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
49
- from hermes_ui.thumbnails import list_thumbnail_tasks, regenerate_thumbnail
49
+ from hermes_ui.thumbnails import (
50
+ generate_thumbnail_for_task,
51
+ list_thumbnail_tasks,
52
+ regenerate_thumbnail,
53
+ regenerate_thumbnail_lettering,
54
+ regenerate_thumbnail_prompt,
55
+ regenerate_thumbnail_prompt_and_image,
56
+ upload_thumbnail_image,
57
+ )
50
58
  from hermes_ui.draft_video import DRAFT_SETTING_SECTIONS, missing_content_fields, missing_setting_sections, normalise_saved_script, setting_widget_suffixes
51
- from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel, generate_video_keywords
59
+ from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_thumbnail_prompt, generate_topic_for_channel, generate_video_keywords
52
60
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
53
61
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
54
62
  from integrations.postiz import PostizAdapter
@@ -2144,7 +2152,16 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2144
2152
  thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
2145
2153
  if st.button("Gerar imagem com Nano Banana", key=f"{prefix}_general_generate_thumbnail_{channel['id']}", use_container_width=True):
2146
2154
  try:
2147
- 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
+ )
2148
2165
  variant["image_path"] = thumbnail_path
2149
2166
  payload["thumbnail_path"] = thumbnail_path
2150
2167
  payload["thumbnail_status"] = "generated"
@@ -2208,7 +2225,16 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2208
2225
  thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
2209
2226
  if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", use_container_width=True):
2210
2227
  try:
2211
- 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
+ )
2212
2238
  variant["image_path"] = thumbnail_path
2213
2239
  payload["thumbnail_path"] = thumbnail_path
2214
2240
  payload["thumbnail_status"] = "generated"
@@ -3215,9 +3241,31 @@ def render_videos():
3215
3241
  st.rerun()
3216
3242
 
3217
3243
 
3244
+ def _thumbnail_editor_context(record: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
3245
+ """Resolve the persisted task channel/Blueprint without requiring either one to remain registered."""
3246
+ channel_id = str(record.get("channel_id") or "").strip()
3247
+ channels = read_json("channels.json", [])
3248
+ channel = next(
3249
+ (item for item in channels if isinstance(item, dict) and str(item.get("id") or "") == channel_id),
3250
+ None,
3251
+ ) if isinstance(channels, list) else None
3252
+ if not channel:
3253
+ channel = {
3254
+ "id": channel_id,
3255
+ "name": record.get("channel_name") or "Canal sem nome",
3256
+ "language": record.get("language") or "Português",
3257
+ "default_blueprint_id": record.get("blueprint_id") or "",
3258
+ "blueprint_id": record.get("blueprint_id") or "",
3259
+ }
3260
+ blueprint = blueprint_for_channel(channel)
3261
+ if not blueprint and record.get("blueprint_id"):
3262
+ blueprint = {"id": record.get("blueprint_id"), "name": record.get("blueprint_name") or record.get("blueprint_id")}
3263
+ return channel, blueprint
3264
+
3265
+
3218
3266
  def render_thumbnails():
3219
3267
  st.title("Thumbnails")
3220
- st.caption("Biblioteca de thumbnails associadas às tarefas da pipeline. A refacção actualiza apenas a imagem da tarefa e preserva o histórico local.")
3268
+ st.caption("Biblioteca de thumbnails associadas às tarefas da pipeline. Cada acção preserva a imagem anterior no histórico local.")
3221
3269
  records = list_thumbnail_tasks()
3222
3270
  if not records:
3223
3271
  st.info("Ainda não existem tarefas com thumbnail gerada ou prompt de imagem disponível.")
@@ -3225,8 +3273,9 @@ def render_thumbnails():
3225
3273
 
3226
3274
  settings = read_json("settings.json", {})
3227
3275
  for record in records:
3276
+ task_id = record["task_id"]
3228
3277
  with st.container(border=True):
3229
- image_col, details_col, action_col = st.columns([1.35, 2.65, 1.25])
3278
+ image_col, details_col, action_col = st.columns([1.25, 2.35, 1.7])
3230
3279
  with image_col:
3231
3280
  image_path = record.get("image_path")
3232
3281
  if image_path and image_path.is_file():
@@ -3236,32 +3285,157 @@ def render_thumbnails():
3236
3285
  st.caption("Imagem ainda não gerada")
3237
3286
  with details_col:
3238
3287
  st.write(f"**{record['title']}**")
3239
- st.caption(f"Canal: {record['channel_name']} · Tarefa: {record['task_id']}")
3288
+ st.caption(f"Canal: {record['channel_name']} · Tarefa: {task_id}")
3240
3289
  st.caption(f"Estado: {record['status']} · Variante: {record['variant_index'] + 1}")
3241
3290
  if record["prompt"]:
3242
3291
  with st.expander("Ver prompt da thumbnail", expanded=False):
3243
3292
  st.code(record["prompt"], language="text")
3244
3293
  else:
3245
- st.warning("Esta tarefa não tem prompt de imagem. Não é possível refazer a thumbnail.")
3294
+ st.warning("A thumbnail não tem um prompt de imagem para gerar.")
3295
+
3246
3296
  with action_col:
3247
3297
  if st.button(
3248
- "Refazer thumbnail",
3249
- key=f"regenerate_thumbnail_{record['task_id']}",
3298
+ "Refazer Prompt Thumb",
3299
+ key=f"regenerate_thumbnail_{task_id}",
3250
3300
  icon=":material/refresh:",
3251
3301
  use_container_width=True,
3302
+ disabled=not bool(record["title"] or record["topic"]),
3303
+ ):
3304
+ try:
3305
+ with st.spinner("A refazer apenas o prompt da thumbnail…"):
3306
+ channel, blueprint = _thumbnail_editor_context(record)
3307
+ _task, prompt_variant = regenerate_thumbnail_prompt(
3308
+ task_id,
3309
+ settings,
3310
+ channel,
3311
+ blueprint=blueprint,
3312
+ language=str(record.get("language") or current_ui_language()),
3313
+ )
3314
+ record_notification(
3315
+ "thumbnail_generation_completed",
3316
+ "Prompt da thumbnail refeito",
3317
+ "Prompt da thumbnail actualizado; a imagem existente foi preservada.",
3318
+ metadata={
3319
+ "task_id": task_id,
3320
+ "channel_name": record["channel_name"],
3321
+ "prompt_regenerated": True,
3322
+ "prompt_only": True,
3323
+ "image_path": str(record.get("image_path") or ""),
3324
+ },
3325
+ dedupe_key=f"thumbnail:prompt-only:{task_id}:{prompt_variant.get('image_prompt', '')}",
3326
+ )
3327
+ st.success("Prompt da thumbnail actualizado; a imagem existente foi preservada.")
3328
+ st.rerun()
3329
+ except (CreativeGenerationError, ThumbnailGenerationError) as exc:
3330
+ st.error(str(exc))
3331
+
3332
+ if st.button(
3333
+ "Gerar Imagem",
3334
+ key=f"generate_thumbnail_image_{task_id}",
3335
+ icon=":material/image:",
3336
+ use_container_width=True,
3252
3337
  disabled=not bool(record["prompt"]),
3253
3338
  ):
3254
3339
  try:
3255
- with st.spinner("A refazer a thumbnail…"):
3256
- _task, image_path = regenerate_thumbnail(record["task_id"], settings)
3340
+ with st.spinner("A gerar a imagem com Nano Banana…"):
3341
+ _task, generated_path = generate_thumbnail_for_task(task_id, settings)
3342
+ record_notification(
3343
+ "thumbnail_generation_completed",
3344
+ f"Thumbnail gerada: {record['title']}",
3345
+ "Thumbnail gerada com sucesso.",
3346
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(generated_path)},
3347
+ dedupe_key=f"thumbnail:generated:{task_id}:{generated_path}",
3348
+ )
3349
+ st.success("Thumbnail gerada com sucesso.")
3350
+ st.rerun()
3351
+ except ThumbnailGenerationError as exc:
3352
+ st.error(str(exc))
3353
+
3354
+ if st.button(
3355
+ "Refazer Prompt e Gerar Imagem",
3356
+ key=f"regenerate_thumbnail_prompt_{task_id}",
3357
+ icon=":material/auto_awesome:",
3358
+ use_container_width=True,
3359
+ disabled=not bool(record["title"] or record["topic"]),
3360
+ ):
3361
+ try:
3362
+ with st.spinner("A refazer o prompt e a imagem…"):
3363
+ channel, blueprint = _thumbnail_editor_context(record)
3364
+ variant = generate_thumbnail_prompt(
3365
+ settings,
3366
+ channel,
3367
+ record["title"] or record["topic"],
3368
+ current_prompt=record["prompt"],
3369
+ blueprint=blueprint,
3370
+ language=str(record.get("language") or current_ui_language()),
3371
+ )
3372
+ _task, generated_path = regenerate_thumbnail_prompt_and_image(task_id, settings, variant)
3373
+ record_notification(
3374
+ "thumbnail_generation_completed",
3375
+ f"Thumbnail renovada: {record['title']}",
3376
+ "Prompt da thumbnail e imagem actualizados.",
3377
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(generated_path), "prompt_regenerated": True},
3378
+ dedupe_key=f"thumbnail:prompt-regenerated:{task_id}:{generated_path}",
3379
+ )
3380
+ st.success("Prompt da thumbnail e imagem actualizados.")
3381
+ st.rerun()
3382
+ except (CreativeGenerationError, ThumbnailGenerationError) as exc:
3383
+ st.error(str(exc))
3384
+
3385
+ if st.button(
3386
+ "Refazer Lettering",
3387
+ key=f"regenerate_thumbnail_lettering_{task_id}",
3388
+ icon=":material/title:",
3389
+ use_container_width=True,
3390
+ disabled=not bool(record.get("image_path") and record["image_path"].is_file()),
3391
+ ):
3392
+ try:
3393
+ with st.spinner("A refazer apenas o lettering…"):
3394
+ _task, generated_path = regenerate_thumbnail_lettering(
3395
+ task_id,
3396
+ settings,
3397
+ lettering_prompt=record.get("lettering_prompt") or "",
3398
+ )
3399
+ record_notification(
3400
+ "thumbnail_generation_completed",
3401
+ f"Lettering refeito: {record['title']}",
3402
+ "Lettering refeito; a imagem original foi usada como base.",
3403
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(generated_path), "lettering_only": True},
3404
+ dedupe_key=f"thumbnail:lettering:{task_id}:{generated_path}",
3405
+ )
3406
+ st.success("Lettering refeito; a imagem original foi usada como base.")
3407
+ st.rerun()
3408
+ except ThumbnailGenerationError as exc:
3409
+ st.error(str(exc))
3410
+
3411
+ uploaded = st.file_uploader(
3412
+ "Upload Image",
3413
+ type=["png", "jpg", "jpeg", "webp"],
3414
+ key=f"thumbnail_upload_{task_id}",
3415
+ help="Suba uma imagem para a associar a esta tarefa e à pipeline.",
3416
+ )
3417
+ if uploaded is not None:
3418
+ uploaded_bytes = uploaded.getvalue()
3419
+ uploaded_digest = hashlib.sha256(uploaded_bytes).hexdigest()
3420
+ digest_key = f"thumbnail_upload_digest_{task_id}"
3421
+ if st.session_state.get(digest_key) != uploaded_digest:
3422
+ try:
3423
+ with st.spinner("A guardar a imagem carregada…"):
3424
+ _task, uploaded_path = upload_thumbnail_image(
3425
+ task_id,
3426
+ uploaded_bytes,
3427
+ uploaded.name,
3428
+ uploaded.type,
3429
+ )
3430
+ st.session_state[digest_key] = uploaded_digest
3257
3431
  record_notification(
3258
3432
  "thumbnail_generation_completed",
3259
- f"Thumbnail refeita: {record['title']}",
3260
- "A thumbnail da tarefa foi regenerada com sucesso.",
3261
- metadata={"task_id": record["task_id"], "channel_name": record["channel_name"], "image_path": str(image_path)},
3262
- dedupe_key=f"thumbnail:regenerated:{record['task_id']}:{image_path}",
3433
+ f"Thumbnail carregada: {record['title']}",
3434
+ "Imagem carregada e vinculada à tarefa.",
3435
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(uploaded_path), "source": "upload"},
3436
+ dedupe_key=f"thumbnail:uploaded:{task_id}:{uploaded_digest}",
3263
3437
  )
3264
- st.success("Thumbnail refeita com sucesso.")
3438
+ st.success("Imagem carregada e vinculada à tarefa.")
3265
3439
  st.rerun()
3266
3440
  except ThumbnailGenerationError as exc:
3267
3441
  st.error(str(exc))
@@ -249,6 +249,64 @@ def _keywords_from_text(*values: str) -> list[str]:
249
249
  return result[:15]
250
250
 
251
251
 
252
+ def generate_thumbnail_prompt(
253
+ settings: dict[str, Any],
254
+ channel: dict[str, Any],
255
+ topic: str,
256
+ current_prompt: str = "",
257
+ blueprint: dict[str, Any] | None = None,
258
+ language: str = "",
259
+ ) -> dict[str, Any]:
260
+ """Create one image prompt while keeping visual direction and lettering separate."""
261
+ topic = str(topic or "").strip()
262
+ if not topic:
263
+ raise CreativeGenerationError("É necessário um tópico para refazer o prompt da thumbnail.")
264
+ context = channel_context(channel, blueprint)
265
+ system = (
266
+ "És um director de arte de thumbnails para YouTube. Refaz um prompt de imagem forte e específico "
267
+ "para o tópico fornecido, mantendo a intenção visual quando já existir um prompt. "
268
+ "Separa rigorosamente a imagem sem texto do lettering: image_prompt nunca deve pedir texto renderizado, "
269
+ "enquanto overlay_text é obrigatório, deve ser curto, legível e ter entre três e quatro palavras. "
270
+ "Responde apenas com JSON válido nas chaves concept, overlay_text, composition, color_palette, subject, "
271
+ "image_prompt, title_synergy e lettering_prompt."
272
+ )
273
+ user = json.dumps(
274
+ {
275
+ "channel": context,
276
+ "language": language or context["language"],
277
+ "topic": topic,
278
+ "current_prompt": str(current_prompt or "").strip(),
279
+ "reference_rules": reference_bundle(),
280
+ "requirements": {
281
+ "image_prompt": "cinematic visual direction, no words, letters, logos or watermarks rendered in the image",
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
+ },
285
+ },
286
+ ensure_ascii=False,
287
+ )
288
+ result = _chat_json(settings, system, user)
289
+ image_prompt = str(result.get("image_prompt") or "").strip()
290
+ if not image_prompt:
291
+ raise CreativeGenerationError("O provider não devolveu um prompt de imagem válido.")
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
+ )
297
+ return {
298
+ "concept": str(result.get("concept") or "Thumbnail renovada").strip(),
299
+ "overlay_text": overlay_text,
300
+ "composition": str(result.get("composition") or "").strip(),
301
+ "color_palette": str(result.get("color_palette") or "").strip(),
302
+ "subject": str(result.get("subject") or topic).strip(),
303
+ "image_prompt": image_prompt,
304
+ "title_synergy": str(result.get("title_synergy") or "").strip(),
305
+ "lettering_prompt": lettering_prompt,
306
+ "status": "prompt_ready",
307
+ }
308
+
309
+
252
310
  def generate_creative_package(
253
311
  settings: dict[str, Any],
254
312
  channel: dict[str, Any],
@@ -263,7 +321,7 @@ def generate_creative_package(
263
321
  "És director editorial e de thumbnails para YouTube. Cria um pacote coerente de título e thumbnail "
264
322
  "para o tópico fornecido. Gera exactamente pelo menos 20 títulos candidatos e entre 3 e 5 variantes de thumbnail. "
265
323
  "O título deve carregar keywords no início, ter curiosidade, especificidade e emoção, sem clickbait falso. "
266
- "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, "
267
325
  "safe zones e leitura em 120px. O texto da thumbnail não pode repetir o título integralmente. Remove AI tells. "
268
326
  "Responde apenas com JSON válido nas chaves selected_title, title_candidates, keywords e thumbnail_variants."
269
327
  )
@@ -283,12 +341,13 @@ def generate_creative_package(
283
341
  },
284
342
  "thumbnail_variant_schema": {
285
343
  "concept": "string",
286
- "overlay_text": "string, maximum 4 words",
344
+ "overlay_text": "required string, 3 to 4 words",
287
345
  "composition": "string",
288
346
  "color_palette": "string",
289
347
  "subject": "string",
290
- "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",
291
349
  "title_synergy": "string",
350
+ "lettering_prompt": "string describing how to render the exact overlay_text",
292
351
  },
293
352
  },
294
353
  ensure_ascii=False,
@@ -330,15 +389,21 @@ def generate_creative_package(
330
389
  continue
331
390
  if not str(item.get("concept") or "").strip() or not str(item.get("image_prompt") or "").strip():
332
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
+ )
333
397
  variants.append(
334
398
  {
335
399
  "concept": str(item.get("concept") or "").strip(),
336
- "overlay_text": _short_overlay(item.get("overlay_text")),
400
+ "overlay_text": overlay_text,
337
401
  "composition": str(item.get("composition") or "").strip(),
338
402
  "color_palette": str(item.get("color_palette") or "").strip(),
339
403
  "subject": str(item.get("subject") or "").strip(),
340
404
  "image_prompt": str(item.get("image_prompt") or "").strip(),
341
405
  "title_synergy": str(item.get("title_synergy") or "").strip(),
406
+ "lettering_prompt": lettering_prompt,
342
407
  "status": "prompt_ready",
343
408
  }
344
409
  )
@@ -934,6 +934,35 @@ _CONTENT_TRANSLATION_ROWS = (
934
934
  ("Modo de ligação", "Modo de ligação", "Connection mode", "连接模式", "Verbindungsmodus", "Chế độ kết nối", "Bağlantı modu", "Режим подключения", "Modo de conexión", "Mode koneksi", "Modalità di connessione"),
935
935
  ("Metadados removidos e nova cópia criada. O original continua preservado.", "Metadados removidos e nova cópia criada. O original continua preservado.", "Metadata removed and a new copy created. The original remains preserved.", "元数据已移除并创建新副本。原文件仍然保留。", "Metadaten entfernt und eine neue Kopie erstellt. Das Original bleibt erhalten.", "Đã xóa siêu dữ liệu và tạo bản sao mới. Bản gốc vẫn được giữ nguyên.", "Üst veriler kaldırıldı ve yeni bir kopya oluşturuldu. Orijinal korunur.", "Метаданные удалены и создана новая копия. Оригинал сохранён.", "Metadatos eliminados y nueva copia creada. El original se conserva.", "Metadata dihapus dan salinan baru dibuat. File asli tetap dipertahankan.", "Metadati rimossi e nuova copia creata. L'originale è preservato."),
936
936
  ("Detalhes da conta Google", "Detalhes da conta Google", "Google account details", "Google 账户详情", "Details des Google-Kontos", "Chi tiết tài khoản Google", "Google hesap ayrıntıları", "Сведения об аккаунте Google", "Detalles de la cuenta de Google", "Detail akun Google", "Dettagli dell'account Google"),
937
+ ("Refazer Prompt Thumb", "Refazer Prompt Thumb", "Regenerate Thumbnail Prompt", "重新生成缩略图提示词", "Thumbnail-Prompt neu erstellen", "Tạo lại prompt thumbnail", "Küçük resim istemini yeniden oluştur", "Перегенерировать промпт миниатюры", "Regenerar prompt de miniatura", "Buat ulang prompt thumbnail", "Rigenera prompt miniatura"),
938
+ ("Gerar Imagem", "Gerar Imagem", "Generate Image", "生成图片", "Bild generieren", "Tạo hình ảnh", "Görsel oluştur", "Создать изображение", "Generar imagen", "Buat gambar", "Genera immagine"),
939
+ ("Refazer Prompt e Gerar Imagem", "Refazer Prompt e Gerar Imagem", "Regenerate Prompt and Generate Image", "重新生成提示词并生成图片", "Prompt neu erstellen und Bild generieren", "Tạo lại prompt và hình ảnh", "İstemi Yeniden Oluştur ve Görseli Üret", "Перегенерировать промпт и создать изображение", "Regenerar prompt y generar imagen", "Buat Ulang Prompt dan Gambar", "Rigenera prompt e genera immagine"),
940
+ ("Refazer Lettering", "Refazer Lettering", "Regenerate Lettering", "重新生成文字", "Lettering neu erstellen", "Tạo lại chữ", "Yazıyı Yeniden Oluştur", "Перегенерировать надпись", "Regenerar lettering", "Buat ulang lettering", "Rigenera lettering"),
941
+ ("Upload Image", "Upload Image", "Upload Image", "上传图片", "Bild hochladen", "Tải hình ảnh lên", "Görsel Yükle", "Загрузить изображение", "Subir imagen", "Unggah gambar", "Carica immagine"),
942
+ ("A gerar a imagem com Nano Banana…", "A gerar a imagem com Nano Banana…", "Generating image with Nano Banana…", "正在使用 Nano Banana 生成图片…", "Bild wird mit Nano Banana generiert…", "Đang tạo ảnh bằng Nano Banana…", "Nano Banana ile görsel oluşturuluyor…", "Создание изображения с помощью Nano Banana…", "Generando imagen con Nano Banana…", "Membuat gambar dengan Nano Banana…", "Generazione immagine con Nano Banana…"),
943
+ ("A refazer o prompt e a imagem…", "A refazer o prompt e a imagem…", "Regenerating the prompt and image…", "正在重新生成提示词和图片…", "Prompt und Bild werden neu erstellt…", "Đang tạo lại prompt và hình ảnh…", "İstem ve görsel yeniden oluşturuluyor…", "Перегенерация промпта и изображения…", "Regenerando el prompt y la imagen…", "Membuat ulang prompt dan gambar…", "Rigenerazione del prompt e dell'immagine…"),
944
+ ("A refazer apenas o prompt da thumbnail…", "A refazer apenas o prompt da thumbnail…", "Regenerating only the thumbnail prompt…", "仅重新生成缩略图提示词…", "Nur der Thumbnail-Prompt wird neu erstellt…", "Chỉ tạo lại prompt thumbnail…", "Yalnızca küçük resim istemi yeniden oluşturuluyor…", "Перегенерация только промпта миниатюры…", "Regenerando solo el prompt de la miniatura…", "Membuat ulang prompt thumbnail saja…", "Rigenerazione del solo prompt della miniatura…"),
945
+ ("A refazer apenas o lettering…", "A refazer apenas o lettering…", "Regenerating only the lettering…", "仅重新生成文字…", "Nur das Lettering wird neu erstellt…", "Chỉ tạo lại chữ…", "Yalnızca yazı yeniden oluşturuluyor…", "Перегенерация только надписи…", "Regenerando solo el lettering…", "Membuat ulang lettering saja…", "Rigenerazione del solo lettering…"),
946
+ ("Prompt da thumbnail refeito", "Prompt da thumbnail refeito", "Thumbnail prompt regenerated", "缩略图提示词已重新生成", "Thumbnail-Prompt neu erstellt", "Đã tạo lại prompt thumbnail", "Küçük resim istemi yeniden oluşturuldu", "Промпт миниатюры перегенерирован", "Prompt de miniatura regenerado", "Prompt thumbnail dibuat ulang", "Prompt della miniatura rigenerato"),
947
+ ("Prompt da thumbnail actualizado; a imagem existente foi preservada.", "Prompt da thumbnail actualizado; a imagem existente foi preservada.", "Thumbnail prompt updated; the existing image was preserved.", "缩略图提示词已更新;现有图片已保留。", "Thumbnail-Prompt aktualisiert; das vorhandene Bild wurde beibehalten.", "Đã cập nhật prompt thumbnail; ảnh hiện tại được giữ nguyên.", "Küçük resim istemi güncellendi; mevcut görsel korundu.", "Промпт миниатюры обновлён; существующее изображение сохранено.", "Prompt de miniatura actualizado; la imagen existente se conservó.", "Prompt thumbnail diperbarui; gambar yang ada dipertahankan.", "Prompt della miniatura aggiornato; l'immagine esistente è stata preservata."),
948
+ ("Thumbnail gerada com sucesso.", "Thumbnail gerada com sucesso.", "Thumbnail generated successfully.", "缩略图生成成功。", "Thumbnail erfolgreich generiert.", "Đã tạo thumbnail thành công.", "Küçük resim başarıyla oluşturuldu.", "Миниатюра успешно создана.", "Miniatura generada correctamente.", "Thumbnail berhasil dibuat.", "Miniatura generata con successo."),
949
+ ("Prompt da thumbnail e imagem actualizados.", "Prompt da thumbnail e imagem actualizados.", "Thumbnail prompt and image updated.", "缩略图提示词和图片已更新。", "Thumbnail-Prompt und Bild aktualisiert.", "Đã cập nhật prompt và hình ảnh thumbnail.", "Küçük resim istemi ve görsel güncellendi.", "Промпт и изображение миниатюры обновлены.", "Prompt e imagen de miniatura actualizados.", "Prompt dan gambar thumbnail diperbarui.", "Prompt e immagine della miniatura aggiornati."),
950
+ ("Lettering refeito; a imagem original foi usada como base.", "Lettering refeito; a imagem original foi usada como base.", "Lettering regenerated; the original image was used as the base.", "文字已重新生成;原图用作基础。", "Lettering neu erstellt; das Originalbild wurde als Grundlage verwendet.", "Đã tạo lại chữ; ảnh gốc được dùng làm nền.", "Yazı yeniden oluşturuldu; orijinal görsel temel olarak kullanıldı.", "Надпись перегенерирована; исходное изображение использовано как основа.", "Lettering regenerado; la imagen original se usó como base.", "Lettering dibuat ulang; gambar asli digunakan sebagai dasar.", "Lettering rigenerato; l'immagine originale è stata usata come base."),
951
+ ("Imagem carregada e vinculada à tarefa.", "Imagem carregada e vinculada à tarefa.", "Image uploaded and linked to the task.", "图片已上传并关联到任务。", "Bild hochgeladen und mit der Aufgabe verknüpft.", "Đã tải ảnh lên và liên kết với tác vụ.", "Görsel yüklendi ve göreve bağlandı.", "Изображение загружено и привязано к задаче.", "Imagen subida y vinculada a la tarea.", "Gambar diunggah dan ditautkan ke tugas.", "Immagine caricata e collegata all'attività."),
952
+ ("A thumbnail não tem um prompt de imagem para gerar.", "A thumbnail não tem um prompt de imagem para gerar.", "This thumbnail has no image prompt to generate.", "此缩略图没有可生成的图片提示词。", "Für dieses Thumbnail ist kein Bild-Prompt zum Generieren vorhanden.", "Thumbnail này không có prompt hình ảnh để tạo.", "Bu küçük resim için oluşturulacak bir görsel istemi yok.", "У этой миниатюры нет промпта для создания изображения.", "Esta miniatura no tiene un prompt de imagen para generar.", "Thumbnail ini tidak memiliki prompt gambar untuk dibuat.", "Questa miniatura non ha un prompt immagine da generare."),
953
+ ("A thumbnail precisa de uma imagem existente para refazer o lettering.", "A thumbnail precisa de uma imagem existente para refazer o lettering.", "An existing image is required to regenerate the lettering.", "需要现有图片才能重新生成文字。", "Für die Neugenerierung des Letterings ist ein vorhandenes Bild erforderlich.", "Cần có ảnh hiện tại để tạo lại chữ.", "Lettering'i yeniden oluşturmak için mevcut bir görsel gerekir.", "Для перегенерации надписи требуется существующее изображение.", "Se necesita una imagen existente para regenerar el lettering.", "Diperlukan gambar yang ada untuk membuat ulang lettering.", "È necessaria un'immagine esistente per rigenerare il lettering."),
954
+ ("Thumbnails", "Thumbnails", "Thumbnails", "缩略图", "Thumbnails", "Thumbnails", "Küçük Resimler", "Миниатюры", "Miniaturas", "Thumbnail", "Miniature"),
955
+ ("Biblioteca de thumbnails associadas às tarefas da pipeline. Cada acção preserva a imagem anterior no histórico local.", "Biblioteca de thumbnails associadas às tarefas da pipeline. Cada acção preserva a imagem anterior no histórico local.", "Thumbnail library linked to pipeline tasks. Each action preserves the previous image in local history.", "与流程任务关联的缩略图库。每项操作都会在本地历史记录中保留上一张图片。", "Thumbnail-Bibliothek für Pipeline-Aufgaben. Jede Aktion bewahrt das vorherige Bild im lokalen Verlauf.", "Thư viện thumbnail liên kết với các tác vụ pipeline. Mỗi thao tác đều giữ ảnh trước đó trong lịch sử cục bộ.", "Pipeline görevlerine bağlı küçük resim kitaplığı. Her işlem önceki görseli yerel geçmişte korur.", "Библиотека миниатюр, связанная с задачами конвейера. Каждое действие сохраняет предыдущее изображение в локальной истории.", "Biblioteca de miniaturas vinculada a las tareas del flujo. Cada acción conserva la imagen anterior en el historial local.", "Pustaka thumbnail yang terhubung ke tugas alur. Setiap tindakan menyimpan gambar sebelumnya dalam riwayat lokal.", "Libreria di miniature collegata alle attività della pipeline. Ogni azione conserva l'immagine precedente nella cronologia locale."),
956
+ ("Ainda não existem tarefas com thumbnail gerada ou prompt de imagem disponível.", "Ainda não existem tarefas com thumbnail gerada ou prompt de imagem disponível.", "There are no tasks with a generated thumbnail or available image prompt yet.", "目前没有已生成缩略图或可用图片提示词的任务。", "Es gibt noch keine Aufgaben mit generiertem Thumbnail oder verfügbarem Bild-Prompt.", "Chưa có tác vụ nào có thumbnail được tạo hoặc prompt hình ảnh khả dụng.", "Henüz oluşturulmuş küçük resmi veya kullanılabilir görsel istemi olan görev yok.", "Пока нет задач с созданной миниатюрой или доступным промптом изображения.", "Aún no hay tareas con miniatura generada o prompt de imagen disponible.", "Belum ada tugas dengan thumbnail yang dibuat atau prompt gambar yang tersedia.", "Non ci sono ancora attività con miniatura generata o prompt immagine disponibile."),
957
+ ("Sem imagem", "Sem imagem", "No image", "无图片", "Kein Bild", "Không có ảnh", "Görsel yok", "Нет изображения", "Sin imagen", "Tidak ada gambar", "Nessuna immagine"),
958
+ ("Imagem ainda não gerada", "Imagem ainda não gerada", "Image not generated yet", "图片尚未生成", "Bild noch nicht generiert", "Ảnh chưa được tạo", "Görsel henüz oluşturulmadı", "Изображение ещё не создано", "Imagen aún no generada", "Gambar belum dibuat", "Immagine non ancora generata"),
959
+ ("Canal", "Canal", "Channel", "频道", "Kanal", "Kênh", "Kanal", "Канал", "Canal", "Kanal", "Canale"),
960
+ ("Tarefa", "Tarefa", "Task", "任务", "Aufgabe", "Tác vụ", "Görev", "Задача", "Tarea", "Tugas", "Attività"),
961
+ ("Estado", "Estado", "Status", "状态", "Status", "Trạng thái", "Durum", "Статус", "Estado", "Status", "Stato"),
962
+ ("Variante", "Variante", "Variant", "变体", "Variante", "Biến thể", "Varyant", "Вариант", "Variante", "Varian", "Variante"),
963
+ ("Ver prompt da thumbnail", "Ver prompt da thumbnail", "View thumbnail prompt", "查看缩略图提示词", "Thumbnail-Prompt anzeigen", "Xem prompt thumbnail", "Küçük resim istemini görüntüle", "Просмотреть промпт миниатюры", "Ver prompt de miniatura", "Lihat prompt thumbnail", "Visualizza prompt miniatura"),
964
+ ("Suba uma imagem para a associar a esta tarefa e à pipeline.", "Suba uma imagem para a associar a esta tarefa e à pipeline.", "Upload an image to link it to this task and the pipeline.", "上传图片以将其关联到此任务和流程。", "Laden Sie ein Bild hoch, um es mit dieser Aufgabe und der Pipeline zu verknüpfen.", "Tải ảnh lên để liên kết với tác vụ này và pipeline.", "Bu göreve ve pipeline'a bağlamak için bir görsel yükleyin.", "Загрузите изображение, чтобы связать его с этой задачей и конвейером.", "Suba una imagen para vincularla a esta tarea y al flujo.", "Unggah gambar untuk menautkannya ke tugas dan alur ini.", "Carica un'immagine per collegarla a questa attività e alla pipeline."),
965
+ ("A guardar a imagem carregada…", "A guardar a imagem carregada…", "Saving uploaded image…", "正在保存上传的图片…", "Hochgeladenes Bild wird gespeichert…", "Đang lưu ảnh đã tải lên…", "Yüklenen görsel kaydediliyor…", "Сохранение загруженного изображения…", "Guardando la imagen subida…", "Menyimpan gambar yang diunggah…", "Salvataggio dell'immagine caricata…"),
937
966
  )
938
967
  UI_CONTENT_TRANSLATIONS: dict[str, dict[str, str]] = {code: {} for code in _CONTENT_TRANSLATION_CODES}
939
968
  for _row in _CONTENT_TRANSLATION_ROWS:
@@ -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
 
@@ -1,8 +1,10 @@
1
1
  import base64
2
2
  import binascii
3
3
  import hashlib
4
+ import mimetypes
4
5
  import json
5
6
  import os
7
+ import re
6
8
  import tempfile
7
9
  from pathlib import Path
8
10
  from typing import Any
@@ -29,6 +31,51 @@ def _clean_detail(value: Any, api_key: str) -> str:
29
31
  return detail.replace(api_key, "[REDACTED]") if api_key else detail
30
32
 
31
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
+
32
79
  def _decode_image_data(value: Any) -> bytes | None:
33
80
  if not isinstance(value, str) or not value.strip():
34
81
  return None
@@ -72,6 +119,25 @@ def _extract_image_bytes(payload: dict[str, Any]) -> bytes:
72
119
  raise ThumbnailGenerationError("O Gemini concluiu a interação, mas não devolveu uma imagem inline.")
73
120
 
74
121
 
122
+ def _image_input(path: Path) -> dict[str, str]:
123
+ if not path.is_file():
124
+ raise ThumbnailGenerationError("A imagem de referência da thumbnail não está disponível no storage.")
125
+ try:
126
+ image_data = path.read_bytes()
127
+ except OSError as exc:
128
+ raise ThumbnailGenerationError("Não foi possível ler a imagem de referência da thumbnail.") from exc
129
+ if not image_data:
130
+ raise ThumbnailGenerationError("A imagem de referência da thumbnail está vazia.")
131
+ mime_type = mimetypes.guess_type(path.name)[0] or "image/jpeg"
132
+ if not mime_type.startswith("image/"):
133
+ mime_type = "image/jpeg"
134
+ return {
135
+ "type": "image",
136
+ "mime_type": mime_type,
137
+ "data": base64.b64encode(image_data).decode("ascii"),
138
+ }
139
+
140
+
75
141
  def _thumbnail_filename(prompt: str, topic: str, variant_index: int, model: str) -> str:
76
142
  source = f"{model}\n{topic.strip()}\n{variant_index}\n{prompt.strip()}".encode("utf-8")
77
143
  digest = hashlib.sha256(source).hexdigest()[:20]
@@ -84,6 +150,9 @@ def generate_thumbnail_image(
84
150
  *,
85
151
  topic: str = "",
86
152
  variant_index: int = 0,
153
+ reference_image: str | Path | None = None,
154
+ lettering_text: str = "",
155
+ lettering_prompt: str = "",
87
156
  ) -> Path:
88
157
  api_key = str(settings.get("gemini_image_api_key") or "").strip()
89
158
  if not api_key:
@@ -91,13 +160,25 @@ def generate_thumbnail_image(
91
160
  clean_prompt = str(prompt or "").strip()
92
161
  if not clean_prompt:
93
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
+ )
94
169
 
95
170
  model = str(settings.get("gemini_image_model") or DEFAULT_GEMINI_IMAGE_MODEL).strip()
96
171
  aspect_ratio = str(settings.get("gemini_image_aspect_ratio") or DEFAULT_ASPECT_RATIO).strip()
97
172
  image_size = str(settings.get("gemini_image_size") or DEFAULT_IMAGE_SIZE).strip()
173
+ request_input: str | list[dict[str, str]] = effective_prompt
174
+ if reference_image:
175
+ request_input = [
176
+ {"type": "text", "text": effective_prompt},
177
+ _image_input(Path(reference_image)),
178
+ ]
98
179
  body = {
99
180
  "model": model,
100
- "input": clean_prompt,
181
+ "input": request_input,
101
182
  "response_format": {
102
183
  "type": "image",
103
184
  "mime_type": DEFAULT_MIME_TYPE,
@@ -134,7 +215,12 @@ def generate_thumbnail_image(
134
215
  ensure_storage()
135
216
  output_dir = STORAGE / "thumbnails"
136
217
  output_dir.mkdir(parents=True, exist_ok=True)
137
- 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
+ )
138
224
  fd, temp_name = tempfile.mkstemp(prefix=f".{destination.name}.", dir=output_dir)
139
225
  try:
140
226
  with os.fdopen(fd, "wb") as handle:
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import shutil
5
+ import tempfile
4
6
  import uuid
5
7
  from pathlib import Path
6
8
  from typing import Any
7
9
 
10
+ from .creative_generation import generate_thumbnail_prompt
8
11
  from .storage import STORAGE, now, read_json, write_json
9
12
  from .thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
10
13
 
@@ -51,13 +54,18 @@ def normalize_thumbnail_task(task: dict[str, Any]) -> dict[str, Any]:
51
54
  "task_id": str(task.get("id") or ""),
52
55
  "title": title,
53
56
  "topic": str(task.get("topic") or "").strip(),
57
+ "channel_id": str(task.get("channel_id") or "").strip(),
54
58
  "channel_name": str(task.get("channel_name") or "Canal sem nome").strip(),
59
+ "blueprint_id": str(task.get("blueprint_id") or "").strip(),
60
+ "blueprint_name": str(task.get("blueprint_name") or "").strip(),
55
61
  "status": str(task.get("thumbnail_status") or "not_generated"),
56
62
  "prompt": str(prompt or "").strip(),
57
63
  "image_path": _as_path(image_path),
58
64
  "variant": variant,
59
65
  "variants": variants,
60
66
  "variant_index": _variant_index(task, variant, variants),
67
+ "thumbnail_text": str(variant.get("overlay_text") or task.get("thumbnail_text") or "").strip(),
68
+ "lettering_prompt": str(variant.get("lettering_prompt") or task.get("thumbnail_lettering_prompt") or "").strip(),
61
69
  }
62
70
 
63
71
 
@@ -82,46 +90,282 @@ def list_thumbnail_tasks() -> list[dict[str, Any]]:
82
90
  return records
83
91
 
84
92
 
85
- def regenerate_thumbnail(task_id: str, settings: dict[str, Any]) -> tuple[dict[str, Any], Path]:
93
+ def _archive_image(task_id: str, image_path: Path | None) -> Path | None:
94
+ if not image_path or not image_path.is_file():
95
+ return None
96
+ history_dir = STORAGE / "thumbnails" / "history"
97
+ history_dir.mkdir(parents=True, exist_ok=True)
98
+ history_path = history_dir / f"{task_id}-{uuid.uuid4().hex[:10]}-{image_path.name}"
99
+ shutil.copy2(image_path, history_path)
100
+ return history_path
101
+
102
+
103
+ def _variant_for_record(record: dict[str, Any]) -> dict[str, Any]:
104
+ variant = dict(record.get("variant") or {})
105
+ variants = record.get("variants") if isinstance(record.get("variants"), list) else []
106
+ index = int(record.get("variant_index") or 0)
107
+ if not variant and 0 <= index < len(variants) and isinstance(variants[index], dict):
108
+ variant = dict(variants[index])
109
+ return variant
110
+
111
+
112
+ def _persist_thumbnail_result(
113
+ tasks: list[dict[str, Any]],
114
+ task: dict[str, Any],
115
+ record: dict[str, Any],
116
+ image_path: Path,
117
+ *,
118
+ variant: dict[str, Any] | None = None,
119
+ source: str = "generated",
120
+ lettering_prompt: str = "",
121
+ ) -> dict[str, Any]:
122
+ selected_variant = _variant_for_record(record)
123
+ if variant:
124
+ selected_variant.update({key: value for key, value in variant.items() if value is not None})
125
+ selected_variant["image_path"] = str(image_path)
126
+ selected_variant["status"] = "generated"
127
+ selected_variant.setdefault("image_prompt", record.get("prompt") or "")
128
+ task["thumbnail_variant"] = selected_variant
129
+ variants = task.get("thumbnail_variants")
130
+ if not isinstance(variants, list):
131
+ variants = []
132
+ variants = list(variants)
133
+ index = int(record.get("variant_index") or 0)
134
+ while len(variants) <= index:
135
+ variants.append({})
136
+ variants[index] = {**(variants[index] if isinstance(variants[index], dict) else {}), **selected_variant}
137
+ task["thumbnail_variants"] = variants
138
+ task["thumbnail_prompt"] = str(selected_variant.get("image_prompt") or record.get("prompt") or "")
139
+ task["thumbnail_text"] = str(selected_variant.get("overlay_text") or task.get("thumbnail_text") or "")
140
+ if lettering_prompt:
141
+ task["thumbnail_lettering_prompt"] = lettering_prompt
142
+ task["thumbnail_source"] = source
143
+ task["thumbnail_status"] = "generated"
144
+ artifacts = dict(task.get("artifacts") or {})
145
+ artifacts["thumbnail"] = str(image_path)
146
+ task["artifacts"] = artifacts
147
+ task["thumbnail_path"] = str(image_path)
148
+ task["updated_at"] = now()
149
+ return task
150
+
151
+
152
+ def _persist_thumbnail_prompt_result(
153
+ task: dict[str, Any],
154
+ record: dict[str, Any],
155
+ variant: dict[str, Any],
156
+ ) -> dict[str, Any]:
157
+ """Persist a prompt-only update while leaving the active image and artifacts untouched."""
158
+ selected_variant = _variant_for_record(record)
159
+ selected_variant.update({key: value for key, value in variant.items() if value is not None})
160
+ if record.get("image_path"):
161
+ selected_variant.setdefault("image_path", str(record["image_path"]))
162
+ selected_variant["status"] = "prompt_ready"
163
+ variants = task.get("thumbnail_variants")
164
+ if not isinstance(variants, list):
165
+ variants = []
166
+ variants = list(variants)
167
+ index = int(record.get("variant_index") or 0)
168
+ while len(variants) <= index:
169
+ variants.append({})
170
+ variants[index] = {
171
+ **(variants[index] if isinstance(variants[index], dict) else {}),
172
+ **selected_variant,
173
+ }
174
+ task["thumbnail_variant"] = selected_variant
175
+ task["thumbnail_variants"] = variants
176
+ task["thumbnail_prompt"] = str(selected_variant.get("image_prompt") or "")
177
+ task["thumbnail_text"] = str(selected_variant.get("overlay_text") or task.get("thumbnail_text") or "")
178
+ task["thumbnail_lettering_prompt"] = str(
179
+ selected_variant.get("lettering_prompt") or task.get("thumbnail_lettering_prompt") or ""
180
+ )
181
+ task["thumbnail_source"] = "prompt_regenerated"
182
+ task["thumbnail_status"] = "prompt_ready"
183
+ task["updated_at"] = now()
184
+ return task
185
+
186
+
187
+ def _find_task(tasks: list[Any], task_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
188
+ for task in tasks:
189
+ if isinstance(task, dict) and str(task.get("id") or "") == str(task_id):
190
+ return task, normalize_thumbnail_task(task)
191
+ raise ThumbnailGenerationError(f"A tarefa {task_id} não foi encontrada.")
192
+
193
+
194
+ def generate_thumbnail_for_task(task_id: str, settings: dict[str, Any]) -> tuple[dict[str, Any], Path]:
195
+ """Generate an image from the task's current prompt and persist it on the task."""
86
196
  tasks = read_json("tasks.json", [])
87
197
  if not isinstance(tasks, list):
88
198
  raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
89
- for task in tasks:
90
- if not isinstance(task, dict) or str(task.get("id") or "") != str(task_id):
91
- continue
92
- record = normalize_thumbnail_task(task)
93
- if not record["prompt"]:
94
- raise ThumbnailGenerationError("Esta tarefa não tem um prompt de imagem para refazer a thumbnail.")
95
- previous_image = record.get("image_path")
96
- if previous_image and previous_image.is_file():
97
- history_dir = STORAGE / "thumbnails" / "history"
98
- history_dir.mkdir(parents=True, exist_ok=True)
99
- history_path = history_dir / f"{task_id}-{uuid.uuid4().hex[:10]}-{previous_image.name}"
100
- shutil.copy2(previous_image, history_path)
101
- image_path = generate_thumbnail_image(
102
- settings,
103
- record["prompt"],
104
- topic=record["title"] or record["topic"],
105
- variant_index=record["variant_index"],
106
- )
107
- artifacts = dict(task.get("artifacts") or {})
108
- artifacts["thumbnail"] = str(image_path)
109
- task["artifacts"] = artifacts
110
- task["thumbnail_path"] = str(image_path)
111
- task["thumbnail_status"] = "generated"
112
- variant = dict(record["variant"])
113
- if variant:
114
- variant["image_path"] = str(image_path)
115
- task["thumbnail_variant"] = variant
116
- variants = task.get("thumbnail_variants")
117
- if isinstance(variants, list) and 0 <= record["variant_index"] < len(variants) and isinstance(variants[record["variant_index"]], dict):
118
- variants[record["variant_index"]] = {**variants[record["variant_index"]], "image_path": str(image_path)}
119
- task["thumbnail_variants"] = variants
120
- task["updated_at"] = now()
121
- write_json("tasks.json", tasks)
122
- return task, image_path
123
- raise ThumbnailGenerationError(f"A tarefa {task_id} não foi encontrada.")
199
+ task, record = _find_task(tasks, task_id)
200
+ if not record["prompt"]:
201
+ raise ThumbnailGenerationError("A thumbnail não tem um prompt de imagem para gerar.")
202
+ _archive_image(str(task_id), record.get("image_path"))
203
+ image_path = generate_thumbnail_image(
204
+ settings,
205
+ record["prompt"],
206
+ topic=record["title"] or record["topic"],
207
+ variant_index=record["variant_index"],
208
+ lettering_text=record.get("thumbnail_text") or "",
209
+ lettering_prompt=record.get("lettering_prompt") or "",
210
+ )
211
+ _persist_thumbnail_result(tasks, task, record, image_path)
212
+ write_json("tasks.json", tasks)
213
+ return task, image_path
214
+
215
+
216
+ def regenerate_thumbnail_prompt(
217
+ task_id: str,
218
+ settings: dict[str, Any],
219
+ channel: dict[str, Any],
220
+ blueprint: dict[str, Any] | None = None,
221
+ language: str = "",
222
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
223
+ """Regenerate and persist only the thumbnail prompt, keeping the existing image unchanged."""
224
+ tasks = read_json("tasks.json", [])
225
+ if not isinstance(tasks, list):
226
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
227
+ task, record = _find_task(tasks, task_id)
228
+ topic = record["topic"] or record["title"]
229
+ if not topic:
230
+ raise ThumbnailGenerationError("A tarefa não tem tópico para refazer o prompt da thumbnail.")
231
+ variant = generate_thumbnail_prompt(
232
+ settings,
233
+ channel or {},
234
+ topic,
235
+ current_prompt=record["prompt"],
236
+ blueprint=blueprint,
237
+ language=language,
238
+ )
239
+ _persist_thumbnail_prompt_result(task, record, variant)
240
+ write_json("tasks.json", tasks)
241
+ return task, variant
242
+
243
+
244
+ def regenerate_thumbnail_prompt_and_image(
245
+ task_id: str,
246
+ settings: dict[str, Any],
247
+ variant: dict[str, Any],
248
+ ) -> tuple[dict[str, Any], Path]:
249
+ """Persist a newly generated prompt and render its image as one atomic task update."""
250
+ tasks = read_json("tasks.json", [])
251
+ if not isinstance(tasks, list):
252
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
253
+ task, record = _find_task(tasks, task_id)
254
+ prompt = str((variant or {}).get("image_prompt") or "").strip()
255
+ if not prompt:
256
+ raise ThumbnailGenerationError("O provider não devolveu um prompt de imagem válido.")
257
+ _archive_image(str(task_id), record.get("image_path"))
258
+ image_path = generate_thumbnail_image(
259
+ settings,
260
+ prompt,
261
+ topic=record["title"] or record["topic"],
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 ""),
265
+ )
266
+ _persist_thumbnail_result(tasks, task, record, image_path, variant=variant, source="prompt_regenerated")
267
+ write_json("tasks.json", tasks)
268
+ return task, image_path
269
+
270
+
271
+ def regenerate_thumbnail_lettering(
272
+ task_id: str,
273
+ settings: dict[str, Any],
274
+ lettering_prompt: str = "",
275
+ ) -> tuple[dict[str, Any], Path]:
276
+ """Edit only the lettering while sending the existing image as a Nano Banana reference."""
277
+ tasks = read_json("tasks.json", [])
278
+ if not isinstance(tasks, list):
279
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
280
+ task, record = _find_task(tasks, task_id)
281
+ previous_image = record.get("image_path")
282
+ if not previous_image or not previous_image.is_file():
283
+ raise ThumbnailGenerationError("A thumbnail precisa de uma imagem existente para refazer o lettering.")
284
+ base_prompt = record["prompt"] or "Cria uma thumbnail de YouTube cinematográfica e de alto contraste."
285
+ edit_prompt = str(lettering_prompt or "").strip() or (
286
+ f"Refaz apenas o lettering da thumbnail para o vídeo {record['title']!r}. "
287
+ "Escolhe uma frase curta e forte, com no máximo quatro palavras, relacionada com o título."
288
+ )
289
+ combined_prompt = (
290
+ "BASE IMAGE LAYER — preserva exactamente a composição, enquadramento, sujeitos, fundo, iluminação, "
291
+ "cores, objectos e estilo da imagem de referência. Não recries nem movas nenhum elemento.\n"
292
+ f"{base_prompt}\n\n"
293
+ "LETTERING EDIT LAYER — altera exclusivamente o texto/lettering visível da thumbnail. "
294
+ "Mantém tudo o que pertence à BASE IMAGE LAYER pixelmente tão próximo quanto possível, sem mudar a imagem.\n"
295
+ f"{edit_prompt}\n"
296
+ "Não adicionar logótipos, marcas de água ou outros elementos."
297
+ )
298
+ _archive_image(str(task_id), previous_image)
299
+ image_path = generate_thumbnail_image(
300
+ settings,
301
+ combined_prompt,
302
+ topic=record["title"] or record["topic"],
303
+ variant_index=record["variant_index"],
304
+ reference_image=previous_image,
305
+ lettering_prompt=edit_prompt,
306
+ )
307
+ variant = _variant_for_record(record)
308
+ variant["image_prompt"] = base_prompt
309
+ variant["lettering_prompt"] = edit_prompt
310
+ _persist_thumbnail_result(tasks, task, record, image_path, variant=variant, source="lettering_regenerated", lettering_prompt=edit_prompt)
311
+ write_json("tasks.json", tasks)
312
+ return task, image_path
313
+
314
+
315
+ def upload_thumbnail_image(
316
+ task_id: str,
317
+ image_bytes: bytes,
318
+ filename: str,
319
+ content_type: str = "",
320
+ ) -> tuple[dict[str, Any], Path]:
321
+ """Store a user-provided image, preserve the old one, and attach it to the task."""
322
+ if not isinstance(image_bytes, (bytes, bytearray)) or not image_bytes:
323
+ raise ThumbnailGenerationError("O ficheiro de imagem está vazio.")
324
+ if len(image_bytes) > 20 * 1024 * 1024:
325
+ raise ThumbnailGenerationError("A imagem excede o limite de 20 MB.")
326
+ suffix = Path(str(filename or "")).suffix.lower()
327
+ allowed = {".png", ".jpg", ".jpeg", ".webp"}
328
+ if suffix not in allowed:
329
+ raise ThumbnailGenerationError("Use uma imagem PNG, JPG, JPEG ou WEBP.")
330
+ if content_type and not str(content_type).lower().startswith("image/"):
331
+ raise ThumbnailGenerationError("O ficheiro enviado não é uma imagem válida.")
332
+ tasks = read_json("tasks.json", [])
333
+ if not isinstance(tasks, list):
334
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
335
+ task, record = _find_task(tasks, task_id)
336
+ _archive_image(str(task_id), record.get("image_path"))
337
+ output_dir = STORAGE / "thumbnails" / "uploads"
338
+ output_dir.mkdir(parents=True, exist_ok=True)
339
+ destination = output_dir / f"uploaded-{task_id}-{uuid.uuid4().hex[:12]}{suffix}"
340
+ fd, temp_name = tempfile.mkstemp(prefix=f".{destination.name}.", dir=output_dir)
341
+ try:
342
+ with os.fdopen(fd, "wb") as handle:
343
+ handle.write(bytes(image_bytes))
344
+ handle.flush()
345
+ os.fsync(handle.fileno())
346
+ os.replace(temp_name, destination)
347
+ finally:
348
+ if os.path.exists(temp_name):
349
+ os.unlink(temp_name)
350
+ variant = _variant_for_record(record)
351
+ _persist_thumbnail_result(tasks, task, record, destination, variant=variant, source="uploaded")
352
+ write_json("tasks.json", tasks)
353
+ return task, destination
354
+
355
+
356
+ def regenerate_thumbnail(task_id: str, settings: dict[str, Any]) -> tuple[dict[str, Any], Path]:
357
+ """Backward-compatible alias for regenerating from the current prompt."""
358
+ return generate_thumbnail_for_task(task_id, settings)
124
359
 
125
360
 
126
- __all__ = ["list_thumbnail_tasks", "normalize_thumbnail_task", "regenerate_thumbnail"]
361
+ __all__ = [
362
+ "generate_thumbnail_for_task",
363
+ "list_thumbnail_tasks",
364
+ "normalize_thumbnail_task",
365
+ "regenerate_thumbnail",
366
+ "regenerate_thumbnail_lettering",
367
+ "regenerate_thumbnail_prompt",
368
+ "regenerate_thumbnail_prompt_and_image",
369
+ "upload_thumbnail_image",
370
+ ]
127
371
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.24",
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",