@danhachuel/thunderbolt 0.4.20 → 0.4.22

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
@@ -45,6 +45,7 @@ from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_d
45
45
  from app.modules.niche_finder.summarizer import summarize_items
46
46
  from app.influencers_ui import render_ai_influencer_characters, render_ai_influencer_content, render_ai_influencers_api_status, render_motion_control, render_ugc_products
47
47
  from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
48
+ 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_channel
48
49
  from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
49
50
  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
50
51
  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
@@ -605,6 +606,17 @@ def render_channel_blueprint_panel(channel: dict, *, compact: bool = False) -> N
605
606
  st.info(f"**Blueprint utilizado pelo canal:** {summary['name']} · `{summary['id']}` · **Voz:** {voice} · **Idioma:** {video_language}")
606
607
 
607
608
 
609
+ def render_channel_thumbnail_blueprint_panel(channel: dict, *, compact: bool = False) -> None:
610
+ document = thumbnail_blueprint_for_channel(channel)
611
+ name = str(document.get("name") or "SEM THUMBNAIL BLUEPRINT CONFIGURADO")
612
+ if name == "SEM THUMBNAIL BLUEPRINT CONFIGURADO":
613
+ st.warning("**SEM THUMBNAIL BLUEPRINT CONFIGURADO** · associe um Thumbnail Blueprint na aba Thumbnail Blueprints.")
614
+ elif compact:
615
+ st.caption(f"**Thumbnail Blueprint:** {name}")
616
+ else:
617
+ st.info(f"**Thumbnail Blueprint utilizado pelo canal:** {name} · apenas leitura")
618
+
619
+
608
620
  def creative_payload_from_result(channel: dict, topic: str, creative: dict, topic_source: str = "manual") -> dict[str, Any]:
609
621
  variant = creative.get("thumbnail_variant") or {}
