@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 +103 -2
- package/hermes_ui/automation_worker.py +2 -0
- package/hermes_ui/creative_generation.py +1 -0
- package/hermes_ui/domain.py +3 -0
- package/hermes_ui/storage.py +12 -1
- package/hermes_ui/thumbnail_blueprints.py +127 -0
- package/package.json +2 -1
- package/scripts/cli.mjs +14 -0
- package/scripts/install.mjs +14 -0
- package/seed/blueprints/thumbnail_blueprint_pairs.json +4 -0
- package/seed/blueprints/thumbnails/Militar_Thumbnail_Blueprint.md +241 -0
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=
|
|
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
|
-
|
|
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
|
|
package/hermes_ui/domain.py
CHANGED
|
@@ -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"),
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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.
|
|
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
|
|
package/scripts/install.mjs
CHANGED
|
@@ -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,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
|
+
|