@danhachuel/thunderbolt 0.7.38 → 0.7.40

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
@@ -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
- pair = st.selectbox("Blueprint de roteiro associado", pair_ids, format_func=lambda item: pair_labels.get(item, item or "Sem Blueprint padrão"), key=f"thumbnail_card_pair_{path.stem}")
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
- save_thumbnail_blueprint_pair(path.stem, pair)
2129
- st.success("Associação guardada; canais com esse Blueprint usarão esta thumbnail blueprint automaticamente.")
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
- if thumb in {"Youtube_Generic_Thumbnail_Blueprint", "Tiktok_Generic_Thumbnail_Blueprint"} and script:
2159
- st.error("Not Allowed to Associate, System Use Only")
2160
- else:
2161
- update_channel(channel_id, {"thumbnail_blueprint_id": thumb, "default_thumbnail_blueprint_id": thumb, "blueprint_id": script, "default_blueprint_id": script})
2162
- st.success("Par Blueprint de roteiro + Thumbnail Blueprint guardado.")
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:
@@ -40,7 +40,7 @@ from .provider_routing import (
40
40
  route_json_request,
41
41
  )
42
42
  from .storage import STORAGE, ensure_storage, write_json
43
- from .thumbnail_generation import _compose_thumbnail_prompt, generate_thumbnail_image, normalize_thumbnail_bytes
43
+ from .thumbnail_generation import _compose_thumbnail_prompt, generate_thumbnail_image, infer_thumbnail_aspect_ratio, normalize_thumbnail_bytes
44
44
  from .canva_client import CanvaClient
45
45
  from .canva_mcp_workflow import run_direct_canva_thumbnail
46
46
 
@@ -267,7 +267,14 @@ def _poll_image(card: Mapping[str, Any], request_id: str, *, attempts: int = 24,
267
267
  raise MediaGenerationError("O provider de imagem não concluiu dentro do limite de polling.")
268
268
 
269
269
 
270
- def _download_or_write(image_bytes: bytes | None, url: str, destination: Path, card: Mapping[str, Any]) -> Path:
270
+ def _download_or_write(
271
+ image_bytes: bytes | None,
272
+ url: str,
273
+ destination: Path,
274
+ card: Mapping[str, Any],
275
+ *,
276
+ aspect_ratio: str = "16:9",
277
+ ) -> Path:
271
278
  if image_bytes is None and url:
272
279
  try:
273
280
  response = requests.get(url, headers={"Authorization": f"Bearer {_api_key(card)}"} if _api_key(card) else {}, timeout=180)
@@ -278,7 +285,7 @@ def _download_or_write(image_bytes: bytes | None, url: str, destination: Path, c
278
285
  if not image_bytes:
279
286
  raise MediaGenerationError("O provider concluiu a chamada mas não devolveu uma imagem utilizável.")
280
287
  try:
281
- image_bytes = normalize_thumbnail_bytes(image_bytes)
288
+ image_bytes = normalize_thumbnail_bytes(image_bytes, aspect_ratio)
282
289
  except Exception as exc:
283
290
  raise MediaGenerationError(str(exc)) from exc
284
291
  destination.parent.mkdir(parents=True, exist_ok=True)
@@ -317,7 +324,11 @@ def _image_endpoint(card: Mapping[str, Any]) -> str:
317
324
  raise MediaGenerationError(f"O provider {card.get('provider')} não tem endpoint de imagem configurado.")
318
325
 
319
326
 
320
- def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", lettering_text: str = "", lettering_prompt: str = "") -> Any:
327
+ def _thumbnail_aspect_ratio(thumbnail_blueprint: Mapping[str, Any] | None, prompt: str = "") -> str:
328
+ return infer_thumbnail_aspect_ratio(prompt, dict(thumbnail_blueprint or {}))
329
+
330
+
331
+ 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
332
  provider = str(card.get("provider") or "").strip().lower()
322
333
  style = str(card.get("api_style") or media_provider_definition(provider).api_style)
323
334
  endpoint = _image_endpoint(card)
@@ -327,11 +338,12 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
327
338
  lettering_text=lettering_text,
328
339
  lettering_prompt=lettering_prompt,
329
340
  )
330
- requested_size = "1792x1024" if provider == "pollinations" else "1280x720 minimum"
341
+ aspect_ratio = _thumbnail_aspect_ratio(thumbnail_blueprint, prompt)
342
+ 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
343
  constrained_prompt = _append_generation_constraints(
332
344
  image_prompt,
333
345
  kind="image",
334
- aspect_ratio="16:9",
346
+ aspect_ratio=aspect_ratio,
335
347
  size=requested_size,
336
348
  )
337
349
  constrained_prompt = _fit_provider_prompt(constrained_prompt, provider)
@@ -348,7 +360,7 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
348
360
  if style == "kie":
349
361
  body = {
350
362
  "model": _model(card),
351
- "input": {"prompt": constrained_prompt, "aspect_ratio": "16:9", "resolution": "1K"},
363
+ "input": {"prompt": constrained_prompt, "aspect_ratio": aspect_ratio, "resolution": "1K"},
352
364
  }
353
365
  return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
354
366
  if provider == "agnes":
@@ -356,7 +368,7 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
356
368
  "model": _model(card) or AGNES_IMAGE_MODEL,
357
369
  "prompt": constrained_prompt,
358
370
  "size": "1K",
359
- "ratio": "16:9",
371
+ "ratio": aspect_ratio,
360
372
  "return_base64": True,
361
373
  "extra_body": {"response_format": "b64_json"},
362
374
  }