610
622
  return {
@@ -620,6 +632,7 @@ def creative_payload_from_result(channel: dict, topic: str, creative: dict, topi
620
632
  "thumbnail_status": str(creative.get("thumbnail_status") or "prompt_ready"),
621
633
  "blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
622
634
  "blueprint_name": str(channel_blueprint_summary(channel).get("name") or "SEM BLUEPRINT CONFIGURADO"),
635
+ "thumbnail_blueprint_id": str(channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id") or ""),
623
636
  "voice": str(channel.get("default_voice") or channel.get("voice") or ""),
624
637
  "ai_generation": {"creative": creative},
625
638
  }
@@ -839,11 +852,15 @@ def generate_thumbnail_for_ui(
839
852
  topic = str(topic or "").strip()
840
853
  if not topic:
841
854
  raise CreativeGenerationError("É necessário um tópico antes de gerar a thumbnail.")
855
+ script_blueprint = blueprint_for_channel(channel)
856
+ visual_blueprint = thumbnail_blueprint_for_channel(channel)
857
+ if visual_blueprint.get("content"):
858
+ script_blueprint = {**script_blueprint, "thumbnail_blueprint_rules": visual_blueprint.get("content", "")}
842
859
  variant = generate_thumbnail_prompt(
843
860
  settings,
844
861
  channel,
845
862
  topic,
846
- blueprint=blueprint_for_channel(channel),
863
+ blueprint=script_blueprint,
847
864
  language=str(channel.get("language") or "Português"),
848
865
  )
849
866
  return {
@@ -1514,6 +1531,75 @@ def render_youtube_brandings():
1514
1531
 
1515
1532
  def render_thumbnail_blueprints():
1516
1533
  st.title("Thumbnail Blueprints")
1534
+ st.caption(f"Documentos de estilo visual por nicho · armazenamento em `{BLUEPRINTS / 'thumbnails'}`")
1535
+ st.info("Crie uma regra visual reutilizável a partir das thumbnails públicas de um canal concorrente. O documento gerado é separado do Blueprint de roteiro e será aplicado automaticamente ao canal associado.")
1536
+ with st.form("create_thumbnail_blueprint_from_link"):
1537
+ source_url = st.text_input("Link do canal ou vídeo YouTube", placeholder="https://www.youtube.com/@canal ou https://youtu.be/video")
1538
+ niche = st.text_input("Nicho (usado no nome do ficheiro)", placeholder="Ex.: Militar")
1539
+ channel_name = st.text_input("Nome do canal concorrente (opcional)")
1540
+ sample_limit = st.slider("Vídeos públicos para analisar", 3, 10, 10)
1541
+ submitted = st.form_submit_button("Analisar e criar Thumbnail Blueprint", type="primary")
1542
+ if submitted:
1543
+ if not source_url.strip() or not niche.strip():
1544
+ st.error("Informe o link do YouTube e o nicho antes de criar.")
1545
+ else:
1546
+ try:
1547
+ public = fetch_channel_videos_public(source_url, limit=sample_limit)
1548
+ if not public.ok and not public.data.get("videos"):
1549
+ raise ValueError(public.message)
1550
+ document = generate_thumbnail_blueprint(read_json("settings.json", {}), source_url=source_url.strip(), niche=niche.strip(), channel_name=channel_name.strip(), videos=public.data.get("videos", []))
1551
+ path = save_thumbnail_blueprint(document)
1552
+ st.success(f"Thumbnail Blueprint criado: {path.name} · {len(document.get('sample_videos', []))} referência(s) analisada(s).")
1553
+ st.rerun()
1554
+ except (ValueError, OSError) as exc:
1555
+ st.error(str(exc))
1556
+ st.divider()
1557
+ st.subheader("Thumbnail Blueprints existentes")
1558
+ search = st.text_input("Pesquisar Thumbnail Blueprints", key="thumbnail_blueprint_search")
1559
+ documents = list_thumbnail_blueprint_documents()
1560
+ if not documents:
1561
+ st.info("Ainda não existem Thumbnail Blueprints. Use um link público do YouTube para criar o primeiro.")
1562
+ for path in documents:
1563
+ if search and search.casefold() not in path.name.casefold():
1564
+ continue
1565
+ with st.container(border=True):
1566
+ st.markdown(f"### {path.stem}")
1567
+ st.caption(f"Ficheiro: `{path.name}`")
1568
+ pair_options = blueprint_catalog()
1569
+ pair_ids = [item[0] for item in pair_options]
1570
+ pair_labels = {item[0]: item[1] for item in pair_options}
1571
+ 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}")
1572
+ if st.button("Guardar associação deste card", key=f"thumbnail_card_pair_save_{path.stem}"):
1573
+ save_thumbnail_blueprint_pair(path.stem, pair)
1574
+ st.success("Associação guardada; canais com esse Blueprint usarão esta thumbnail blueprint automaticamente.")
1575
+ st.code(path.read_text(encoding="utf-8"), language="markdown")
1576
+ st.divider()
1577
+ st.subheader("Associar ao canal e ao Blueprint de roteiro")
1578
+ channels = read_json("channels.json", [])
1579
+ thumbnail_options = thumbnail_blueprint_catalog()
1580
+ thumbnail_ids = [item[0] for item in thumbnail_options]
1581
+ thumbnail_labels = {item[0]: item[1] for item in thumbnail_options}
1582
+ blueprint_options = blueprint_catalog()
1583
+ blueprint_ids = [item[0] for item in blueprint_options]
1584
+ blueprint_labels = {item[0]: item[1] for item in blueprint_options}
1585
+ for channel in channels:
1586
+ channel_id = str(channel.get("id") or "")
1587
+ if not channel_id:
1588
+ continue
1589
+ with st.container(border=True):
1590
+ st.write(f"**{channel.get('name', 'Canal')}**")
1591
+ cols = st.columns(3)
1592
+ current_thumb = str(channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id") or "")
1593
+ current_script = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "")
1594
+ with cols[0]:
1595
+ thumb = st.selectbox("Thumbnail Blueprint", thumbnail_ids, index=thumbnail_ids.index(current_thumb) if current_thumb in thumbnail_ids else 0, format_func=lambda item: thumbnail_labels.get(item, item), key=f"thumbnail_blueprint_channel_{channel_id}")
1596
+ with cols[1]:
1597
+ 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}")
1598
+ with cols[2]:
1599
+ if st.button("Guardar par", key=f"thumbnail_blueprint_save_{channel_id}", use_container_width=True):
1600
+ update_channel(channel_id, {"thumbnail_blueprint_id": thumb, "default_thumbnail_blueprint_id": thumb, "blueprint_id": script, "default_blueprint_id": script})
1601
+ st.success("Par Blueprint de roteiro + Thumbnail Blueprint guardado.")
1602
+ st.rerun()
1517
1603
  def _tiktok_accounts_from_settings(settings: dict[str, Any]) -> list[dict[str, Any]]:
1518
1604
  raw_accounts = settings.get("tiktok_accounts")
1519
1605
  if not isinstance(raw_accounts, list) or not raw_accounts:
@@ -2038,6 +2124,7 @@ def render_channels():
2038
2124
  render_channel_edit_form(channel, youtube_account_ids, youtube_account_labels, youtube_accounts_by_id)
2039
2125
  else:
2040
2126
  summary = channel_blueprint_summary(channel)
2127
+ render_channel_thumbnail_blueprint_panel(channel, compact=True)
2041
2128
  channel_language = language_label(channel.get("language") or "pt")
2042
2129
  block_cols = st.columns(4, gap="small")
2043
2130
  with block_cols[0]:
@@ -2374,6 +2461,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2374
2461
  selected = [selected_one["id"]]
2375
2462
  # Intentionally sits between Canal and the generation settings, as requested.
2376
2463
  render_channel_blueprint_panel(selected_one)
2464
+ render_channel_thumbnail_blueprint_panel(selected_one)
2377
2465
  generation_settings = render_video_generation_settings(
2378
2466
  prefix,
2379
2467
  current_language=str(st.session_state.get("video_language") or ""),
@@ -4183,7 +4271,11 @@ def render_automation():
4183
4271
  with header_cols[3]:
4184
4272
  schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
4185
4273
  blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
4186
- default_cols = st.columns([1.15, 1.15, 1.5, 1.7, 1.35], gap="small")
4274
+ thumbnail_items = thumbnail_blueprint_catalog()
4275
+ thumbnail_ids = [item[0] for item in thumbnail_items]
4276
+ thumbnail_labels = {item[0]: item[1] for item in thumbnail_items}
4277
+ current_thumbnail = str(channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id") or "")
4278
+ default_cols = st.columns([1.0, 1.0, 1.35, 1.55, 1.55, 1.0], gap="small")
4187
4279
  with default_cols[0]:
4188
4280
  st.markdown("**Idioma Padrão**")
4189
4281
  st.caption(language_label(channel.get("language") or "pt"))
@@ -4207,6 +4299,14 @@ def render_automation():
4207
4299
  key=f"automation_voice_{channel_id}",
4208
4300
  )
4209
4301
  with default_cols[4]:
4302
+ automation_thumbnail = st.selectbox(
4303
+ "Thumbnail Blueprint",
4304
+ thumbnail_ids,
4305
+ index=thumbnail_ids.index(current_thumbnail) if current_thumbnail in thumbnail_ids else 0,
4306
+ format_func=lambda item: thumbnail_labels.get(item, item or "Sem Thumbnail Blueprint"),
4307
+ key=f"automation_thumbnail_blueprint_{channel_id}",
4308
+ )
4309
+ with default_cols[5]:
4210
4310
  if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True):
4211
4311
  if not valid_hhmm(schedule_time):
4212
4312
  st.error("Use o formato HH:MM, por exemplo 08:30.")
@@ -4216,6 +4316,7 @@ def render_automation():
4216
4316
  "automation_time": schedule_time.strip(),
4217
4317
  })
