@danhachuel/thunderbolt 0.7.37 → 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 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 == "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:
@@ -6508,7 +6509,7 @@ def render_automation():
6508
6509
  st.link_button("Abrir canal", channel_url, type="primary", width="content")
6509
6510
  with header_cols[2]:
6510
6511
  st.markdown("**Thumbnail Blueprint**")
6511
- st.caption(str(paired_thumbnail.get("name") or "Generic_Thumbnail_Blueprint"))
6512
+ st.caption(str(paired_thumbnail.get("name") or "Youtube_Generic_Thumbnail_Blueprint"))
6512
6513
  with header_cols[3]:
6513
6514
  st.markdown("**Idioma do roteiro**")
6514
6515
  st.caption(video_language_label(normalize_video_language(channel.get("language") or "pt")))
@@ -6561,7 +6562,7 @@ def render_automation():
6561
6562
  "format": automation_format,
6562
6563
  })
6563
6564
  set_channel_defaults(channel_id, automation_blueprint, automation_voice)
6564
- paired_id = str(paired_thumbnail.get("id") or "Generic_Thumbnail_Blueprint")
6565
+ paired_id = str(paired_thumbnail.get("id") or "Youtube_Generic_Thumbnail_Blueprint")
6565
6566
  update_channel(channel_id, {"thumbnail_blueprint_id": paired_id, "default_thumbnail_blueprint_id": paired_id})
6566
6567
  st.success("Agendamento guardado.")
6567
6568
  st.rerun()
@@ -7,7 +7,7 @@ from typing import Any
7
7
 
8
8
  from .notifications import record_notification
9
9
  from .storage import StorageIntegrityError, append_json, now, read_json, update_json, write_json
10
- from .thumbnail_blueprints import GENERIC_THUMBNAIL_BLUEPRINT_ID
10
+ from .thumbnail_blueprints import thumbnail_blueprint_for_channel
11
11
 
12
12
  STAGES = ["niche", "blueprint", "brand", "topic", "script", "title", "keywords", "video", "thumbnail_prompt", "thumbnail", "upload"]
13
13
  # Ordem executada pelo worker. Cada etapa só é executada quando o seu artefacto
@@ -242,7 +242,7 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
242
242
  "generation_settings": generation_settings,
243
243
  "blueprint_id": payload.get("blueprint_id") or channel.get("default_blueprint_id") or channel.get("blueprint_id", ""),
244
244
  "blueprint_name": payload.get("blueprint_name", ""),
245
- "thumbnail_blueprint_id": payload.get("thumbnail_blueprint_id") or channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id") or GENERIC_THUMBNAIL_BLUEPRINT_ID,
245
+ "thumbnail_blueprint_id": payload.get("thumbnail_blueprint_id") or channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id") or thumbnail_blueprint_for_channel({**channel, "thumbnail_blueprint_id": ""}, payload.get("format", options.get("format", ""))).get("id", ""),
246
246
  "voice": payload.get("voice") or channel.get("default_voice") or channel.get("voice", ""),
247
247
  "automation_on": bool(channel.get("automation_on", False)),
248
248
  "automation_time": channel.get("automation_time", "00:00"),
@@ -525,7 +525,7 @@ def _refresh_task_channel_video_settings(task: dict[str, Any]) -> None:
525
525
  channel.get("default_thumbnail_blueprint_id")
526
526
  or channel.get("thumbnail_blueprint_id")
527
527
  or task.get("thumbnail_blueprint_id")
528
- or GENERIC_THUMBNAIL_BLUEPRINT_ID
528
+ or thumbnail_blueprint_for_channel({**channel, "thumbnail_blueprint_id": ""}, task.get("format", "")).get("id", "")
529
529
  )
530
530
  task["blueprint_id"] = channel.get("default_blueprint_id") or channel.get("blueprint_id") or task.get("blueprint_id", "")
531
531
  if channel.get("style_wide"):
@@ -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 _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", lettering_text: str = "", lettering_prompt: str = "") -> Any:
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
- requested_size = "1792x1024" if provider == "pollinations" else "1280x720 minimum"
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="16:9",
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": "16:9", "resolution": "1K"},
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": "16:9",
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": "16:9"},
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="16:9",
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="16:9",
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)
@@ -1353,7 +1353,8 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
1353
1353
  blueprint = _blueprint_for_channel(channel)
