@danhachuel/thunderbolt 0.3.24 → 0.3.25

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
@@ -3215,9 +3223,31 @@ def render_videos():
3215
3223
  st.rerun()
3216
3224
 
3217
3225
 
3226
+ def _thumbnail_editor_context(record: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
3227
+ """Resolve the persisted task channel/Blueprint without requiring either one to remain registered."""
3228
+ channel_id = str(record.get("channel_id") or "").strip()
3229
+ channels = read_json("channels.json", [])
3230
+ channel = next(
3231
+ (item for item in channels if isinstance(item, dict) and str(item.get("id") or "") == channel_id),
3232
+ None,
3233
+ ) if isinstance(channels, list) else None
3234
+ if not channel:
3235
+ channel = {
3236
+ "id": channel_id,
3237
+ "name": record.get("channel_name") or "Canal sem nome",
3238
+ "language": record.get("language") or "Português",
3239
+ "default_blueprint_id": record.get("blueprint_id") or "",
3240
+ "blueprint_id": record.get("blueprint_id") or "",
3241
+ }
3242
+ blueprint = blueprint_for_channel(channel)
3243
+ if not blueprint and record.get("blueprint_id"):
3244
+ blueprint = {"id": record.get("blueprint_id"), "name": record.get("blueprint_name") or record.get("blueprint_id")}
3245
+ return channel, blueprint
3246
+
3247
+
3218
3248
  def render_thumbnails():
3219
3249
  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.")
3250
+ st.caption("Biblioteca de thumbnails associadas às tarefas da pipeline. Cada acção preserva a imagem anterior no histórico local.")
3221
3251
  records = list_thumbnail_tasks()
3222
3252
  if not records:
3223
3253
  st.info("Ainda não existem tarefas com thumbnail gerada ou prompt de imagem disponível.")
@@ -3225,8 +3255,9 @@ def render_thumbnails():
3225
3255
 
3226
3256
  settings = read_json("settings.json", {})
3227
3257
  for record in records:
3258
+ task_id = record["task_id"]
3228
3259
  with st.container(border=True):
3229
- image_col, details_col, action_col = st.columns([1.35, 2.65, 1.25])
3260
+ image_col, details_col, action_col = st.columns([1.25, 2.35, 1.7])
3230
3261
  with image_col:
3231
3262
  image_path = record.get("image_path")
3232
3263
  if image_path and image_path.is_file():
@@ -3236,32 +3267,157 @@ def render_thumbnails():
3236
3267
  st.caption("Imagem ainda não gerada")
3237
3268
  with details_col:
3238
3269
  st.write(f"**{record['title']}**")
3239
- st.caption(f"Canal: {record['channel_name']} · Tarefa: {record['task_id']}")
3270
+ st.caption(f"Canal: {record['channel_name']} · Tarefa: {task_id}")
3240
3271
  st.caption(f"Estado: {record['status']} · Variante: {record['variant_index'] + 1}")
3241
3272
  if record["prompt"]:
3242
3273
  with st.expander("Ver prompt da thumbnail", expanded=False):
3243
3274
  st.code(record["prompt"], language="text")
3244
3275
  else:
3245
- st.warning("Esta tarefa não tem prompt de imagem. Não é possível refazer a thumbnail.")
3276
+ st.warning("A thumbnail não tem um prompt de imagem para gerar.")
3277
+
3246
3278
  with action_col:
3247
3279
  if st.button(
3248
- "Refazer thumbnail",
3249
- key=f"regenerate_thumbnail_{record['task_id']}",
3280
+ "Refazer Prompt Thumb",
3281
+ key=f"regenerate_thumbnail_{task_id}",
3250
3282
  icon=":material/refresh:",
3251
3283
  use_container_width=True,
3284
+ disabled=not bool(record["title"] or record["topic"]),
3285
+ ):
3286
+ try:
3287
+ with st.spinner("A refazer apenas o prompt da thumbnail…"):
3288
+ channel, blueprint = _thumbnail_editor_context(record)
3289
+ _task, prompt_variant = regenerate_thumbnail_prompt(
3290
+ task_id,
3291
+ settings,
3292
+ channel,
3293
+ blueprint=blueprint,
3294
+ language=str(record.get("language") or current_ui_language()),
3295
+ )
3296
+ record_notification(
3297
+ "thumbnail_generation_completed",
3298
+ "Prompt da thumbnail refeito",
3299
+ "Prompt da thumbnail actualizado; a imagem existente foi preservada.",
3300
+ metadata={
3301
+ "task_id": task_id,
3302
+ "channel_name": record["channel_name"],
3303
+ "prompt_regenerated": True,
3304
+ "prompt_only": True,
3305
+ "image_path": str(record.get("image_path") or ""),
3306
+ },
3307
+ dedupe_key=f"thumbnail:prompt-only:{task_id}:{prompt_variant.get('image_prompt', '')}",
3308
+ )
3309
+ st.success("Prompt da thumbnail actualizado; a imagem existente foi preservada.")
3310
+ st.rerun()
3311
+ except (CreativeGenerationError, ThumbnailGenerationError) as exc:
3312
+ st.error(str(exc))
3313
+
3314
+ if st.button(
3315
+ "Gerar Imagem",
3316
+ key=f"generate_thumbnail_image_{task_id}",
3317
+ icon=":material/image:",
3318
+ use_container_width=True,
3252
3319
  disabled=not bool(record["prompt"]),
3253
3320
  ):
3254
3321
  try:
3255
- with st.spinner("A refazer a thumbnail…"):
3256
- _task, image_path = regenerate_thumbnail(record["task_id"], settings)
3322
+ with st.spinner("A gerar a imagem com Nano Banana…"):
3323
+ _task, generated_path = generate_thumbnail_for_task(task_id, settings)
3324
+ record_notification(
3325
+ "thumbnail_generation_completed",
3326
+ f"Thumbnail gerada: {record['title']}",
3327
+ "Thumbnail gerada com sucesso.",
3328
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(generated_path)},
3329
+ dedupe_key=f"thumbnail:generated:{task_id}:{generated_path}",
3330
+ )
3331
+ st.success("Thumbnail gerada com sucesso.")
3332
+ st.rerun()
3333
+ except ThumbnailGenerationError as exc:
3334
+ st.error(str(exc))
3335
+
3336
+ if st.button(
3337
+ "Refazer Prompt e Gerar Imagem",
3338
+ key=f"regenerate_thumbnail_prompt_{task_id}",
3339
+ icon=":material/auto_awesome:",
3340
+ use_container_width=True,
3341
+ disabled=not bool(record["title"] or record["topic"]),
3342
+ ):
3343
+ try:
3344
+ with st.spinner("A refazer o prompt e a imagem…"):
3345
+ channel, blueprint = _thumbnail_editor_context(record)
3346
+ variant = generate_thumbnail_prompt(
3347
+ settings,
3348
+ channel,
3349
+ record["title"] or record["topic"],
3350
+ current_prompt=record["prompt"],
3351
+ blueprint=blueprint,
3352
+ language=str(record.get("language") or current_ui_language()),
3353
+ )
3354
+ _task, generated_path = regenerate_thumbnail_prompt_and_image(task_id, settings, variant)
3355
+ record_notification(
3356
+ "thumbnail_generation_completed",
3357
+ f"Thumbnail renovada: {record['title']}",
3358
+ "Prompt da thumbnail e imagem actualizados.",
3359
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(generated_path), "prompt_regenerated": True},
3360
+ dedupe_key=f"thumbnail:prompt-regenerated:{task_id}:{generated_path}",
3361
+ )
3362
+ st.success("Prompt da thumbnail e imagem actualizados.")
3363
+ st.rerun()
3364
+ except (CreativeGenerationError, ThumbnailGenerationError) as exc:
3365
+ st.error(str(exc))
3366
+
3367
+ if st.button(
3368
+ "Refazer Lettering",
3369
+ key=f"regenerate_thumbnail_lettering_{task_id}",
3370
+ icon=":material/title:",
3371
+ use_container_width=True,
3372
+ disabled=not bool(record.get("image_path") and record["image_path"].is_file()),
3373
+ ):
3374
+ try:
3375
+ with st.spinner("A refazer apenas o lettering…"):
3376
+ _task, generated_path = regenerate_thumbnail_lettering(
3377
+ task_id,
3378
+ settings,
3379
+ lettering_prompt=record.get("lettering_prompt") or "",
3380
+ )
3381
+ record_notification(
3382
+ "thumbnail_generation_completed",
3383
+ f"Lettering refeito: {record['title']}",
3384
+ "Lettering refeito; a imagem original foi usada como base.",
3385
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(generated_path), "lettering_only": True},
3386
+ dedupe_key=f"thumbnail:lettering:{task_id}:{generated_path}",
3387
+ )
3388
+ st.success("Lettering refeito; a imagem original foi usada como base.")
3389
+ st.rerun()
3390
+ except ThumbnailGenerationError as exc:
3391
+ st.error(str(exc))
3392
+
3393
+ uploaded = st.file_uploader(
3394
+ "Upload Image",
3395
+ type=["png", "jpg", "jpeg", "webp"],
3396
+ key=f"thumbnail_upload_{task_id}",
3397
+ help="Suba uma imagem para a associar a esta tarefa e à pipeline.",
3398
+ )
3399
+ if uploaded is not None:
3400
+ uploaded_bytes = uploaded.getvalue()
3401
+ uploaded_digest = hashlib.sha256(uploaded_bytes).hexdigest()
3402
+ digest_key = f"thumbnail_upload_digest_{task_id}"
3403
+ if st.session_state.get(digest_key) != uploaded_digest:
3404
+ try:
3405
+ with st.spinner("A guardar a imagem carregada…"):
3406
+ _task, uploaded_path = upload_thumbnail_image(
3407
+ task_id,
3408
+ uploaded_bytes,
3409
+ uploaded.name,
3410
+ uploaded.type,
3411
+ )
3412
+ st.session_state[digest_key] = uploaded_digest
3257
3413
  record_notification(
3258
3414
  "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}",
3415
+ f"Thumbnail carregada: {record['title']}",
3416
+ "Imagem carregada e vinculada à tarefa.",
3417
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(uploaded_path), "source": "upload"},
3418
+ dedupe_key=f"thumbnail:uploaded:{task_id}:{uploaded_digest}",
3263
3419
  )