@@ -365,12 +377,12 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
365
377
  return requests.post(
366
378
  endpoint,
367
379
  headers=_headers(card),
368
- json={"model": _model(card), "prompt": constrained_prompt, "n": 1, "aspect_ratio": "16:9"},
380
+ json={"model": _model(card), "prompt": constrained_prompt, "n": 1, "aspect_ratio": aspect_ratio},
369
381
  timeout=180,
370
382
  )
371
383
  body = {"model": _model(card), "prompt": constrained_prompt, "n": 1, "response_format": "b64_json"}
372
384
  if provider == "pollinations":
373
- body["size"] = "1792x1024"
385
+ body["size"] = "1024x1792" if aspect_ratio == "9:16" else "1792x1024"
374
386
  return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
375
387
 
376
388
 
@@ -427,6 +439,7 @@ def generate_image_for_card(
427
439
  )
428
440
  except Exception as exc:
429
441
  raise MediaGenerationError(str(exc)) from exc
442
+ aspect_ratio = _thumbnail_aspect_ratio(thumbnail_blueprint, prompt)
430
443
  if provider == "nano_banana":
431
444
  merged = dict(settings)
432
445
  merged["gemini_image_api_key"] = _api_key(card)
@@ -439,14 +452,15 @@ def generate_image_for_card(
439
452
  _append_generation_constraints(
440
453
  prompt,
441
454
  kind="image",
442
- aspect_ratio="16:9",
443
- size="1280x720 minimum",
455
+ aspect_ratio=aspect_ratio,
456
+ size="720x1280 minimum" if aspect_ratio == "9:16" else "1280x720 minimum",
444
457
  ),
445
458
  topic=topic,
446
459
  variant_index=variant_index,
447
460
  lettering_text=lettering_text,
448
461
  lettering_prompt=lettering_prompt,
449
462
  reference_image=reference_image,
463
+ aspect_ratio=aspect_ratio,
450
464
  )
451
465
  except Exception as exc:
452
466
  raise MediaGenerationError(str(exc)) from exc