1354
1354
  if not blueprint and (task.get("blueprint_id") or task.get("blueprint_name")):
1355
1355
  blueprint = {"id": str(task.get("blueprint_id") or ""), "name": str(task.get("blueprint_name") or task.get("blueprint_id") or "")}
1356
- visual_blueprint = thumbnail_blueprint_for_channel(channel)
1356
+ visual_format = task.get("format") or ("portrait" if str(task.get("platform") or "").casefold() in {"tiktok", "instagram"} else "wide")
1357
+ visual_blueprint = thumbnail_blueprint_for_channel(channel, visual_format)
1357
1358
  if visual_blueprint.get("content"):
1358
1359
  blueprint = {**blueprint, "thumbnail_blueprint_rules": visual_blueprint["content"]}
1359
1360
  route = _normalise_video_route(task, 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
@@ -7,9 +7,12 @@ from pathlib import Path
7
7
  from typing import Any, Mapping
8
8
 
9
9
  from .provider_routing import route_llm_json
10
- from .storage import BLUEPRINTS, atomic_write, list_blueprint_files, load_blueprint_file
10
+ from .storage import BLUEPRINTS, SEED_THUMBNAIL_BLUEPRINTS, atomic_write, list_blueprint_files, load_blueprint_file
11
11
 
12
- GENERIC_THUMBNAIL_BLUEPRINT_ID = "Generic_Thumbnail_Blueprint"
12
+ HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID = "Youtube_Generic_Thumbnail_Blueprint"
13
+ VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID = "Tiktok_Generic_Thumbnail_Blueprint"
14
+ # Backwards-compatible name for callers that only need the landscape default.
15
+ GENERIC_THUMBNAIL_BLUEPRINT_ID = HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID
13
16
  GENERIC_ASSOCIATION_ERROR = "Not Allowed to Associate, System Use Only"
14
17
 
15
18
  PROMPT_MASTER = '''You are a forensic YouTube thumbnail analyst. Build a reusable Thumbnail Blueprint from the reference channel videos below.
@@ -38,26 +41,60 @@ def resolve_thumbnail_blueprint(identifier: Any) -> dict[str, Any]:
38
41
  wanted = str(identifier or "").strip()
39
42
  if not wanted:
40
43
  return {}
41
- folder = BLUEPRINTS / "thumbnails"
42
- for path in folder.glob("*.md"):
43
- if path.stem == wanted or path.name == wanted:
44
- return {"id": path.stem, "name": path.stem, "path": str(path), "content": path.read_text(encoding="utf-8")}
44
+ if wanted == "Generic_Thumbnail_Blueprint":
45
+ wanted = HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID
46
+ folders = (BLUEPRINTS / "thumbnails", SEED_THUMBNAIL_BLUEPRINTS)
47
+ for folder in folders:
48
+ for path in folder.glob("*.md"):
49
+ if path.stem == wanted or path.name == wanted:
50
+ return {"id": path.stem, "name": path.stem, "path": str(path), "content": path.read_text(encoding="utf-8")}
45
51
  return {"id": wanted, "name": wanted}
46
52
 
47
53
 
48
- def thumbnail_blueprint_for_channel(channel: Mapping[str, Any]) -> dict[str, Any]:
54
+ def _generic_thumbnail_blueprint_id(format_value: Any = "", platform: Any = "") -> str:
55
+ raw = str(format_value or "").strip().casefold()
56
+ if raw in {"portrait", "portrait 9:16", "vertical", "shorts", "9:16", "tiktok", "reels"}:
57
+ return VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID
58
+ platform_raw = str(platform or "").strip().casefold()
59
+ if platform_raw in {"tiktok", "instagram", "instagram reels", "shorts"} and not raw:
60
+ return VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID
61
+ return HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID
62
+
63
+
64
+ def thumbnail_blueprint_for_channel(channel: Mapping[str, Any], format_value: Any = "") -> dict[str, Any]:
49
65
  direct = channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id")
50
- 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
+ }:
51
71
  return resolve_thumbnail_blueprint(direct)
52
72
  script_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
53
73
  pairs = _pair_state()
54
- return resolve_thumbnail_blueprint(pairs.get(script_id, "") or GENERIC_THUMBNAIL_BLUEPRINT_ID)
74
+ return resolve_thumbnail_blueprint(
75
+ pairs.get(script_id, "") or _generic_thumbnail_blueprint_id(format_value, channel.get("platform"))
76
+ )
77
+
78
+
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)
55
90
 
56
91
 
57
- def thumbnail_blueprint_for_blueprint(blueprint_id: Any) -> dict[str, Any]:
92
+ def thumbnail_blueprint_for_blueprint(blueprint_id: Any, format_value: Any = "") -> dict[str, Any]:
58
93
  """Resolve the visual pair for a script Blueprint, falling back to Generic."""
59
94
  paired_id = _pair_state().get(str(blueprint_id or "").strip(), "")
60
- return resolve_thumbnail_blueprint(paired_id or 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))
61
98
 
62
99
 
63
100
  def _pair_state() -> dict[str, str]:
@@ -69,9 +106,11 @@ def _pair_state() -> dict[str, str]:
69
106
  return {}
70
107
 
71
108
 
109
+ def thumbnail_blueprint_associations() -> dict[str, str]:
110
+ return _pair_state()
111
+
112
+
72
113
  def save_thumbnail_blueprint_pair(thumbnail_id: str, blueprint_id: str) -> None:
73
- if str(thumbnail_id) == GENERIC_THUMBNAIL_BLUEPRINT_ID and str(blueprint_id):
74
- raise ValueError(GENERIC_ASSOCIATION_ERROR)
75
114
  pairs = _pair_state()
76
115
  if blueprint_id:
77
116
  pairs[str(blueprint_id)] = str(thumbnail_id)
@@ -82,6 +121,21 @@ def save_thumbnail_blueprint_pair(thumbnail_id: str, blueprint_id: str) -> None:
82
121
  atomic_write(BLUEPRINTS / "thumbnail_blueprint_pairs.json", pairs)
83
122
 
84
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
+
85
139
  def generate_thumbnail_blueprint(
86
140
  settings: Mapping[str, Any],
87
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, YouTube-ready 16:9 JPEG at 1792×1024."""
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
- image = ImageOps.fit(image, (THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT), method=Image.Resampling.LANCZOS, centering=(0.5, 0.5))
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"
@@ -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 thumbnail_blueprint_for_channel
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 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.37",
3
+ "version": "0.7.39",
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
  }
@@ -0,0 +1,181 @@
1
+ 🔒 TRAVA DE ESTILO — NÃO NEGOCIÁVEL
2
+ [Um parágrafo descrevendo o estilo visual geral do canal: ex.: "Estilo limpo, minimalista, com iluminação high‑key, cores sólidas e composição centrada no rosto/produto, otimizado para consumo em tela vertical de celular."]
3
+
4
+ ❌ Não permitido (elementos que o canal nunca usa, com base nas capas observadas):
5
+
6
+ [ex.: Sem texto no rodapé da tela, sem rostos cortados, sem cores saturadas, etc.]
7
+
8
+ [Liste todas as proibições observadas]
9
+
10
+ 📱 ZONAS SEGURAS (VERTICAL 9:16) — OBRIGATÓRIO
11
+ A capa é feita para 1080 × 1920 px. A UI do TikTok/Reels cobre partes da imagem — nada essencial pode ficar ali.
12
+
13
+ text
14
+ 0 px ─────────────────────────────
15
+ ┌────────────────────┐
16
+ │ ZONA MORTA TOPO │ ~150 px (barra de progresso / "Seguindo")
17
+ ├───────────────┬────┤
18
+ │ │ UI │
19
+ │ ÁREA SEGURA │ DI │ ~220 px (curtir, comentar,
20
+ │ texto + │ RE │ compartilhar, som, avatar)
21
+ │ rosto/produto│ │
22
+ │ │ │
23
+ ├───────────────┴────┤
24
+ │ ZONA MORTA BASE │ ~480 px (legenda, @usuário,
25
+ │ │ música, CTA, barra de navegação)
26
+ └────────────────────┘
27
+ 1920 px ────────────────────────────
28
+ Regras rígidas de zona segura:
29
+
30
+ Margem superior: 150 px livres
31
+
32
+ Margem inferior: 480 px livres
33
+
34
+ Margem direita: 220 px livres
35
+
36
+ Margem esquerda: 80 px livres
37
+
38
+ Área útil real: aproximadamente 780 × 1290 px (canto superior esquerdo em 80,150)
39
+
40
+ Recorte do grid do perfil (3:4):
41
+
42
+ O TikTok e o Instagram cortam a capa em 3:4 no grid do perfil.
43
+
44
+ O núcleo da mensagem (rosto + 2–4 palavras) deve caber dentro do recorte central 1080 × 1440 px (y entre 240 e 1680).
45
+
46
+ Teste sempre: "se essa capa for cortada em 3:4, ainda entendo a mensagem?"
47
+
48
+ 🧍‍♂️ ENQUADRAMENTO E POSE
49
+ Sujeito principal: [descreva o elemento visual principal – pessoa, produto, cena, etc.]
50
+
51
+ Regras de enquadramento:
52
+
53
+ [ex.: Sujeito centralizado horizontalmente, ocupando o terço médio do quadro]
54
+
55
+ [ex.: Rosto/produto nunca abaixo de y = 1400 px]
56
+
57
+ [Adicione regras]
58
+
59
+ Ângulo de câmera: [baixo, alto, nível dos olhos, etc.]
60
+
61
+ Corte (cropping): [quanto do sujeito preenche o quadro — em vertical, o sujeito costuma ocupar 50–70% da altura útil]
62
+
63
+ Energia / ação: [estático, dinâmico, expressivo, ação congelada, etc.]
64
+
65
+ Expressão / leitura à distância: [a emoção precisa ser legível a 2 metros de distância do celular]
66
+
67
+ 🎨 FUNDO E ILUMINAÇÃO
68
+ Ambiente de fundo: [estúdio, externo, escuro, cor sólida, etc.]
69
+
70
+ Iluminação: [high‑key, low‑key, natural, dramática, etc.]
71
+
72
+ Efeitos: [bokeh, flare, fumaça, glow, etc.]
73
+
74
+ Profundidade: [profundidade de campo rasa, foco profundo, etc.]
75
+
76
+ Ruído visual: [nível de detalhe, poluído vs. limpo — em vertical, prefira fundos simples para não competir com o texto]
77
+
78
+ 🧭 IDENTIFICADORES / SÍMBOLOS DO CANAL
79
+ [Descreva símbolos recorrentes que comunicam contexto instantaneamente — bandeiras, logos, ícones específicos, rostos recorrentes, cores-assinatura. Se não houver, escreva explicitamente: "O canal não utiliza identificadores simbólicos recorrentes."]
80
+
81
+ 🚨 ELEMENTO DE ATENÇÃO VISUAL
82
+ [Descreva se o canal usa setas, círculos, zooms, emojis, contornos — especifique cor, forma, posição e regras de uso. Em vertical, o elemento deve ficar acima da zona morta inferior. Se o canal NÃO usa, escreva explicitamente: "O canal não utiliza elementos de atenção visual."]
83
+
84
+ 📝 ESTILO DE TEXTO
85
+ Estrutura:
86
+
87
+ [Número de linhas / faixas de texto e posicionamento — ex.: bloco de 2 linhas no terço superior, alinhado à esquerda]
88
+
89
+ [Hierarquia — ex.: headline grande + sub‑headline menor]
90
+
91
+ Tipografia:
92
+
93
+ [Família, peso, caixa — ex.: sans‑serif bold, CAIXA ALTA, contorno grosso]
94
+
95
+ Hierarquia de cor:
96
+
97
+ [Cor de fundo do bloco, cor do texto de cada camada, contorno/sombra]
98
+
99
+ Tamanho mínimo legível:
100
+
101
+ Fonte nunca menor que 90 px de altura de caixa (em 1080 px de largura)
102
+
103
+ Contraste alto obrigatório (texto claro sobre fundo escuro ou vice‑versa)
104
+
105
+ Cobertura de texto: [% aproximado da capa ocupada por texto — ex.: faixa superior ocupando 25%]
106
+
107
+ Regra do mudo: a capa precisa funcionar com o som desligado.
108
+
109
+ 🧠 PSICOLOGIA DO TEXTO
110
+ [Descreva os gatilhos emocionais usados nas headlines — urgência, curiosidade, choque, números, perguntas, etc.]
111
+
112
+ Exemplos de palavras/frases observadas:
113
+
114
+ [ex.: "Você não vai acreditar", "Destruiu", "Novo", "Por quê", "Ninguém fala disso"]
115
+
116
+ Restrição de comprimento: 2–4 palavras. Máximo 5. Texto longo morre em tela vertical.
117
+
118
+ 🎯 REGRAS DE COMPOSIÇÃO
119
+ Aplicação da regra dos terços: [onde os elementos-chave são posicionados no quadro vertical]
120
+
121
+ Hierarquia focal: [ordem de importância visual — sujeito, texto, elemento de atenção, fundo]
122
+
123
+ Espaço negativo: [como as áreas vazias são usadas — em vertical, o espaço negativo é o que evita que o texto seja engolido pela UI]
124
+
125
+ Controle de distração: [o que é evitado para manter o foco — ex.: nada de detalhes no canto inferior direito]
126
+
127
+ Mobile‑first: [garantir leitura em tela de 5–6 polegadas, com brilho ambiente alto]
128
+
129
+ 📐 FORMATO E QUALIDADE
130
+ Proporção:
131
+ 9:16 (vertical)
132
+
133
+ Resolução:
134
+ 1080 × 1920 px (mínimo) — 1440 × 2560 px (ideal / futuro‑proof)
135
+
136
+ Compatibilidade:
137
+ TikTok · Instagram Reels · YouTube Shorts · Kwai — mesma capa, sem reenquadramento
138
+
139
+ Estilo de imagem:
140
+ [Fotorrealista / ilustrado / misto — descreva o nível de realismo]
141
+
142
+ Nitidez:
143
+ [Clareza esperada — ex.: detalhes nítidos no sujeito, fundo levemente suave]
144
+
145
+ Restrições:
146
+
147
+ [Liste restrições técnicas observadas — sem marca d'água, sem blur, sem iluminação chapada, sem texto cortado pela UI, etc.]
148
+
149
+ [Nenhum elemento essencial fora da ÁREA SEGURA definida acima]
150
+
151
+ 🧠 OBJETIVO FINAL
152
+ Objetivo primário:
153
+ Maximizar CTR / taxa de retenção nos 3 primeiros segundos para [nicho do canal — ex.: reviews de tecnologia, finanças pessoais, true crime, etc.] em formato vertical.
154
+
155
+ Gatilhos emocionais:
156
+ [Emoções-alvo — ex.: curiosidade, medo, surpresa, excitação, indignação]
157
+
158
+ Público:
159
+ [Descreva o espectador-alvo — ex.: entusiastas de tech, investidores, público 18–34]
160
+
161
+ A capa deve comunicar instantaneamente:
162
+ [Mensagem central — ex.: "Uma revelação chocante", "Um produto imperdível", "Você está fazendo isso errado"]
163
+
164
+ Teste de aprovação (checklist):
165
+
166
+ □ Mensagem compreendida em menos de 1 segundo
167
+ □ Nada importante abaixo de y = 1440 px
168
+ □ Nada importante à direita de x = 860 px
169
+ □ Funciona no recorte 3:4 do grid do perfil
170
+ □ Funciona com o som desligado
171
+ □ Legível em tela de celular a 2 metros de distância
172
+ 🧾 FORMATO DE ENTRADA FINAL
173
+ TÍTULO DO VÍDEO / TEMA:
174
+ "[Insira o título real do vídeo ou o tópico do roteiro aqui]"
175
+
176
+ ⚙️ INSTRUÇÃO FINAL DE SISTEMA
177
+ Usando o estilo travado definido acima, gere uma capa vertical para TikTok/Instagram Reels que siga rigorosamente todas as regras composicionais, textuais e visuais.
178
+
179
+ Extraia automaticamente 2–4 palavras do TÍTULO DO VÍDEO fornecido para servir como headline e posicione-a de acordo com as regras de ESTILO DE TEXTO e de ZONAS SEGURAS (nada abaixo de y = 1440 px, nada à direita de x = 860 px).
180
+
181
+ A saída deve ser uma capa pronta para upload em 9:16, mínimo 1080 × 1920 px, replicando a identidade visual exata do canal — válida simultaneamente para TikTok, Reels e Shorts, e legível também no recorte 3:4 do grid do perfil. [Especifique se o logo deve ser incluído ou não — ex.: "SEM LOGO" ou "INCLUIR LOGO DO CANAL no canto superior esquerdo, dentro da área segura".]