4218
4318
  set_channel_defaults(channel_id, automation_blueprint, automation_voice)
4319
+ update_channel(channel_id, {"thumbnail_blueprint_id": automation_thumbnail, "default_thumbnail_blueprint_id": automation_thumbnail})
4219
4320
  st.success("Agendamento guardado.")
4220
4321
  st.rerun()
4221
4322
 
@@ -182,6 +182,7 @@ def _creative_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
182
182
  "thumbnail_status": "pending_prompt",
183
183
  "blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
184
184
  "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
185
+ "thumbnail_blueprint_id": str(channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id") or ""),
185
186
  "voice": str(channel.get("default_voice") or channel.get("voice") or ""),
186
187
  "material_source": material_source,
187
188
  "generation_settings": {"video_keywords": editorial.get("keywords", []), "material_source": material_source},
@@ -228,6 +229,7 @@ def _pending_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
228
229
  "language": language,
229
230
  "blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
230
231
  "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
232
+ "thumbnail_blueprint_id": str(channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id") or ""),
231
233
  "voice": str(channel.get("default_voice") or channel.get("voice") or ""),
232
234
  "material_source": material_source,
233
235
  "generation_settings": {"material_source": material_source},
@@ -136,6 +136,7 @@ def channel_context(channel: dict[str, Any], blueprint: dict[str, Any] | None =
136
136
  "blueprint_id": str(blueprint.get("id") or channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
137
137
  "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
138
138
  "blueprint_niche": str(blueprint.get("target_niche") or blueprint.get("niche") or metadata.get("target_niche") or metadata.get("niche") or ""),
139
+ "thumbnail_blueprint_rules": str(blueprint.get("thumbnail_blueprint_rules") or "").strip(),
139
140
  "default_voice": str(channel.get("default_voice") or channel.get("voice") or ""),
140
141
  }
141
142
 
@@ -45,6 +45,8 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
45
45
  "language": "Português",
46
46
  "blueprint_id": "",
47
47
  "default_blueprint_id": "",
48
+ "thumbnail_blueprint_id": "",
49
+ "default_thumbnail_blueprint_id": "",
48
50
  "style_wide": "pexels",
49
51
  "voice": "",
50
52
  "default_voice": "",
@@ -178,6 +180,7 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
178
180
  "generation_settings": payload.get("generation_settings", options.get("generation_settings", {})),
179
181
  "blueprint_id": payload.get("blueprint_id") or channel.get("default_blueprint_id") or channel.get("blueprint_id", ""),
180
182
  "blueprint_name": payload.get("blueprint_name", ""),
183
+ "thumbnail_blueprint_id": payload.get("thumbnail_blueprint_id") or channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id", ""),
181
184
  "voice": payload.get("voice") or channel.get("default_voice") or channel.get("voice", ""),
182
185
  "automation_on": bool(channel.get("automation_on", False)),
183
186
  "automation_time": channel.get("automation_time", "00:00"),
@@ -21,6 +21,7 @@ TIKTOK_PROMPT_MASTERS = STORAGE / "tiktok" / "prompts_master"
21
21
  MEDIA_DOWNLOADS = STORAGE / "downloads"
22
22
  NICHES_DATA = STORAGE / "data" / "niches"
23
23
  SEED_BLUEPRINTS = ROOT / "seed" / "blueprints"
24
+ SEED_THUMBNAIL_BLUEPRINTS = SEED_BLUEPRINTS / "thumbnails"
24
25
  SEED_TIKTOK_PROMPT_MASTERS = ROOT / "seed" / "prompt_masters"
25
26
 
26
27
  DEFAULTS: dict[str, Any] = {
@@ -359,6 +360,16 @@ def seed_blueprints() -> None:
359
360
  target = destination / source.name
360
361
  if not target.exists():
361
362
  shutil.copy2(source, target)
363
+ thumbnail_destination = BLUEPRINTS / "thumbnails"
364
+ thumbnail_destination.mkdir(parents=True, exist_ok=True)
365
+ for source in sorted(SEED_THUMBNAIL_BLUEPRINTS.glob("*.md")):
366
+ target = thumbnail_destination / source.name
367
+ if not target.exists():
368
+ shutil.copy2(source, target)
369
+ pair_source = SEED_BLUEPRINTS / "thumbnail_blueprint_pairs.json"
370
+ pair_target = BLUEPRINTS / "thumbnail_blueprint_pairs.json"
371
+ if pair_source.exists() and not pair_target.exists():
372
+ shutil.copy2(pair_source, pair_target)
362
373
 
363
374
 
364
375
  def seed_prompt_masters() -> None:
@@ -400,7 +411,7 @@ def _migrate_settings(settings: Any) -> tuple[dict[str, Any], bool]:
400
411
 
401
412
 
402
413
  def ensure_storage() -> None:
403
- for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", TIKTOK_PROMPT_MASTERS, MEDIA_DOWNLOADS, STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "python_editor", STORAGE / "influencers", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", STORAGE / "python_editor", NICHES_DATA]:
414
+ for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", BLUEPRINTS / "thumbnails", TIKTOK_PROMPT_MASTERS, MEDIA_DOWNLOADS, STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "python_editor", STORAGE / "influencers", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", STORAGE / "python_editor", NICHES_DATA]:
404
415
  path.mkdir(parents=True, exist_ok=True)
405
416
  seed_blueprints()
406
417
  seed_prompt_masters()
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Mapping
8
+
9
+ from .provider_routing import route_llm_json
10
+ from .storage import BLUEPRINTS, atomic_write, list_blueprint_files, load_blueprint_file
11
+
12
+ PROMPT_MASTER = '''You are a forensic YouTube thumbnail analyst. Build a reusable Thumbnail Blueprint from the reference channel videos below.
13
+ The output must be a practical, locked visual system, not a script blueprint. Infer recurring composition, framing, lighting, color, typography, overlay text, symbols, emotional triggers, mobile readability, aspect ratio, quality and negative constraints. Use the exact Markdown structure of the requested reference: STYLE LOCK, FRAMING & POSE, BACKGROUND & LIGHTING, GEOPOLITICAL SYMBOLS when relevant, VISUAL ATTENTION ELEMENT, TEXT STYLE, TEXT PSYCHOLOGY, COMPOSITION RULES, FORMAT & QUALITY, FINAL OBJECTIVE, FINAL INPUT FORMAT and FINAL SYSTEM INSTRUCTION. Write the document in English. Do not invent channel analytics. The document must instruct future thumbnail generation and include a concise, ready-to-use image prompt template.'''
14
+
15
+
16
+ def _slug(value: Any) -> str:
17
+ text = re.sub(r"[^A-Za-z0-9]+", "_", str(value or "").strip()).strip("_")
18
+ return text or "General"
19
+
20
+
21
+ def _record_name(niche: str) -> str:
22
+ return f"{_slug(niche)}_Thumbnail_Blueprint"
23
+
24
+
25
+ def thumbnail_blueprint_catalog() -> list[tuple[str, str]]:
26
+ folder = BLUEPRINTS / "thumbnails"
27
+ folder.mkdir(parents=True, exist_ok=True)
28
+ result: list[tuple[str, str]] = [("", "Sem Thumbnail Blueprint padrão")]
29
+ for path in sorted(folder.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True):
30
+ result.append((path.stem, path.stem))
31
+ return result
32
+
33
+
34
+ def resolve_thumbnail_blueprint(identifier: Any) -> dict[str, Any]:
35
+ wanted = str(identifier or "").strip()
36
+ if not wanted:
37
+ return {}
38
+ folder = BLUEPRINTS / "thumbnails"
39
+ for path in folder.glob("*.md"):
40
+ if path.stem == wanted or path.name == wanted:
41
+ return {"id": path.stem, "name": path.stem, "path": str(path), "content": path.read_text(encoding="utf-8")}
42
+ return {"id": wanted, "name": wanted}
43
+
44
+
45
+ def thumbnail_blueprint_for_channel(channel: Mapping[str, Any]) -> dict[str, Any]:
46
+ direct = channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id")
47
+ if direct:
48
+ return resolve_thumbnail_blueprint(direct)
49
+ script_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
50
+ pairs = _pair_state()
51
+ return resolve_thumbnail_blueprint(pairs.get(script_id, ""))
52
+
53
+
54
+ def _pair_state() -> dict[str, str]:
55
+ path = BLUEPRINTS / "thumbnail_blueprint_pairs.json"
56
+ try:
57
+ value = __import__("json").loads(path.read_text(encoding="utf-8"))
58
+ return {str(k): str(v) for k, v in value.items()} if isinstance(value, dict) else {}
59
+ except (OSError, ValueError, TypeError):
60
+ return {}
61
+
62
+
63
+ def save_thumbnail_blueprint_pair(thumbnail_id: str, blueprint_id: str) -> None:
64
+ pairs = _pair_state()
65
+ if blueprint_id:
66
+ pairs[str(blueprint_id)] = str(thumbnail_id)
67
+ else:
68
+ for key, value in list(pairs.items()):
69
+ if value == thumbnail_id:
70
+ pairs.pop(key, None)
71
+ atomic_write(BLUEPRINTS / "thumbnail_blueprint_pairs.json", pairs)
72
+
73
+
74
+ def generate_thumbnail_blueprint(
75
+ settings: Mapping[str, Any],
76
+ *,
77
+ source_url: str,
78
+ niche: str,
79
+ channel_name: str = "",
80
+ videos: list[Mapping[str, Any]] | None = None,
81
+ ) -> dict[str, Any]:
82
+ clean_niche = str(niche or "General").strip() or "General"
83
+ samples = list(videos or [])[:10]
84
+ sample_text = "\n".join(
85
+ f"- Title: {item.get('title', '')}\n URL: {item.get('url', '')}\n Thumbnail URL: {item.get('thumbnail_url', '')}"
86
+ for item in samples
87
+ ) or "- No public sample videos were available; produce a clearly marked baseline system from the niche."
88
+ user_prompt = f"Niche: {clean_niche}\nChannel: {channel_name}\nSource: {source_url}\nReference videos:\n{sample_text}\nReturn JSON with one key content containing only the complete Markdown document."
89
+ try:
90
+ routed = route_llm_json(settings, PROMPT_MASTER, user_prompt)
91
+ content = str(routed.payload.get("content") or "").strip()
92
+ except Exception as exc:
93
+ raise ValueError(f"Não foi possível gerar o Thumbnail Blueprint no provider configurado: {exc}") from exc
94
+ if not content:
95
+ raise ValueError("O provider não devolveu um documento Thumbnail Blueprint válido.")
96
+ if not content.startswith("#"):
97
+ content = f"# {_record_name(clean_niche)}\n\n" + content
98
+ return {
99
+ "id": _record_name(clean_niche),
100
+ "name": _record_name(clean_niche),
101
+ "niche": clean_niche,
102
+ "channel_name": channel_name,
103
+ "source_url": source_url,
104
+ "created_at": datetime.now(timezone.utc).isoformat(),
105
+ "version": 1,
106
+ "sample_videos": samples,
107
+ "content": content.rstrip() + "\n",
108
+ }
109
+
110
+
111
+ def save_thumbnail_blueprint(document: Mapping[str, Any]) -> Path:
112
+ name = _record_name(str(document.get("niche") or document.get("name") or "General").replace("_Thumbnail_Blueprint", ""))
113
+ folder = BLUEPRINTS / "thumbnails"
114
+ folder.mkdir(parents=True, exist_ok=True)
115
+ target = folder / f"{name}.md"
116
+ content = str(document.get("content") or "").strip()
117
+ if not content:
118
+ raise ValueError("O documento Thumbnail Blueprint não pode ficar vazio.")
119
+ front = ["---", f"type: thumbnail_blueprint", f"id: {name}", f"name: {name}", f"niche: {document.get('niche', '')}", f"source_url: {document.get('source_url', '')}", f"created_at: {document.get('created_at', '')}", "---", ""]
120
+ target.write_text("\n".join(front) + content + "\n", encoding="utf-8")
121
+ return target
122
+
123
+
124
+ def list_thumbnail_blueprint_documents() -> list[Path]:
125
+ folder = BLUEPRINTS / "thumbnails"
126
+ folder.mkdir(parents=True, exist_ok=True)
127
+ return sorted(folder.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.20",
3
+ "version": "0.4.22",
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",
@@ -18,6 +18,7 @@
18
18
  "scripts/*.mjs",
19
19
  "seed/prompt_masters/**/*.md",
20
20
  "seed/blueprints/**/*.json",
21
+ "seed/blueprints/**/*.md",
21
22
  "seed/skills/*.md",
22
23
  "seed/skills/*.py",
23
24
  "seed/references/*.md",
package/scripts/cli.mjs CHANGED
@@ -75,6 +75,7 @@ function ensureRuntimeStorage() {
75
75
  join(storageRoot, "blueprints", "nichos"),
76
76
  join(storageRoot, "blueprints", "importados"),
77
77
  join(storageRoot, "blueprints", "brandings"),
78
+ join(storageRoot, "blueprints", "thumbnails"),
78
79
  join(storageRoot, "metadata_cleaner"),
79
80
  join(storageRoot, "metadata_cleaner", "originals"),
80
81
  join(storageRoot, "metadata_cleaner", "outputs"),
@@ -93,6 +94,19 @@ function ensureRuntimeStorage() {
93
94
  const target = join(destination, filename);
94
95
  if (!existsSync(target)) copyFileSync(join(seedRoot, filename), target);
95
96
  }
97
+ const thumbnailSeedRoot = join(seedRoot, "thumbnails");
98
+ const thumbnailDestination = join(storageRoot, "blueprints", "thumbnails");
99
+ if (existsSync(thumbnailSeedRoot)) {
100
+ for (const filename of readdirSync(thumbnailSeedRoot)) {
101
+ if (filename.endsWith(".md")) {
102
+ const target = join(thumbnailDestination, filename);
103
+ if (!existsSync(target)) copyFileSync(join(thumbnailSeedRoot, filename), target);
104
+ }
105
+ }
106
+ }
107
+ const pairSeed = join(seedRoot, "thumbnail_blueprint_pairs.json");
108
+ const pairTarget = join(storageRoot, "blueprints", "thumbnail_blueprint_pairs.json");
109
+ if (existsSync(pairSeed) && !existsSync(pairTarget)) copyFileSync(pairSeed, pairTarget);
96
110
  }
97
111
  }
98
112
 
@@ -103,6 +103,7 @@ function ensureDirs() {
103
103
  join(storageRoot, "blueprints", "nichos"),
104
104
  join(storageRoot, "blueprints", "importados"),
105
105
  join(storageRoot, "blueprints", "brandings"),
106
+ join(storageRoot, "blueprints", "thumbnails"),
106
107
  join(storageRoot, "tiktok"),
107
108
  join(storageRoot, "tiktok", "prompts_master"),
108
109
  join(storageRoot, "metadata_cleaner"),
@@ -130,6 +131,19 @@ function copySeedBlueprints(storageRoot) {
130
131
  const target = join(destination, filename);
131
132
  if (!existsSync(target)) copyFileSync(source, target);
132
133
  }
134
+ const thumbnailSeedRoot = join(seedRoot, "thumbnails");
135
+ const thumbnailDestination = join(storageRoot, "blueprints", "thumbnails");
136
+ if (existsSync(thumbnailSeedRoot)) {
137
+ for (const thumbnailFilename of readdirSync(thumbnailSeedRoot)) {
138
+ if (!thumbnailFilename.endsWith(".md")) continue;
139
+ const source = join(thumbnailSeedRoot, thumbnailFilename);
140
+ const target = join(thumbnailDestination, thumbnailFilename);
141
+ if (!existsSync(target)) copyFileSync(source, target);
142
+ }
143
+ }
144
+ const pairSource = join(seedRoot, "thumbnail_blueprint_pairs.json");
145
+ const pairTarget = join(storageRoot, "blueprints", "thumbnail_blueprint_pairs.json");
146
+ if (existsSync(pairSource) && !existsSync(pairTarget)) copyFileSync(pairSource, pairTarget);
133
147
  }
134
148
 
135
149
  function copySeedPromptMasters(storageRoot) {
@@ -0,0 +1,4 @@
1
+ {
2
+ "BLUEPRINTCANALMILITAR": "Militar_Thumbnail_Blueprint",
3
+ "MILITAR": "Militar_Thumbnail_Blueprint"
4
+ }
@@ -0,0 +1,241 @@
1
+ 🔒 STYLE LOCK — NON-NEGOTIABLE
2
+
3
+ *Ultra-dramatic military breaking-news YouTube thumbnail style used by naval defense news channels.
4
+ Photorealistic war imagery featuring modern naval combat scenarios, explosions, missiles, submarines, fighter jets, and warships.
5
+
6
+ ​The tone is urgent, dramatic, geopolitical crisis, resembling a live military emergency broadcast.
7
+ The visual language must replicate:
8
+
9
+ Breaking news urgency
10
+ High contrast war photography
11
+ Clear geopolitical symbolism using national flags
12
+ Explosions, smoke, missiles, and large red arrows highlighting action
13
+ News-style banner occupying the bottom of the frame
14
+ The style must feel like a shocking military incident that just happened seconds ago.
15
+
16
+ ❌ No stylized illustration
17
+ ​❌ No cartoon elements
18
+ ❌ No minimalism
19
+ ❌ No soft colors
20
+ ❌ No alternate layout structures
21
+
22
+ The system must replicate the exact visual storytelling structure of the reference.
23
+ 🧍‍♂️ FRAMING & POSE
24
+
25
+ Primary subject: large military hardware
26
+
27
+ *Examples:
28
+ Warships
29
+ Missile launches
30
+ Submarines
31
+ Fighter jets
32
+ Naval fleets
33
+ Explosions on ships
34
+
35
+ *Framing rules:
36
+ Main military object center-left or center frame
37
+ Secondary object in background distance
38
+ Action element (missile / explosion / aircraft) top-right or mid-air
39
+
40
+ *Camera angle:
41
+ Cinematic telephoto military photography
42
+ Slightly low or horizon-level perspective
43
+ Ocean horizon visible
44
+ Subjects large and clear
45
+
46
+ *Cropping:
47
+ Military assets must dominate the frame
48
+ Avoid excessive sky
49
+ Ensure action area is visible
50
+
51
+ *Energy:
52
+ Active combat moment
53
+ Missile firing
54
+ Explosion mid-blast
55
+ Smoke rising
56
+ Jets flying overhead
57
+
58
+ 🎨 BACKGROUND & LIGHTING
59
+ *Background environment:
60
+ Open ocean battlefield
61
+ Arctic sea
62
+ Cold war naval environment
63
+ Overcast sky or cold blue daylight
64
+
65
+ *Lighting:
66
+ Natural daylight or cold military lighting
67
+ High contrast
68
+ Realistic shadows
69
+
70
+ *Effects required:
71
+ Thick black smoke
72
+ Bright orange explosions
73
+ Missile exhaust flames
74
+ Water splash or waves
75
+
76
+ *Depth:
77
+ Strong atmospheric depth
78
+ Background ships slightly blurred
79
+
80
+ *Visual noise:
81
+ Moderate realism
82
+ Military hardware details must remain sharp
83
+
84
+ 🧭 GEOPOLITICAL SYMBOLS
85
+ *Flags used as identifiers:
86
+ National flags placed above ships or aircraft
87
+ Small but clearly visible
88
+ Floating overlay style
89
+
90
+ *Purpose:
91
+ Instantly communicate international conflict
92
+
93
+ *Typical combinations:
94
+ NATO vs Russia
95
+ Western fleets vs adversary forces
96
+
97
+ 🚨 VISUAL ATTENTION ELEMENT
98
+ Large red arrow pointing to the key action moment.
99
+
100
+ *Arrow rules:
101
+ Bright red
102
+ Thick outline
103
+ Positioned top-right or top-center
104
+ Pointing directly at explosion, missile, aircraft, or submarine
105
+ Immediate viewer focus
106
+ Mobile screen clarity
107
+
108
+ 📝 TEXT STYLE
109
+ Text is placed in a large news banner at the bottom of the thumbnail.
110
+
111
+ *Structure:
112
+
113
+ *Top strip:
114
+ BREAKING NEWS
115
+
116
+ *Main headline:
117
+ 2–4 WORDS MAX
118
+ JUST HAPPENED
119
+ 1 MINUTE AGO
120
+ THEY ATTACKED
121
+ WAR BEGINS
122
+ SHIP DESTROYED
123
+
124
+ *Bottom strip:
125
+ URGENT ALERT
126
+
127
+ *Typography:
128
+ Bold condensed sans-serif
129
+ Extremely heavy weight
130
+ All caps
131
+
132
+ *Color hierarchy:
133
+ *Top strip:
134
+ Red background
135
+ White text
136
+
137
+ *Main headline:
138
+ Bright yellow background
139
+ Black text
140
+ Text must occupy bottom 30–35% of the thumbnail.
141
+
142
+ 🧠 TEXT PSYCHOLOGY
143
+ *The headline must trigger:
144
+ Urgency
145
+ Breaking news shock
146
+ Military escalation
147
+ Fear of global conflict
148
+ Immediate curiosity
149
+
150
+ *Psychological triggers:
151
+ “Just happened”
152
+ “Minutes ago”
153
+ “Attack”
154
+ “War”
155
+ “Emergency”
156
+
157
+ *Goal:
158
+ Viewer must feel they are seeing breaking world news right now.
159
+
160
+ 🎯 COMPOSITION RULES
161
+ *Rule of thirds:
162
+ Main military subject center-left
163
+ Action element top-right
164
+
165
+ *Focal hierarchy:
166
+ Explosion / missile / aircraft
167
+ Warship or submarine
168
+ Red arrow
169
+ Headline text
170
+
171
+ *Negative space:
172
+ Sky area used to highlight arrows or aircraft
173
+
174
+ *Distraction control:
175
+ ​No cluttered UI elements
176
+ Only essential military imagery
177
+
178
+ *Mobile-first design:
179
+ All key elements readable at small mobile sizes
180
+ Text extremely large
181
+ High color contrast
182
+
183
+ 📐 FORMAT & QUALITY
184
+
185
+ *Aspect ratio:
186
+ 16:9
187
+
188
+ *Resolution:
189
+ 1280 × 720 minimum
190
+
191
+ *Image style:
192
+ Hyper-realistic military photography
193
+ High detail naval equipment
194
+
195
+ *Sharpness:
196
+ Military hardware extremely crisp
197
+ Explosion glow vivid
198
+
199
+ *Restrictions:
200
+ ❌ No stock-photo watermarks
201
+ ❌ No blurry subjects
202
+ ❌ No flat lighting
203
+ ❌ No modern graphic design layouts
204
+
205
+ 🧠 FINAL OBJECTIVE
206
+
207
+ *Primary goal:
208
+ Maximize YouTube CTR for geopolitical military content.
209
+
210
+ *Emotional triggers:
211
+ Shock
212
+ Fear
213
+ Curiosity
214
+ Global conflict intrigue
215
+
216
+ *Audience:
217
+ Military enthusiasts
218
+ Geopolitical news viewers
219
+ Defense technology audiences
220
+ The thumbnail must instantly communicate:
221
+ “A major military incident just happened.”
222
+
223
+ 🧾 FINAL INPUT FORMAT
224
+
225
+ *VIDEO TITLE:
226
+ “China's SECRET Kill Chain TARGETS US Carrier — America's 22-Minute Response Left Beijing SPEECHLESS”
227
+
228
+ ⚙️ FINAL SYSTEM INSTRUCTION
229
+
230
+ *Using the locked style above, generate a photorealistic military breaking-news thumbnail featuring:
231
+ Modern naval combat scene
232
+ Warships or submarines
233
+ Missiles or explosions
234
+ National flags indicating opposing forces
235
+ Large red arrow highlighting the key moment
236
+ Ocean battlefield environment
237
+
238
+ Auto-generate 2–4 word breaking news headline text and place it in the yellow center banner, maintaining the full BREAKING NEWS / URGENT ALERT news-bar structure.
239
+
240
+ Output must be a ready-to-upload 16:9 YouTube thumbnail replicating the reference style exactly. WITHOUT LOGO
241
+