@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 +191 -17
- package/hermes_ui/creative_generation.py +69 -4
- package/hermes_ui/languages.py +29 -0
- package/hermes_ui/pipeline_worker.py +23 -2
- package/hermes_ui/thumbnail_generation.py +88 -2
- package/hermes_ui/thumbnails.py +281 -37
- package/package.json +1 -1
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
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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: {
|
|
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("
|
|
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
|
|
3249
|
-
key=f"regenerate_thumbnail_{
|
|
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
|
|
3256
|
-
_task,
|
|
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
|
|
3260
|
-
"
|
|
3261
|
-
metadata={"task_id":
|
|
3262
|
-
dedupe_key=f"thumbnail:
|
|
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("
|
|
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
|
|
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,
|
|
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,
|
|
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":
|
|
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
|
)
|
package/hermes_ui/languages.py
CHANGED
|
@@ -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 = {
|
|
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(
|
|
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":
|
|
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(
|
|
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:
|
package/hermes_ui/thumbnails.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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__ = [
|
|
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