@danhachuel/thunderbolt 0.7.38 → 0.7.39
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 +11 -10
- package/hermes_ui/media_generation.py +19 -12
- package/hermes_ui/storage.py +61 -8
- package/hermes_ui/thumbnail_blueprints.py +42 -6
- package/hermes_ui/thumbnail_generation.py +8 -7
- package/hermes_ui/thumbnails.py +13 -8
- package/package.json +1 -1
- package/seed/blueprints/thumbnail_blueprint_pairs.json +22 -5
- /package/seed/blueprints/thumbnails/{FINANCE USA_Thumbnail_Blueprint.md → FINANCE_Thumbnail_Blueprint.md} +0 -0
package/app/main.py
CHANGED
|
@@ -97,7 +97,7 @@ from app.modules.token_optimizer.metrics import get_stats as get_token_optimizer
|
|
|
97
97
|
from app.influencers_ui import render_ai_influencer_characters, render_ai_influencer_content, render_ai_influencers_api_status, render_motion_control, render_ugc_products
|
|
98
98
|
from app.social_networks_ui import render_meta_api_cards, render_social_networks
|
|
99
99
|
from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
|
|
100
|
-
from hermes_ui.thumbnail_blueprints import generate_thumbnail_blueprint, list_thumbnail_blueprint_documents, resolve_thumbnail_blueprint, save_thumbnail_blueprint, save_thumbnail_blueprint_pair, thumbnail_blueprint_catalog, thumbnail_blueprint_for_blueprint, thumbnail_blueprint_for_channel
|
|
100
|
+
from hermes_ui.thumbnail_blueprints import generate_thumbnail_blueprint, list_thumbnail_blueprint_documents, resolve_thumbnail_blueprint, save_thumbnail_blueprint, save_thumbnail_blueprint_pair, save_thumbnail_blueprint_pairs, thumbnail_blueprint_associations, thumbnail_blueprint_catalog, thumbnail_blueprint_for_blueprint, thumbnail_blueprint_for_channel
|
|
101
101
|
from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
|
|
102
102
|
from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEditorError, change_speed, editor_manifest, extract_audio, list_edit_records as list_python_editor_records, list_generated_videos, list_scripts, list_video_files, read_script, remove_audio, replace_audio, resize_video, save_edit_record as save_python_editor_record, save_script, store_uploaded_asset, trim_video
|
|
103
103
|
from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips, list_generated_videos as list_cut_generated_videos, list_runs as list_cut_runs, list_video_files as list_cut_video_files, manifest_bytes as cut_manifest_bytes, store_uploaded_video, zip_run as zip_cut_run
|
|
@@ -2122,11 +2122,13 @@ def render_thumbnail_blueprints():
|
|
|
2122
2122
|
pair_options = blueprint_catalog()
|
|
2123
2123
|
pair_ids = [item[0] for item in pair_options]
|
|
2124
2124
|
pair_labels = {item[0]: item[1] for item in pair_options}
|
|
2125
|
-
|
|
2125
|
+
existing_pairs = thumbnail_blueprint_associations()
|
|
2126
|
+
associated = [key for key, value in existing_pairs.items() if value == path.stem]
|
|
2127
|
+
pair = st.multiselect("Blueprints de roteiro associados", pair_ids, default=[item for item in associated if item in pair_ids], format_func=lambda item: pair_labels.get(item, item or "Sem Blueprint padrão"), key=f"thumbnail_card_pair_{path.stem}")
|
|
2126
2128
|
if st.button("Guardar associação deste card", key=f"thumbnail_card_pair_save_{path.stem}"):
|
|
2127
2129
|
try:
|
|
2128
|
-
|
|
2129
|
-
st.success("
|
|
2130
|
+
save_thumbnail_blueprint_pairs(path.stem, pair)
|
|
2131
|
+
st.success("Associações guardadas; todos os Blueprints selecionados usarão esta Thumbnail Blueprint automaticamente.")
|
|
2130
2132
|
except ValueError as exc:
|
|
2131
2133
|
st.error(str(exc))
|
|
2132
2134
|
with st.expander("Abrir documento completo", expanded=False):
|
|
@@ -2155,12 +2157,11 @@ def render_thumbnail_blueprints():
|
|
|
2155
2157
|
script = st.selectbox("Blueprint de roteiro associado", blueprint_ids, index=blueprint_ids.index(current_script) if current_script in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key=f"thumbnail_script_blueprint_channel_{channel_id}")
|
|
2156
2158
|
with cols[2]:
|
|
2157
2159
|
if st.button("Guardar par", key=f"thumbnail_blueprint_save_{channel_id}", width="stretch"):
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
st.rerun()
|
|
2160
|
+
update_channel(channel_id, {"thumbnail_blueprint_id": thumb, "default_thumbnail_blueprint_id": thumb, "blueprint_id": script, "default_blueprint_id": script})
|
|
2161
|
+
if script:
|
|
2162
|
+
save_thumbnail_blueprint_pair(thumb, script)
|
|
2163
|
+
st.success("Par Blueprint de roteiro + Thumbnail Blueprint guardado.")
|
|
2164
|
+
st.rerun()
|
|
2164
2165
|
def _tiktok_accounts_from_settings(settings: dict[str, Any]) -> list[dict[str, Any]]:
|
|
2165
2166
|
raw_accounts = settings.get("tiktok_accounts")
|
|
2166
2167
|
if not isinstance(raw_accounts, list) or not raw_accounts:
|
|
@@ -317,7 +317,12 @@ def _image_endpoint(card: Mapping[str, Any]) -> str:
|
|
|
317
317
|
raise MediaGenerationError(f"O provider {card.get('provider')} não tem endpoint de imagem configurado.")
|
|
318
318
|
|
|
319
319
|
|
|
320
|
-
def
|
|
320
|
+
def _thumbnail_aspect_ratio(thumbnail_blueprint: Mapping[str, Any] | None) -> str:
|
|
321
|
+
content = str((thumbnail_blueprint or {}).get("content") or "")
|
|
322
|
+
return "9:16" if re.search(r"\b9\s*:\s*16\b|VERTICAL", content, flags=re.IGNORECASE) else "16:9"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", lettering_text: str = "", lettering_prompt: str = "", thumbnail_blueprint: Mapping[str, Any] | None = None) -> Any:
|
|
321
326
|
provider = str(card.get("provider") or "").strip().lower()
|
|
322
327
|
style = str(card.get("api_style") or media_provider_definition(provider).api_style)
|
|
323
328
|
endpoint = _image_endpoint(card)
|
|
@@ -327,11 +332,12 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
|
|
|
327
332
|
lettering_text=lettering_text,
|
|
328
333
|
lettering_prompt=lettering_prompt,
|
|
329
334
|
)
|
|
330
|
-
|
|
335
|
+
aspect_ratio = _thumbnail_aspect_ratio(thumbnail_blueprint)
|
|
336
|
+
requested_size = ("1024x1792" if aspect_ratio == "9:16" else "1792x1024") if provider == "pollinations" else ("720x1280 minimum" if aspect_ratio == "9:16" else "1280x720 minimum")
|
|
331
337
|
constrained_prompt = _append_generation_constraints(
|
|
332
338
|
image_prompt,
|
|
333
339
|
kind="image",
|
|
334
|
-
aspect_ratio=
|
|
340
|
+
aspect_ratio=aspect_ratio,
|
|
335
341
|
size=requested_size,
|
|
336
342
|
)
|
|
337
343
|
constrained_prompt = _fit_provider_prompt(constrained_prompt, provider)
|
|
@@ -348,7 +354,7 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
|
|
|
348
354
|
if style == "kie":
|
|
349
355
|
body = {
|
|
350
356
|
"model": _model(card),
|
|
351
|
-
"input": {"prompt": constrained_prompt, "aspect_ratio":
|
|
357
|
+
"input": {"prompt": constrained_prompt, "aspect_ratio": aspect_ratio, "resolution": "1K"},
|
|
352
358
|
}
|
|
353
359
|
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
354
360
|
if provider == "agnes":
|
|
@@ -356,7 +362,7 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
|
|
|
356
362
|
"model": _model(card) or AGNES_IMAGE_MODEL,
|
|
357
363
|
"prompt": constrained_prompt,
|
|
358
364
|
"size": "1K",
|
|
359
|
-
"ratio":
|
|
365
|
+
"ratio": aspect_ratio,
|
|
360
366
|
"return_base64": True,
|
|
361
367
|
"extra_body": {"response_format": "b64_json"},
|
|
362
368
|
}
|
|
@@ -365,12 +371,12 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
|
|
|
365
371
|
return requests.post(
|
|
366
372
|
endpoint,
|
|
367
373
|
headers=_headers(card),
|
|
368
|
-
json={"model": _model(card), "prompt": constrained_prompt, "n": 1, "aspect_ratio":
|
|
374
|
+
json={"model": _model(card), "prompt": constrained_prompt, "n": 1, "aspect_ratio": aspect_ratio},
|
|
369
375
|
timeout=180,
|
|
370
376
|
)
|
|
371
377
|
body = {"model": _model(card), "prompt": constrained_prompt, "n": 1, "response_format": "b64_json"}
|
|
372
378
|
if provider == "pollinations":
|
|
373
|
-
body["size"] = "1792x1024"
|
|
379
|
+
body["size"] = "1024x1792" if aspect_ratio == "9:16" else "1792x1024"
|
|
374
380
|
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
375
381
|
|
|
376
382
|
|
|
@@ -427,6 +433,7 @@ def generate_image_for_card(
|
|
|
427
433
|
)
|
|
428
434
|
except Exception as exc:
|
|
429
435
|
raise MediaGenerationError(str(exc)) from exc
|
|
436
|
+
aspect_ratio = _thumbnail_aspect_ratio(thumbnail_blueprint)
|
|
430
437
|
if provider == "nano_banana":
|
|
431
438
|
merged = dict(settings)
|
|
432
439
|
merged["gemini_image_api_key"] = _api_key(card)
|
|
@@ -439,8 +446,8 @@ def generate_image_for_card(
|
|
|
439
446
|
_append_generation_constraints(
|
|
440
447
|
prompt,
|
|
441
448
|
kind="image",
|
|
442
|
-
aspect_ratio=
|
|
443
|
-
size="1280x720 minimum",
|
|
449
|
+
aspect_ratio=aspect_ratio,
|
|
450
|
+
size="720x1280 minimum" if aspect_ratio == "9:16" else "1280x720 minimum",
|
|
444
451
|
),
|
|
445
452
|
topic=topic,
|
|
446
453
|
variant_index=variant_index,
|
|
@@ -469,8 +476,8 @@ def generate_image_for_card(
|
|
|
469
476
|
constrained_prompt = _append_generation_constraints(
|
|
470
477
|
image_prompt,
|
|
471
478
|
kind="image",
|
|
472
|
-
aspect_ratio=
|
|
473
|
-
size="1280x720 minimum",
|
|
479
|
+
aspect_ratio=aspect_ratio,
|
|
480
|
+
size="720x1280 minimum" if aspect_ratio == "9:16" else "1280x720 minimum",
|
|
474
481
|
)
|
|
475
482
|
from huggingface_hub import InferenceClient
|
|
476
483
|
image = InferenceClient(token=token or None, provider="hf-inference").text_to_image(
|
|
@@ -484,7 +491,7 @@ def generate_image_for_card(
|
|
|
484
491
|
raise MediaGenerationError(f"Hugging Face text-to-image falhou: {str(exc)[:240]}") from exc
|
|
485
492
|
|
|
486
493
|
def request(current: dict[str, Any]) -> Any:
|
|
487
|
-
return _image_request(current, prompt, topic=topic, lettering_text=lettering_text, lettering_prompt=lettering_prompt)
|
|
494
|
+
return _image_request(current, prompt, topic=topic, lettering_text=lettering_text, lettering_prompt=lettering_prompt, thumbnail_blueprint=thumbnail_blueprint)
|
|
488
495
|
|
|
489
496
|
try:
|
|
490
497
|
routed = route_json_request(settings, pool=POOL_IMAGE, cards=[card], request=request)
|
package/hermes_ui/storage.py
CHANGED
|
@@ -379,9 +379,9 @@ def seed_blueprints() -> None:
|
|
|
379
379
|
thumbnail_destination = BLUEPRINTS / "thumbnails"
|
|
380
380
|
thumbnail_destination.mkdir(parents=True, exist_ok=True)
|
|
381
381
|
legacy_thumbnail = thumbnail_destination / "FINANCE_Thumbnail_Blueprint.md"
|
|
382
|
-
|
|
383
|
-
if
|
|
384
|
-
|
|
382
|
+
renamed_thumbnail = thumbnail_destination / "FINANCE USA_Thumbnail_Blueprint.md"
|
|
383
|
+
if renamed_thumbnail.is_file() and not legacy_thumbnail.exists():
|
|
384
|
+
renamed_thumbnail.replace(legacy_thumbnail)
|
|
385
385
|
for source in sorted(SEED_THUMBNAIL_BLUEPRINTS.glob("*.md")):
|
|
386
386
|
target = thumbnail_destination / source.name
|
|
387
387
|
if not target.exists():
|
|
@@ -393,7 +393,15 @@ def seed_blueprints() -> None:
|
|
|
393
393
|
seeded_pairs = json.loads(pair_source.read_text(encoding="utf-8"))
|
|
394
394
|
current_pairs = json.loads(pair_target.read_text(encoding="utf-8")) if pair_target.exists() else {}
|
|
395
395
|
if isinstance(seeded_pairs, dict) and isinstance(current_pairs, dict):
|
|
396
|
+
finance_ids = {
|
|
397
|
+
"blueprintcanalfinanças", "blueprintcanalfinancas", "BLUEPRINT CANAL FINANÇAS",
|
|
398
|
+
"FINANÇAS", "FINANCAS", "FINANCE", "FINANCE USA", "FINANCE BRAZIL", "FINANCE CANADA",
|
|
399
|
+
"FINANCE FRANCE", "FINANCE GERMANY", "FINANCE IRELAND", "FINANCE ISRAEL", "FINANCE ITALY",
|
|
400
|
+
"FINANCE JAPAN", "FINANCE MEXICO", "FINANCE POLONY", "FINANCE SOUTH AFRICA",
|
|
401
|
+
"FINANCE SOUTH COREA", "FINANCE SPAIN", "FINANCE UK", "FINANCE AUSTRALIA",
|
|
402
|
+
}
|
|
396
403
|
merged_pairs = {**seeded_pairs, **current_pairs}
|
|
404
|
+
merged_pairs.update({key: "FINANCE_Thumbnail_Blueprint" for key in finance_ids})
|
|
397
405
|
if merged_pairs != current_pairs:
|
|
398
406
|
atomic_write(pair_target, merged_pairs)
|
|
399
407
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
@@ -402,15 +410,15 @@ def seed_blueprints() -> None:
|
|
|
402
410
|
|
|
403
411
|
|
|
404
412
|
def _rename_pending_finance_thumbnail_references(value: Any) -> tuple[Any, bool]:
|
|
405
|
-
"""
|
|
406
|
-
|
|
407
|
-
current = "
|
|
413
|
+
"""Restore the finance thumbnail name in unfinished persisted records."""
|
|
414
|
+
renamed = "FINANCE USA_Thumbnail_Blueprint"
|
|
415
|
+
current = "FINANCE_Thumbnail_Blueprint"
|
|
408
416
|
changed = False
|
|
409
417
|
|
|
410
418
|
def replace(item: Any) -> Any:
|
|
411
419
|
nonlocal changed
|
|
412
420
|
if isinstance(item, str):
|
|
413
|
-
if item ==
|
|
421
|
+
if item == renamed:
|
|
414
422
|
changed = True
|
|
415
423
|
return current
|
|
416
424
|
return item
|
|
@@ -424,7 +432,7 @@ def _rename_pending_finance_thumbnail_references(value: Any) -> tuple[Any, bool]
|
|
|
424
432
|
|
|
425
433
|
|
|
426
434
|
def migrate_finance_thumbnail_blueprint() -> None:
|
|
427
|
-
"""
|
|
435
|
+
"""Restore the finance thumbnail name in pending records and map finance variants."""
|
|
428
436
|
def load_state(name: str, default: Any) -> Any:
|
|
429
437
|
path = STATE / name
|
|
430
438
|
try:
|
|
@@ -477,6 +485,50 @@ def migrate_finance_thumbnail_blueprint() -> None:
|
|
|
477
485
|
save_state("queues.json", updated_queues)
|
|
478
486
|
|
|
479
487
|
|
|
488
|
+
def migrate_tiktok_thumbnail_blueprint() -> None:
|
|
489
|
+
"""Adopt the vertical generic thumbnail for legacy TikTok state."""
|
|
490
|
+
def load_state(name: str, default: Any) -> Any:
|
|
491
|
+
try:
|
|
492
|
+
return _load_json_unlocked(STATE / name)
|
|
493
|
+
except (OSError, json.JSONDecodeError):
|
|
494
|
+
return default
|
|
495
|
+
|
|
496
|
+
def save_state(name: str, value: Any) -> None:
|
|
497
|
+
_atomic_write_unlocked(STATE / name, value)
|
|
498
|
+
|
|
499
|
+
channels = load_state("channels.json", [])
|
|
500
|
+
channel_map = {str(item.get("id") or ""): item for item in channels if isinstance(item, dict)} if isinstance(channels, list) else {}
|
|
501
|
+
vertical_ids = {"Generic_Thumbnail_Blueprint", "Youtube_Generic_Thumbnail_Blueprint"}
|
|
502
|
+
channel_changes = False
|
|
503
|
+
if isinstance(channels, list):
|
|
504
|
+
for channel in channels:
|
|
505
|
+
platform = str(channel.get("platform") or "").strip().casefold()
|
|
506
|
+
current = str(channel.get("thumbnail_blueprint_id") or channel.get("default_thumbnail_blueprint_id") or "").strip()
|
|
507
|
+
if platform in {"tiktok", "instagram", "instagram reels"} and (not current or current in vertical_ids):
|
|
508
|
+
channel["thumbnail_blueprint_id"] = "Tiktok_Generic_Thumbnail_Blueprint"
|
|
509
|
+
channel["default_thumbnail_blueprint_id"] = "Tiktok_Generic_Thumbnail_Blueprint"
|
|
510
|
+
channel_changes = True
|
|
511
|
+
if channel_changes:
|
|
512
|
+
save_state("channels.json", channels)
|
|
513
|
+
|
|
514
|
+
tasks = load_state("tasks.json", [])
|
|
515
|
+
tasks_changed = False
|
|
516
|
+
if isinstance(tasks, list):
|
|
517
|
+
for task in tasks:
|
|
518
|
+
if not isinstance(task, dict):
|
|
519
|
+
continue
|
|
520
|
+
channel = channel_map.get(str(task.get("channel_id") or ""), {})
|
|
521
|
+
platform = str(task.get("platform") or channel.get("platform") or "").strip().casefold()
|
|
522
|
+
format_value = str(task.get("format") or (task.get("generation_settings") or {}).get("video_aspect_ratio") or "").strip().casefold()
|
|
523
|
+
vertical = platform in {"tiktok", "instagram", "instagram reels"} or format_value in {"portrait", "vertical", "shorts", "9:16"}
|
|
524
|
+
current = str(task.get("thumbnail_blueprint_id") or "").strip()
|
|
525
|
+
if vertical and (not current or current in vertical_ids):
|
|
526
|
+
task["thumbnail_blueprint_id"] = "Tiktok_Generic_Thumbnail_Blueprint"
|
|
527
|
+
tasks_changed = True
|
|
528
|
+
if tasks_changed:
|
|
529
|
+
save_state("tasks.json", tasks)
|
|
530
|
+
|
|
531
|
+
|
|
480
532
|
def seed_prompt_masters() -> None:
|
|
481
533
|
"""Copy packaged TikTok Prompt Masters without overwriting user files."""
|
|
482
534
|
if not SEED_TIKTOK_PROMPT_MASTERS.exists():
|
|
@@ -525,6 +577,7 @@ def ensure_storage() -> None:
|
|
|
525
577
|
if not target.exists():
|
|
526
578
|
atomic_write(target, default)
|
|
527
579
|
migrate_finance_thumbnail_blueprint()
|
|
580
|
+
migrate_tiktok_thumbnail_blueprint()
|
|
528
581
|
|
|
529
582
|
|
|
530
583
|
_LOCK_TIMEOUT_SECONDS = 30.0
|
|
@@ -53,7 +53,7 @@ def resolve_thumbnail_blueprint(identifier: Any) -> dict[str, Any]:
|
|
|
53
53
|
|
|
54
54
|
def _generic_thumbnail_blueprint_id(format_value: Any = "", platform: Any = "") -> str:
|
|
55
55
|
raw = str(format_value or "").strip().casefold()
|
|
56
|
-
if raw in {"portrait", "vertical", "shorts", "9:16", "tiktok", "reels"}:
|
|
56
|
+
if raw in {"portrait", "portrait 9:16", "vertical", "shorts", "9:16", "tiktok", "reels"}:
|
|
57
57
|
return VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID
|
|
58
58
|
platform_raw = str(platform or "").strip().casefold()
|
|
59
59
|
if platform_raw in {"tiktok", "instagram", "instagram reels", "shorts"} and not raw:
|
|
@@ -63,7 +63,11 @@ def _generic_thumbnail_blueprint_id(format_value: Any = "", platform: Any = "")
|
|
|
63
63
|
|
|
64
64
|
def thumbnail_blueprint_for_channel(channel: Mapping[str, Any], format_value: Any = "") -> dict[str, Any]:
|
|
65
65
|
direct = channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id")
|
|
66
|
-
if direct
|
|
66
|
+
if direct and str(direct).strip() not in {
|
|
67
|
+
"Generic_Thumbnail_Blueprint",
|
|
68
|
+
HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID,
|
|
69
|
+
VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID,
|
|
70
|
+
}:
|
|
67
71
|
return resolve_thumbnail_blueprint(direct)
|
|
68
72
|
script_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
|
|
69
73
|
pairs = _pair_state()
|
|
@@ -72,10 +76,25 @@ def thumbnail_blueprint_for_channel(channel: Mapping[str, Any], format_value: An
|
|
|
72
76
|
)
|
|
73
77
|
|
|
74
78
|
|
|
75
|
-
def
|
|
79
|
+
def thumbnail_blueprint_for_task(channel: Mapping[str, Any], task: Mapping[str, Any]) -> dict[str, Any]:
|
|
80
|
+
"""Resolve a task thumbnail, replacing stale generic orientation defaults."""
|
|
81
|
+
format_value = task.get("format") or (task.get("generation_settings") or {}).get("video_aspect_ratio")
|
|
82
|
+
direct = str(task.get("thumbnail_blueprint_id") or "").strip()
|
|
83
|
+
if direct and direct not in {
|
|
84
|
+
"Generic_Thumbnail_Blueprint",
|
|
85
|
+
HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID,
|
|
86
|
+
VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID,
|
|
87
|
+
}:
|
|
88
|
+
return resolve_thumbnail_blueprint(direct)
|
|
89
|
+
return thumbnail_blueprint_for_channel({**channel, "thumbnail_blueprint_id": ""}, format_value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def thumbnail_blueprint_for_blueprint(blueprint_id: Any, format_value: Any = "") -> dict[str, Any]:
|
|
76
93
|
"""Resolve the visual pair for a script Blueprint, falling back to Generic."""
|
|
77
94
|
paired_id = _pair_state().get(str(blueprint_id or "").strip(), "")
|
|
78
|
-
|
|
95
|
+
if isinstance(paired_id, list):
|
|
96
|
+
paired_id = paired_id[0] if paired_id else ""
|
|
97
|
+
return resolve_thumbnail_blueprint(paired_id or _generic_thumbnail_blueprint_id(format_value))
|
|
79
98
|
|
|
80
99
|
|
|
81
100
|
def _pair_state() -> dict[str, str]:
|
|
@@ -87,9 +106,11 @@ def _pair_state() -> dict[str, str]:
|
|
|
87
106
|
return {}
|
|
88
107
|
|
|
89
108
|
|
|
109
|
+
def thumbnail_blueprint_associations() -> dict[str, str]:
|
|
110
|
+
return _pair_state()
|
|
111
|
+
|
|
112
|
+
|
|
90
113
|
def save_thumbnail_blueprint_pair(thumbnail_id: str, blueprint_id: str) -> None:
|
|
91
|
-
if str(thumbnail_id) in {HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID, VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID} and str(blueprint_id):
|
|
92
|
-
raise ValueError(GENERIC_ASSOCIATION_ERROR)
|
|
93
114
|
pairs = _pair_state()
|
|
94
115
|
if blueprint_id:
|
|
95
116
|
pairs[str(blueprint_id)] = str(thumbnail_id)
|
|
@@ -100,6 +121,21 @@ def save_thumbnail_blueprint_pair(thumbnail_id: str, blueprint_id: str) -> None:
|
|
|
100
121
|
atomic_write(BLUEPRINTS / "thumbnail_blueprint_pairs.json", pairs)
|
|
101
122
|
|
|
102
123
|
|
|
124
|
+
def save_thumbnail_blueprint_pairs(thumbnail_id: str, blueprint_ids: list[str]) -> None:
|
|
125
|
+
"""Associate one thumbnail blueprint with any number of script blueprints."""
|
|
126
|
+
selected = {str(item).strip() for item in blueprint_ids if str(item).strip()}
|
|
127
|
+
pairs = _pair_state()
|
|
128
|
+
for key, value in list(pairs.items()):
|
|
129
|
+
values = value if isinstance(value, list) else [value]
|
|
130
|
+
if str(key) in selected:
|
|
131
|
+
continue
|
|
132
|
+
if str(thumbnail_id) in {str(item) for item in values}:
|
|
133
|
+
pairs.pop(key, None)
|
|
134
|
+
for blueprint_id in selected:
|
|
135
|
+
pairs[blueprint_id] = str(thumbnail_id)
|
|
136
|
+
atomic_write(BLUEPRINTS / "thumbnail_blueprint_pairs.json", pairs)
|
|
137
|
+
|
|
138
|
+
|
|
103
139
|
def generate_thumbnail_blueprint(
|
|
104
140
|
settings: Mapping[str, Any],
|
|
105
141
|
*,
|
|
@@ -23,18 +23,21 @@ DEFAULT_MIME_TYPE = "image/jpeg"
|
|
|
23
23
|
DEFAULT_IMAGE_EXTENSION = ".jpg"
|
|
24
24
|
THUMBNAIL_WIDTH = 1792
|
|
25
25
|
THUMBNAIL_HEIGHT = 1024
|
|
26
|
+
VERTICAL_THUMBNAIL_WIDTH = 1024
|
|
27
|
+
VERTICAL_THUMBNAIL_HEIGHT = 1792
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
class ThumbnailGenerationError(RuntimeError):
|
|
29
31
|
"""Raised when Nano Banana cannot produce a thumbnail image."""
|
|
30
32
|
|
|
31
33
|
|
|
32
|
-
def normalize_thumbnail_bytes(image_bytes: bytes) -> bytes:
|
|
33
|
-
"""Return a non-distorted
|
|
34
|
+
def normalize_thumbnail_bytes(image_bytes: bytes, aspect_ratio: str = DEFAULT_ASPECT_RATIO) -> bytes:
|
|
35
|
+
"""Return a non-distorted JPEG in the requested thumbnail orientation."""
|
|
34
36
|
try:
|
|
35
37
|
with Image.open(__import__("io").BytesIO(image_bytes)) as source:
|
|
36
38
|
image = ImageOps.exif_transpose(source).convert("RGB")
|
|
37
|
-
|
|
39
|
+
size = (VERTICAL_THUMBNAIL_WIDTH, VERTICAL_THUMBNAIL_HEIGHT) if str(aspect_ratio) == "9:16" else (THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
|
40
|
+
image = ImageOps.fit(image, size, method=Image.Resampling.LANCZOS, centering=(0.5, 0.5))
|
|
38
41
|
output = __import__("io").BytesIO()
|
|
39
42
|
image.save(output, format="JPEG", quality=95, optimize=True)
|
|
40
43
|
return output.getvalue()
|
|
@@ -171,6 +174,7 @@ def generate_thumbnail_image(
|
|
|
171
174
|
reference_image: str | Path | None = None,
|
|
172
175
|
lettering_text: str = "",
|
|
173
176
|
lettering_prompt: str = "",
|
|
177
|
+
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
|
174
178
|
) -> Path:
|
|
175
179
|
api_key = str(settings.get("gemini_image_api_key") or "").strip()
|
|
176
180
|
if not api_key:
|
|
@@ -186,9 +190,6 @@ def generate_thumbnail_image(
|
|
|
186
190
|
)
|
|
187
191
|
|
|
188
192
|
model = str(settings.get("gemini_image_model") or DEFAULT_GEMINI_IMAGE_MODEL).strip()
|
|
189
|
-
# YouTube thumbnails are never square: do not allow a legacy/provider setting
|
|
190
|
-
# to override the required horizontal canvas.
|
|
191
|
-
aspect_ratio = DEFAULT_ASPECT_RATIO
|
|
192
193
|
image_size = DEFAULT_IMAGE_SIZE
|
|
193
194
|
request_input: str | list[dict[str, str]] = effective_prompt
|
|
194
195
|
if reference_image:
|
|
@@ -229,7 +230,7 @@ def generate_thumbnail_image(
|
|
|
229
230
|
raise ThumbnailGenerationError("A API Nano Banana devolveu uma resposta que não é JSON.") from exc
|
|
230
231
|
if str(payload.get("status") or "completed").lower() not in {"completed", "succeeded"}:
|
|
231
232
|
raise ThumbnailGenerationError(f"A interação Nano Banana terminou com estado inesperado: {payload.get('status') or 'desconhecido'}.")
|
|
232
|
-
image_bytes = normalize_thumbnail_bytes(_extract_image_bytes(payload))
|
|
233
|
+
image_bytes = normalize_thumbnail_bytes(_extract_image_bytes(payload), aspect_ratio)
|
|
233
234
|
|
|
234
235
|
ensure_storage()
|
|
235
236
|
output_dir = STORAGE / "thumbnails"
|
package/hermes_ui/thumbnails.py
CHANGED
|
@@ -12,7 +12,7 @@ from .media_generation import generate_image_from_pool
|
|
|
12
12
|
from .media_providers import media_cards_for_pool
|
|
13
13
|
from .storage import STORAGE, ensure_storage, now, read_json, update_json, write_json
|
|
14
14
|
from .thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
|
|
15
|
-
from .thumbnail_blueprints import
|
|
15
|
+
from .thumbnail_blueprints import thumbnail_blueprint_for_task
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def _generate_image_with_pool(
|
|
@@ -41,6 +41,7 @@ def _generate_image_with_pool(
|
|
|
41
41
|
reference_image=reference_image,
|
|
42
42
|
lettering_text=lettering_text,
|
|
43
43
|
lettering_prompt=lettering_prompt,
|
|
44
|
+
aspect_ratio="9:16" if "9:16" in str((thumbnail_blueprint or {}).get("content") or "") else "16:9",
|
|
44
45
|
)
|
|
45
46
|
return generate_image_from_pool(
|
|
46
47
|
settings,
|
|
@@ -270,15 +271,13 @@ def generate_thumbnail_for_task(
|
|
|
270
271
|
and bool(item.get("enabled", True))
|
|
271
272
|
for item in configured_cards
|
|
272
273
|
) if isinstance(configured_cards, list) else False
|
|
273
|
-
if not effective_blueprint
|
|
274
|
+
if not effective_blueprint:
|
|
274
275
|
channels = read_json("channels.json", [])
|
|
275
276
|
channel = next(
|
|
276
277
|
(item for item in channels if isinstance(item, dict) and str(item.get("id") or "") == record["channel_id"]),
|
|
277
278
|
{},
|
|
278
279
|
) if isinstance(channels, list) else {}
|
|
279
|
-
effective_blueprint =
|
|
280
|
-
{**channel, "thumbnail_blueprint_id": record["thumbnail_blueprint_id"] or channel.get("thumbnail_blueprint_id", "")}
|
|
281
|
-
)
|
|
280
|
+
effective_blueprint = thumbnail_blueprint_for_task(channel, task)
|
|
282
281
|
rules = str(effective_blueprint.get("content") or "").strip()
|
|
283
282
|
if canva_selected and not rules:
|
|
284
283
|
raise ThumbnailGenerationError("A tarefa não tem um Thumbnail Blueprint local válido.")
|
|
@@ -316,9 +315,7 @@ def regenerate_thumbnail_prompt(
|
|
|
316
315
|
if not topic:
|
|
317
316
|
raise ThumbnailGenerationError("A tarefa não tem tópico para refazer o prompt da thumbnail.")
|
|
318
317
|
ensure_storage()
|
|
319
|
-
visual_blueprint =
|
|
320
|
-
{**(channel or {}), "thumbnail_blueprint_id": record.get("thumbnail_blueprint_id") or (channel or {}).get("thumbnail_blueprint_id", "")}
|
|
321
|
-
)
|
|
318
|
+
visual_blueprint = thumbnail_blueprint_for_task(channel or {}, task)
|
|
322
319
|
effective_blueprint = {**(blueprint or {})}
|
|
323
320
|
if visual_blueprint.get("content"):
|
|
324
321
|
effective_blueprint["thumbnail_blueprint_rules"] = visual_blueprint["content"]
|
|
@@ -350,6 +347,12 @@ def regenerate_thumbnail_prompt_and_image(
|
|
|
350
347
|
prompt = str((variant or {}).get("image_prompt") or "").strip()
|
|
351
348
|
if not prompt:
|
|
352
349
|
raise ThumbnailGenerationError("O provider não devolveu um prompt de imagem válido.")
|
|
350
|
+
channels = read_json("channels.json", [])
|
|
351
|
+
channel = next(
|
|
352
|
+
(item for item in channels if isinstance(item, dict) and str(item.get("id") or "") == str(task.get("channel_id") or "")),
|
|
353
|
+
{},
|
|
354
|
+
) if isinstance(channels, list) else {}
|
|
355
|
+
effective_blueprint = thumbnail_blueprint_for_task(channel, task)
|
|
353
356
|
_archive_image(str(task_id), record.get("image_path"))
|
|
354
357
|
image_path = _generate_image_with_pool(
|
|
355
358
|
settings,
|
|
@@ -358,6 +361,7 @@ def regenerate_thumbnail_prompt_and_image(
|
|
|
358
361
|
variant_index=record["variant_index"],
|
|
359
362
|
lettering_text=str((variant or {}).get("overlay_text") or ""),
|
|
360
363
|
lettering_prompt=str((variant or {}).get("lettering_prompt") or ""),
|
|
364
|
+
thumbnail_blueprint=effective_blueprint,
|
|
361
365
|
)
|
|
362
366
|
updated = _update_thumbnail_task(
|
|
363
367
|
task_id,
|
|
@@ -433,6 +437,7 @@ def regenerate_thumbnail_lettering(
|
|
|
433
437
|
reference_image=previous_image,
|
|
434
438
|
lettering_text=exact_headline,
|
|
435
439
|
lettering_prompt=edit_prompt,
|
|
440
|
+
thumbnail_blueprint=thumbnail_blueprint_for_task(resolved_channel, task),
|
|
436
441
|
)
|
|
437
442
|
variant = _variant_for_record(record)
|
|
438
443
|
variant["image_prompt"] = base_prompt
|
package/package.json
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"BLUEPRINTCANALMILITAR": "Militar_Thumbnail_Blueprint",
|
|
3
3
|
"MILITAR": "Militar_Thumbnail_Blueprint",
|
|
4
|
-
"blueprintcanalfinanças": "
|
|
5
|
-
"blueprintcanalfinancas": "
|
|
6
|
-
"BLUEPRINT CANAL FINANÇAS": "
|
|
7
|
-
"FINANÇAS": "
|
|
8
|
-
"FINANCAS": "
|
|
4
|
+
"blueprintcanalfinanças": "FINANCE_Thumbnail_Blueprint",
|
|
5
|
+
"blueprintcanalfinancas": "FINANCE_Thumbnail_Blueprint",
|
|
6
|
+
"BLUEPRINT CANAL FINANÇAS": "FINANCE_Thumbnail_Blueprint",
|
|
7
|
+
"FINANÇAS": "FINANCE_Thumbnail_Blueprint",
|
|
8
|
+
"FINANCAS": "FINANCE_Thumbnail_Blueprint",
|
|
9
|
+
"FINANCE": "FINANCE_Thumbnail_Blueprint",
|
|
10
|
+
"FINANCE USA": "FINANCE_Thumbnail_Blueprint",
|
|
11
|
+
"FINANCE AUSTRALIA": "FINANCE_Thumbnail_Blueprint",
|
|
12
|
+
"FINANCE BRAZIL": "FINANCE_Thumbnail_Blueprint",
|
|
13
|
+
"FINANCE CANADA": "FINANCE_Thumbnail_Blueprint",
|
|
14
|
+
"FINANCE FRANCE": "FINANCE_Thumbnail_Blueprint",
|
|
15
|
+
"FINANCE GERMANY": "FINANCE_Thumbnail_Blueprint",
|
|
16
|
+
"FINANCE IRELAND": "FINANCE_Thumbnail_Blueprint",
|
|
17
|
+
"FINANCE ISRAEL": "FINANCE_Thumbnail_Blueprint",
|
|
18
|
+
"FINANCE ITALY": "FINANCE_Thumbnail_Blueprint",
|
|
19
|
+
"FINANCE JAPAN": "FINANCE_Thumbnail_Blueprint",
|
|
20
|
+
"FINANCE MEXICO": "FINANCE_Thumbnail_Blueprint",
|
|
21
|
+
"FINANCE POLONY": "FINANCE_Thumbnail_Blueprint",
|
|
22
|
+
"FINANCE SOUTH AFRICA": "FINANCE_Thumbnail_Blueprint",
|
|
23
|
+
"FINANCE SOUTH COREA": "FINANCE_Thumbnail_Blueprint",
|
|
24
|
+
"FINANCE SPAIN": "FINANCE_Thumbnail_Blueprint",
|
|
25
|
+
"FINANCE UK": "FINANCE_Thumbnail_Blueprint"
|
|
9
26
|
}
|
|
File without changes
|