3264
- st.success("Thumbnail refeita com sucesso.")
3420
+ st.success("Imagem carregada e vinculada à tarefa.")
3265
3421
  st.rerun()
3266
3422
  except ThumbnailGenerationError as exc:
3267
3423
  st.error(str(exc))
@@ -249,6 +249,60 @@ 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 deve ser curto, legível e ter no máximo 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": "maximum 4 words, emotionally strong, not the full title",
283
+ "lettering_prompt": "instructions for changing only the lettering 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"))
293
+ return {
294
+ "concept": str(result.get("concept") or "Thumbnail renovada").strip(),
295
+ "overlay_text": overlay_text,
296
+ "composition": str(result.get("composition") or "").strip(),
297
+ "color_palette": str(result.get("color_palette") or "").strip(),
298
+ "subject": str(result.get("subject") or topic).strip(),
299
+ "image_prompt": image_prompt,
300
+ "title_synergy": str(result.get("title_synergy") or "").strip(),
301
+ "lettering_prompt": str(result.get("lettering_prompt") or "").strip(),
302
+ "status": "prompt_ready",
303
+ }
304
+
305
+
252
306
  def generate_creative_package(
253
307
  settings: dict[str, Any],
254
308
  channel: dict[str, Any],
@@ -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:
@@ -1,6 +1,7 @@
1
1
  import base64
2
2
  import binascii
3
3
  import hashlib
4
+ import mimetypes
4
5
  import json
5
6
  import os
6
7
  import tempfile
@@ -72,6 +73,25 @@ def _extract_image_bytes(payload: dict[str, Any]) -> bytes:
72
73
  raise ThumbnailGenerationError("O Gemini concluiu a interação, mas não devolveu uma imagem inline.")
73
74
 
74
75
 
76
+ def _image_input(path: Path) -> dict[str, str]:
77
+ if not path.is_file():
78
+ raise ThumbnailGenerationError("A imagem de referência da thumbnail não está disponível no storage.")
79
+ try:
80
+ image_data = path.read_bytes()
81
+ except OSError as exc:
82
+ raise ThumbnailGenerationError("Não foi possível ler a imagem de referência da thumbnail.") from exc
83
+ if not image_data:
84
+ raise ThumbnailGenerationError("A imagem de referência da thumbnail está vazia.")
85
+ mime_type = mimetypes.guess_type(path.name)[0] or "image/jpeg"
86
+ if not mime_type.startswith("image/"):
87
+ mime_type = "image/jpeg"
88
+ return {
89
+ "type": "image",
90
+ "mime_type": mime_type,
91
+ "data": base64.b64encode(image_data).decode("ascii"),
92
+ }
93
+
94
+
75
95
  def _thumbnail_filename(prompt: str, topic: str, variant_index: int, model: str) -> str:
76
96
  source = f"{model}\n{topic.strip()}\n{variant_index}\n{prompt.strip()}".encode("utf-8")
77
97
  digest = hashlib.sha256(source).hexdigest()[:20]
@@ -84,6 +104,7 @@ def generate_thumbnail_image(
84
104
  *,
85
105
  topic: str = "",
86
106
  variant_index: int = 0,
107
+ reference_image: str | Path | None = None,
87
108
  ) -> Path:
88
109
  api_key = str(settings.get("gemini_image_api_key") or "").strip()
89
110
  if not api_key:
@@ -95,9 +116,15 @@ def generate_thumbnail_image(
95
116
  model = str(settings.get("gemini_image_model") or DEFAULT_GEMINI_IMAGE_MODEL).strip()
96
117
  aspect_ratio = str(settings.get("gemini_image_aspect_ratio") or DEFAULT_ASPECT_RATIO).strip()
97
118
  image_size = str(settings.get("gemini_image_size") or DEFAULT_IMAGE_SIZE).strip()
119
+ request_input: str | list[dict[str, str]] = clean_prompt
120
+ if reference_image:
121
+ request_input = [
122
+ {"type": "text", "text": clean_prompt},
123
+ _image_input(Path(reference_image)),
124
+ ]
98
125
  body = {
99
126
  "model": model,
100
- "input": clean_prompt,
127
+ "input": request_input,
101
128
  "response_format": {
102
129
  "type": "image",
103
130
  "mime_type": DEFAULT_MIME_TYPE,
@@ -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,277 @@ 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
+ )
209
+ _persist_thumbnail_result(tasks, task, record, image_path)
210
+ write_json("tasks.json", tasks)
211
+ return task, image_path
212
+
213
+
214
+ def regenerate_thumbnail_prompt(
215
+ task_id: str,
216
+ settings: dict[str, Any],
217
+ channel: dict[str, Any],
218
+ blueprint: dict[str, Any] | None = None,
219
+ language: str = "",
220
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
221
+ """Regenerate and persist only the thumbnail prompt, keeping the existing image unchanged."""
222
+ tasks = read_json("tasks.json", [])
223
+ if not isinstance(tasks, list):
224
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
225
+ task, record = _find_task(tasks, task_id)
226
+ topic = record["topic"] or record["title"]
227
+ if not topic:
228
+ raise ThumbnailGenerationError("A tarefa não tem tópico para refazer o prompt da thumbnail.")
229
+ variant = generate_thumbnail_prompt(
230
+ settings,
231
+ channel or {},
232
+ topic,
233
+ current_prompt=record["prompt"],
234
+ blueprint=blueprint,
235
+ language=language,
236
+ )
237
+ _persist_thumbnail_prompt_result(task, record, variant)
238
+ write_json("tasks.json", tasks)
239
+ return task, variant
240
+
241
+
242
+ def regenerate_thumbnail_prompt_and_image(
243
+ task_id: str,
244
+ settings: dict[str, Any],
245
+ variant: dict[str, Any],
246
+ ) -> tuple[dict[str, Any], Path]:
247
+ """Persist a newly generated prompt and render its image as one atomic task update."""
248
+ tasks = read_json("tasks.json", [])
249
+ if not isinstance(tasks, list):
250
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
251
+ task, record = _find_task(tasks, task_id)
252
+ prompt = str((variant or {}).get("image_prompt") or "").strip()
253
+ if not prompt:
254
+ raise ThumbnailGenerationError("O provider não devolveu um prompt de imagem válido.")
255
+ _archive_image(str(task_id), record.get("image_path"))
256
+ image_path = generate_thumbnail_image(
257
+ settings,
258
+ prompt,
259
+ topic=record["title"] or record["topic"],
260
+ variant_index=record["variant_index"],
261
+ )
262
+ _persist_thumbnail_result(tasks, task, record, image_path, variant=variant, source="prompt_regenerated")
263
+ write_json("tasks.json", tasks)
264
+ return task, image_path
265
+
266
+
267
+ def regenerate_thumbnail_lettering(
268
+ task_id: str,
269
+ settings: dict[str, Any],
270
+ lettering_prompt: str = "",
271
+ ) -> tuple[dict[str, Any], Path]:
272
+ """Edit only the lettering while sending the existing image as a Nano Banana reference."""
273
+ tasks = read_json("tasks.json", [])
274
+ if not isinstance(tasks, list):
275
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
276
+ task, record = _find_task(tasks, task_id)
277
+ previous_image = record.get("image_path")
278
+ if not previous_image or not previous_image.is_file():
279
+ raise ThumbnailGenerationError("A thumbnail precisa de uma imagem existente para refazer o lettering.")
280
+ base_prompt = record["prompt"] or "Cria uma thumbnail de YouTube cinematográfica e de alto contraste."
281
+ edit_prompt = str(lettering_prompt or "").strip() or (
282
+ f"Refaz apenas o lettering da thumbnail para o vídeo {record['title']!r}. "
283
+ "Escolhe uma frase curta e forte, com no máximo quatro palavras, relacionada com o título."
284
+ )
285
+ combined_prompt = (
286
+ "BASE IMAGE LAYER — preserva exactamente a composição, enquadramento, sujeitos, fundo, iluminação, "
287
+ "cores, objectos e estilo da imagem de referência. Não recries nem movas nenhum elemento.\n"
288
+ f"{base_prompt}\n\n"
289
+ "LETTERING EDIT LAYER — altera exclusivamente o texto/lettering visível da thumbnail. "
290
+ "Mantém tudo o que pertence à BASE IMAGE LAYER pixelmente tão próximo quanto possível, sem mudar a imagem.\n"
291
+ f"{edit_prompt}\n"
292
+ "Não adicionar logótipos, marcas de água ou outros elementos."
293
+ )
294
+ _archive_image(str(task_id), previous_image)
295
+ image_path = generate_thumbnail_image(
296
+ settings,
297
+ combined_prompt,
298
+ topic=record["title"] or record["topic"],
299
+ variant_index=record["variant_index"],
300
+ reference_image=previous_image,
301
+ )
302
+ variant = _variant_for_record(record)
303
+ variant["image_prompt"] = base_prompt
304
+ variant["lettering_prompt"] = edit_prompt
305
+ _persist_thumbnail_result(tasks, task, record, image_path, variant=variant, source="lettering_regenerated", lettering_prompt=edit_prompt)
306
+ write_json("tasks.json", tasks)
307
+ return task, image_path
308
+
309
+
310
+ def upload_thumbnail_image(
311
+ task_id: str,
312
+ image_bytes: bytes,
313
+ filename: str,
314
+ content_type: str = "",
315
+ ) -> tuple[dict[str, Any], Path]:
316
+ """Store a user-provided image, preserve the old one, and attach it to the task."""
317
+ if not isinstance(image_bytes, (bytes, bytearray)) or not image_bytes:
318
+ raise ThumbnailGenerationError("O ficheiro de imagem está vazio.")
319
+ if len(image_bytes) > 20 * 1024 * 1024:
320
+ raise ThumbnailGenerationError("A imagem excede o limite de 20 MB.")
321
+ suffix = Path(str(filename or "")).suffix.lower()
322
+ allowed = {".png", ".jpg", ".jpeg", ".webp"}
323
+ if suffix not in allowed:
324
+ raise ThumbnailGenerationError("Use uma imagem PNG, JPG, JPEG ou WEBP.")
325
+ if content_type and not str(content_type).lower().startswith("image/"):
326
+ raise ThumbnailGenerationError("O ficheiro enviado não é uma imagem válida.")
327
+ tasks = read_json("tasks.json", [])
328
+ if not isinstance(tasks, list):
329
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
330
+ task, record = _find_task(tasks, task_id)
331
+ _archive_image(str(task_id), record.get("image_path"))
332
+ output_dir = STORAGE / "thumbnails" / "uploads"
333
+ output_dir.mkdir(parents=True, exist_ok=True)
334
+ destination = output_dir / f"uploaded-{task_id}-{uuid.uuid4().hex[:12]}{suffix}"
335
+ fd, temp_name = tempfile.mkstemp(prefix=f".{destination.name}.", dir=output_dir)
336
+ try:
337
+ with os.fdopen(fd, "wb") as handle:
338
+ handle.write(bytes(image_bytes))
339
+ handle.flush()
340
+ os.fsync(handle.fileno())
341
+ os.replace(temp_name, destination)
342
+ finally:
343
+ if os.path.exists(temp_name):
344
+ os.unlink(temp_name)
345
+ variant = _variant_for_record(record)
346
+ _persist_thumbnail_result(tasks, task, record, destination, variant=variant, source="uploaded")
347
+ write_json("tasks.json", tasks)
348
+ return task, destination
349
+
350
+
351
+ def regenerate_thumbnail(task_id: str, settings: dict[str, Any]) -> tuple[dict[str, Any], Path]:
352
+ """Backward-compatible alias for regenerating from the current prompt."""
353
+ return generate_thumbnail_for_task(task_id, settings)
124
354
 
125
355
 
126
- __all__ = ["list_thumbnail_tasks", "normalize_thumbnail_task", "regenerate_thumbnail"]
356
+ __all__ = [
357
+ "generate_thumbnail_for_task",
358
+ "list_thumbnail_tasks",
359
+ "normalize_thumbnail_task",
360
+ "regenerate_thumbnail",
361
+ "regenerate_thumbnail_lettering",
362
+ "regenerate_thumbnail_prompt",
363
+ "regenerate_thumbnail_prompt_and_image",
364
+ "upload_thumbnail_image",
365
+ ]
127
366
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.24",
3
+ "version": "0.3.25",
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",