@@ -469,8 +483,8 @@ def generate_image_for_card(
469
483
  constrained_prompt = _append_generation_constraints(
470
484
  image_prompt,
471
485
  kind="image",
472
- aspect_ratio="16:9",
473
- size="1280x720 minimum",
486
+ aspect_ratio=aspect_ratio,
487
+ size="720x1280 minimum" if aspect_ratio == "9:16" else "1280x720 minimum",
474
488
  )
475
489
  from huggingface_hub import InferenceClient
476
490
  image = InferenceClient(token=token or None, provider="hf-inference").text_to_image(
@@ -479,12 +493,12 @@ def generate_image_for_card(
479
493
  )
480
494
  buffer = io.BytesIO()
481
495
  image.save(buffer, format="PNG")
482
- return _download_or_write(buffer.getvalue(), "", destination, card)
496
+ return _download_or_write(buffer.getvalue(), "", destination, card, aspect_ratio=aspect_ratio)
483
497
  except Exception as exc:
484
498
  raise MediaGenerationError(f"Hugging Face text-to-image falhou: {str(exc)[:240]}") from exc
485
499
 
486
500
  def request(current: dict[str, Any]) -> Any:
487
- return _image_request(current, prompt, topic=topic, lettering_text=lettering_text, lettering_prompt=lettering_prompt)
501
+ return _image_request(current, prompt, topic=topic, lettering_text=lettering_text, lettering_prompt=lettering_prompt, thumbnail_blueprint=thumbnail_blueprint)
488
502
 
489
503
  try:
490
504
  routed = route_json_request(settings, pool=POOL_IMAGE, cards=[card], request=request)
@@ -507,7 +521,7 @@ def generate_image_for_card(
507
521
  elif request_id and style == "kie":
508
522
  urls = _poll_kie_task(routed.card, request_id, endpoint="/jobs/recordInfo", veo=False)
509
523
  url = urls[0] if urls else ""
510
- return _download_or_write(image_bytes, url, destination, routed.card)
524
+ return _download_or_write(image_bytes, url, destination, routed.card, aspect_ratio=aspect_ratio)
511
525
 
512
526
 
513
527
  def _is_retryable_media_error(exc: BaseException) -> bool:
@@ -32,7 +32,7 @@ from hermes_ui.llm_providers import active_llm_card, provider_definition
32
32
  from hermes_ui.media_generation import MediaGenerationError, _append_generation_constraints, generate_image_from_pool, generate_video_from_pool
33
33
  from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, media_cards_for_pool, media_provider_definition
34
34
  from hermes_ui.material_sources import material_api_keys, material_source_cards, selected_material_source
35
- from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
35
+ from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image, infer_thumbnail_aspect_ratio
36
36
  from hermes_ui.thumbnail_blueprints import thumbnail_blueprint_for_channel
37
37
  from hermes_ui.voice_preview import synthesize_preview
38
38
 
@@ -97,6 +97,7 @@ def _generate_pipeline_thumbnail(
97
97
  variant_index=variant_index,
98
98
  lettering_text=lettering_text,
99
99
  lettering_prompt=lettering_prompt,
100
+ aspect_ratio=infer_thumbnail_aspect_ratio(prompt, thumbnail_blueprint),
100
101
  )
101
102
  return generate_image_from_pool(
102
103
  settings,
@@ -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
- current_thumbnail = thumbnail_destination / "FINANCE USA_Thumbnail_Blueprint.md"
383
- if legacy_thumbnail.is_file() and not current_thumbnail.exists():
384
- legacy_thumbnail.replace(current_thumbnail)
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
- """Rename the legacy finance thumbnail only in unfinished work items."""
406
- legacy = "FINANCE_Thumbnail_Blueprint"
407
- current = "FINANCE USA_Thumbnail_Blueprint"
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 == legacy:
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
- """Move pending automation/video records to the renamed finance thumbnail."""
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 thumbnail_blueprint_for_blueprint(blueprint_id: Any) -> dict[str, Any]:
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
- return resolve_thumbnail_blueprint(paired_id or HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID)
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,37 @@ 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, YouTube-ready 16:9 JPEG at 1792×1024."""
34
+ def infer_thumbnail_aspect_ratio(
35
+ prompt: str = "",
36
+ blueprint: dict[str, Any] | None = None,
37
+ *,
38
+ fallback: str = DEFAULT_ASPECT_RATIO,
39
+ ) -> str:
40
+ """Resolve the requested thumbnail orientation without trusting provider defaults."""
41
+ blueprint_content = str((blueprint or {}).get("content") or "")
42
+ source = f"{blueprint_content}\n{str(prompt or '')}"
43
+ if re.search(r"\b9\s*:\s*16\b|\bvertical\b|\bportrait\b", source, flags=re.IGNORECASE):
44
+ return "9:16"
45
+ if re.search(r"\b16\s*:\s*9\b|\blandscape\b|\bwide\b", source, flags=re.IGNORECASE):
46
+ return "16:9"
47
+ return fallback if fallback in {"16:9", "9:16"} else DEFAULT_ASPECT_RATIO
48
+
49
+
50
+ def normalize_thumbnail_bytes(image_bytes: bytes, aspect_ratio: str = DEFAULT_ASPECT_RATIO) -> bytes:
51
+ """Return a non-distorted JPEG in the requested thumbnail orientation."""
34
52
  try:
35
53
  with Image.open(__import__("io").BytesIO(image_bytes)) as source:
36
54
  image = ImageOps.exif_transpose(source).convert("RGB")
37
- image = ImageOps.fit(image, (THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT), method=Image.Resampling.LANCZOS, centering=(0.5, 0.5))
55
+ size = (VERTICAL_THUMBNAIL_WIDTH, VERTICAL_THUMBNAIL_HEIGHT) if str(aspect_ratio) == "9:16" else (THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
56
+ image = ImageOps.fit(image, size, method=Image.Resampling.LANCZOS, centering=(0.5, 0.5))
38
57
  output = __import__("io").BytesIO()
39
58
  image.save(output, format="JPEG", quality=95, optimize=True)
40
59
  return output.getvalue()
@@ -171,6 +190,7 @@ def generate_thumbnail_image(
171
190
  reference_image: str | Path | None = None,
172
191
  lettering_text: str = "",
173
192
  lettering_prompt: str = "",
193
+ aspect_ratio: str = DEFAULT_ASPECT_RATIO,
174
194
  ) -> Path:
175
195
  api_key = str(settings.get("gemini_image_api_key") or "").strip()
176
196
  if not api_key:
@@ -184,11 +204,9 @@ def generate_thumbnail_image(
184
204
  lettering_text=lettering_text,
185
205
  lettering_prompt=lettering_prompt,
186
206
  )
207
+ effective_aspect_ratio = infer_thumbnail_aspect_ratio(clean_prompt, fallback=aspect_ratio)
187
208
 
188
209
  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
210
  image_size = DEFAULT_IMAGE_SIZE
193
211
  request_input: str | list[dict[str, str]] = effective_prompt
194
212
  if reference_image:
@@ -202,7 +220,7 @@ def generate_thumbnail_image(
202
220
  "response_format": {
203
221
  "type": "image",
204
222
  "mime_type": DEFAULT_MIME_TYPE,
205
- "aspect_ratio": aspect_ratio,
223
+ "aspect_ratio": effective_aspect_ratio,
206
224
  "image_size": image_size,
207
225
  },
208
226
  "store": False,
@@ -229,7 +247,7 @@ def generate_thumbnail_image(
229
247
  raise ThumbnailGenerationError("A API Nano Banana devolveu uma resposta que não é JSON.") from exc
230
248
  if str(payload.get("status") or "completed").lower() not in {"completed", "succeeded"}:
231
249
  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))
250
+ image_bytes = normalize_thumbnail_bytes(_extract_image_bytes(payload), effective_aspect_ratio)
233
251
 
234
252
  ensure_storage()
235
253
  output_dir = STORAGE / "thumbnails"
@@ -257,6 +275,7 @@ __all__ = [
257
275
  "DEFAULT_ASPECT_RATIO",
258
276
  "DEFAULT_GEMINI_IMAGE_MODEL",
259
277
  "DEFAULT_IMAGE_SIZE",
278
+ "infer_thumbnail_aspect_ratio",
260
279
  "THUMBNAIL_HEIGHT",
261
280
  "THUMBNAIL_WIDTH",
262
281
  "GEMINI_INTERACTIONS_ENDPOINT",
@@ -11,8 +11,8 @@ from .creative_generation import _language_instruction, generate_thumbnail_promp
11
11
  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
- from .thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
15
- from .thumbnail_blueprints import thumbnail_blueprint_for_channel
14
+ from .thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image, infer_thumbnail_aspect_ratio
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=infer_thumbnail_aspect_ratio(prompt, thumbnail_blueprint),
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 and canva_selected:
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 = thumbnail_blueprint_for_channel(
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 = thumbnail_blueprint_for_channel(
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.7.38",
3
+ "version": "0.7.40",
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",
@@ -1,9 +1,26 @@
1
1
  {
2
2
  "BLUEPRINTCANALMILITAR": "Militar_Thumbnail_Blueprint",
3
3
  "MILITAR": "Militar_Thumbnail_Blueprint",
4
- "blueprintcanalfinanças": "FINANCE USA_Thumbnail_Blueprint",
5
- "blueprintcanalfinancas": "FINANCE USA_Thumbnail_Blueprint",
6
- "BLUEPRINT CANAL FINANÇAS": "FINANCE USA_Thumbnail_Blueprint",
7
- "FINANÇAS": "FINANCE USA_Thumbnail_Blueprint",
8
- "FINANCAS": "FINANCE USA_Thumbnail_Blueprint"
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
  }