@danhachuel/thunderbolt 0.3.25 → 0.3.27
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 +164 -36
- package/hermes_ui/creative_generation.py +20 -9
- package/hermes_ui/languages.py +12 -0
- package/hermes_ui/pipeline_worker.py +23 -2
- package/hermes_ui/thumbnail_generation.py +62 -3
- package/hermes_ui/thumbnails.py +5 -0
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
+
import html
|
|
4
5
|
import json
|
|
5
6
|
import re
|
|
6
7
|
from contextlib import nullcontext
|
|
@@ -201,6 +202,30 @@ st.markdown("""
|
|
|
201
202
|
[data-testid="stSidebar"] [data-testid="stExpander"] summary p { margin:0; line-height:1; }
|
|
202
203
|
[data-testid="stSidebar"] [data-testid="stExpander"] > div { padding:0 0 0 0.42rem !important; }
|
|
203
204
|
[data-testid="stSidebar"] [data-testid="stExpander"] > div [data-testid="stButton"] button { padding-left:0.92rem; font-size:0.83rem; min-height:1.62rem; height:1.62rem; }
|
|
205
|
+
.tb-channel-kanban-card { box-sizing:border-box; min-height:330px; height:330px; padding:0.85rem 0.9rem; border:1px solid color-mix(in srgb, var(--tb-accent) 42%, currentColor 18%); border-radius:14px; background:color-mix(in srgb, currentColor 5%, transparent); color:inherit; box-shadow:0 10px 24px color-mix(in srgb, currentColor 12%, transparent); overflow:hidden; }
|
|
206
|
+
.tb-channel-kanban-card__header { display:flex; align-items:center; gap:0.58rem; min-width:0; }
|
|
207
|
+
.tb-channel-kanban-card__avatar { width:46px; height:46px; flex:0 0 46px; border-radius:10px; object-fit:cover; background:color-mix(in srgb, var(--tb-accent) 14%, transparent); }
|
|
208
|
+
.tb-channel-kanban-card__avatar--fallback { display:flex; align-items:center; justify-content:center; font-weight:800; color:var(--tb-accent); }
|
|
209
|
+
.tb-channel-kanban-card__identity { min-width:0; flex:1 1 auto; }
|
|
210
|
+
.tb-channel-kanban-card__name { font-size:1rem; font-weight:750; line-height:1.18; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
211
|
+
.tb-channel-kanban-card__niche { margin-top:0.22rem; font-size:0.76rem; opacity:.66; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
212
|
+
.tb-channel-kanban-card__badges { display:flex; gap:0.28rem; flex:0 0 auto; align-items:center; }
|
|
213
|
+
.tb-channel-kanban-card__badge { padding:0.18rem 0.42rem; border-radius:6px; font-size:0.67rem; font-weight:750; line-height:1.1; white-space:nowrap; }
|
|
214
|
+
.tb-channel-kanban-card__badge--youtube { background:#dc2626; color:#fff; }
|
|
215
|
+
.tb-channel-kanban-card__badge--active { background:color-mix(in srgb, #22c55e 25%, transparent); color:#16a34a; border:1px solid color-mix(in srgb, #22c55e 50%, transparent); }
|
|
216
|
+
.tb-channel-kanban-card__badge--inactive { background:color-mix(in srgb, #ef4444 18%, transparent); color:#dc2626; border:1px solid color-mix(in srgb, #ef4444 45%, transparent); }
|
|
217
|
+
.tb-channel-kanban-card__rule { height:1px; margin:0.72rem 0 0.62rem; background:color-mix(in srgb, currentColor 13%, transparent); }
|
|
218
|
+
.tb-channel-kanban-card__metrics { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:0.34rem; }
|
|
219
|
+
.tb-channel-kanban-card__metric { min-width:0; min-height:67px; padding:0.48rem 0.28rem 0.36rem; border-radius:8px; text-align:center; background:color-mix(in srgb, currentColor 4%, transparent); }
|
|
220
|
+
.tb-channel-kanban-card__metric-value { font-size:1.12rem; font-weight:800; line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
221
|
+
.tb-channel-kanban-card__metric-label { margin-top:0.3rem; font-size:0.66rem; opacity:.64; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
222
|
+
.tb-channel-kanban-card__status-row { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:0.34rem; margin-top:0.6rem; }
|
|
223
|
+
.tb-channel-kanban-card__status { min-width:0; padding:0.42rem 0.28rem; border-radius:8px; text-align:center; font-size:0.71rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
224
|
+
.tb-channel-kanban-card__status strong { font-size:0.92rem; margin-right:0.18rem; }
|
|
225
|
+
.tb-channel-kanban-card__status--doing { color:#d97706; background:color-mix(in srgb, #f59e0b 14%, transparent); border:1px solid color-mix(in srgb, #f59e0b 35%, transparent); }
|
|
226
|
+
.tb-channel-kanban-card__status--done { color:#16a34a; background:color-mix(in srgb, #22c55e 11%, transparent); border:1px solid color-mix(in srgb, #22c55e 30%, transparent); }
|
|
227
|
+
.tb-channel-kanban-card__titles { margin-top:0.58rem; font-size:0.73rem; opacity:.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
228
|
+
@media (max-width: 900px) { .tb-channel-kanban-card { min-height:310px; height:310px; } .tb-channel-kanban-card__name { font-size:.9rem; } }
|
|
204
229
|
.content-card { box-sizing:border-box; padding:1rem 1.1rem; border:1px solid rgba(128,128,128,.28); border-radius:14px; background:rgba(128,128,128,.10); color:inherit; min-height:110px; box-shadow:0 4px 14px rgba(0,0,0,.12); }
|
|
205
230
|
.content-card { border:1px solid color-mix(in srgb, currentColor 18%, transparent); background:color-mix(in srgb, currentColor 5%, transparent); box-shadow:0 4px 14px color-mix(in srgb, currentColor 12%, transparent); }
|
|
206
231
|
.content-label { color:inherit; opacity:.72; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; }
|
|
@@ -939,7 +964,7 @@ def render_channel_videos(channel: dict) -> None:
|
|
|
939
964
|
channel_id = str(channel.get("id") or "")
|
|
940
965
|
st.markdown("### Últimos 10 vídeos publicados")
|
|
941
966
|
st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
|
|
942
|
-
refresh_col
|
|
967
|
+
refresh_col = st.columns(1)[0]
|
|
943
968
|
with refresh_col:
|
|
944
969
|
if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
|
|
945
970
|
result = fetch_channel_videos_public(channel, limit=10)
|
|
@@ -950,42 +975,25 @@ def render_channel_videos(channel: dict) -> None:
|
|
|
950
975
|
st.rerun()
|
|
951
976
|
else:
|
|
952
977
|
st.warning(result.message)
|
|
953
|
-
with view_col:
|
|
954
|
-
view_mode = st.radio("Vista", ["Lista", "Kanban"], horizontal=True, key=f"channel_videos_view_{channel_id}", label_visibility="collapsed")
|
|
955
978
|
videos = st.session_state.get(f"channel_videos_{channel_id}") or channel_videos_for(channel, limit=10)
|
|
956
979
|
if not videos:
|
|
957
980
|
st.info("Ainda não existem vídeos sincronizados. Clique em **Actualizar últimos 10 vídeos**.")
|
|
958
981
|
return
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
render_channel_video_editor(video, channel_id)
|
|
975
|
-
else:
|
|
976
|
-
columns = st.columns(4)
|
|
977
|
-
groups = [("planejamento", "Planejamento"), ("produção", "Produção"), ("finalizado", "Finalizado"), ("publicado", "Agendado/Publicado")]
|
|
978
|
-
for column, (status_key, label) in zip(columns, groups):
|
|
979
|
-
with column:
|
|
980
|
-
st.markdown(f"**{label}**")
|
|
981
|
-
group = [video for video in videos[:10] if str(video.get("status") or "publicado").lower() in ({status_key} if status_key != "publicado" else {"publicado", "agendado"})]
|
|
982
|
-
if not group:
|
|
983
|
-
st.caption("Sem vídeos")
|
|
984
|
-
for video in group:
|
|
985
|
-
with st.container(border=True):
|
|
986
|
-
st.write(f"**{video.get('title', 'Vídeo sem título')}**")
|
|
987
|
-
st.caption(video.get("published_at") or "Sem data")
|
|
988
|
-
render_channel_video_editor(video, channel_id)
|
|
982
|
+
for video in videos[:10]:
|
|
983
|
+
with st.container(border=True):
|
|
984
|
+
cols = st.columns([0.7, 3.4, 1.4, 1.1])
|
|
985
|
+
with cols[0]:
|
|
986
|
+
if video.get("thumbnail_url"):
|
|
987
|
+
st.image(video["thumbnail_url"], width=64)
|
|
988
|
+
else:
|
|
989
|
+
st.markdown("### YT")
|
|
990
|
+
with cols[1]:
|
|
991
|
+
st.write(f"**{video.get('title', 'Vídeo sem título')}**")
|
|
992
|
+
st.caption(f"{video.get('published_at') or 'Sem data'} · {video.get('url') or 'Sem URL'}")
|
|
993
|
+
with cols[2]:
|
|
994
|
+
st.caption(str(video.get("status") or "publicado").title())
|
|
995
|
+
with cols[3]:
|
|
996
|
+
render_channel_video_editor(video, channel_id)
|
|
989
997
|
|
|
990
998
|
|
|
991
999
|
def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], youtube_account_labels: dict[str, str], youtube_accounts_by_id: dict[str, dict[str, Any]]) -> None:
|
|
@@ -1424,6 +1432,101 @@ def render_tiktok_prompt_masters():
|
|
|
1424
1432
|
st.error(str(exc))
|
|
1425
1433
|
|
|
1426
1434
|
|
|
1435
|
+
def _format_channel_metric(value: Any) -> str:
|
|
1436
|
+
"""Format a channel metric compactly for the Kanban card."""
|
|
1437
|
+
if value is None or value == "":
|
|
1438
|
+
return "—"
|
|
1439
|
+
try:
|
|
1440
|
+
number = int(value)
|
|
1441
|
+
except (TypeError, ValueError):
|
|
1442
|
+
return html.escape(str(value))
|
|
1443
|
+
if abs(number) >= 1_000_000:
|
|
1444
|
+
return f"{number / 1_000_000:.1f}M".replace(".0M", "M")
|
|
1445
|
+
if abs(number) >= 1_000:
|
|
1446
|
+
return f"{number / 1_000:.1f}K".replace(".0K", "K")
|
|
1447
|
+
return f"{number:,}".replace(",", ".")
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _channel_pipeline_counts(channel: dict[str, Any]) -> tuple[int, int, int]:
|
|
1451
|
+
"""Return in-production, completed and titled task counts for one channel."""
|
|
1452
|
+
channel_id = str(channel.get("id") or "")
|
|
1453
|
+
tasks = read_json("tasks.json", [])
|
|
1454
|
+
if not channel_id or not isinstance(tasks, list):
|
|
1455
|
+
return 0, 0, 0
|
|
1456
|
+
related = [
|
|
1457
|
+
task for task in tasks
|
|
1458
|
+
if isinstance(task, dict) and str(task.get("channel_id") or "") == channel_id
|
|
1459
|
+
]
|
|
1460
|
+
in_production = sum(1 for task in related if str(task.get("state") or "").lower() == "doing")
|
|
1461
|
+
completed = sum(1 for task in related if str(task.get("state") or "").lower() == "done")
|
|
1462
|
+
titled = sum(1 for task in related if str(task.get("title") or "").strip() or task.get("title_candidates"))
|
|
1463
|
+
return in_production, completed, titled
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _render_channel_kanban_card(channel: dict[str, Any]) -> None:
|
|
1467
|
+
"""Render one compact, square-style channel card for the global Kanban view."""
|
|
1468
|
+
ui_language = current_ui_language()
|
|
1469
|
+
name = html.escape(str(channel.get("name") or "Sem nome"))
|
|
1470
|
+
niche = html.escape(channel_niche_label(channel))
|
|
1471
|
+
thumbnail_url = str(channel.get("thumbnail_url") or "").strip()
|
|
1472
|
+
if thumbnail_url:
|
|
1473
|
+
avatar = f'<img class="tb-channel-kanban-card__avatar" src="{html.escape(thumbnail_url, quote=True)}" alt="">'
|
|
1474
|
+
else:
|
|
1475
|
+
avatar = '<div class="tb-channel-kanban-card__avatar tb-channel-kanban-card__avatar--fallback">YT</div>'
|
|
1476
|
+
active = bool(channel.get("active", True))
|
|
1477
|
+
active_label = ui_text("Activo" if active else "Inactivo", ui_language)
|
|
1478
|
+
active_class = "active" if active else "inactive"
|
|
1479
|
+
subscribers = _format_channel_metric(channel.get("subscriber_count"))
|
|
1480
|
+
views = _format_channel_metric(channel.get("view_count"))
|
|
1481
|
+
videos = _format_channel_metric(channel.get("video_count"))
|
|
1482
|
+
in_production, completed, titled = _channel_pipeline_counts(channel)
|
|
1483
|
+
youtube_label = ui_text("YouTube", ui_language)
|
|
1484
|
+
subscribers_label = ui_text("Inscritos", ui_language)
|
|
1485
|
+
views_label = ui_text("Visualizações", ui_language)
|
|
1486
|
+
videos_label = ui_text("Vídeos", ui_language)
|
|
1487
|
+
production_label = ui_text("Em produção", ui_language)
|
|
1488
|
+
completed_label = ui_text("Finalizados", ui_language)
|
|
1489
|
+
titles_label = ui_text("Títulos", ui_language)
|
|
1490
|
+
st.markdown(
|
|
1491
|
+
f"""
|
|
1492
|
+
<div class="tb-channel-kanban-card">
|
|
1493
|
+
<div class="tb-channel-kanban-card__header">
|
|
1494
|
+
{avatar}
|
|
1495
|
+
<div class="tb-channel-kanban-card__identity">
|
|
1496
|
+
<div class="tb-channel-kanban-card__name">{name}</div>
|
|
1497
|
+
<div class="tb-channel-kanban-card__niche">{niche}</div>
|
|
1498
|
+
</div>
|
|
1499
|
+
<div class="tb-channel-kanban-card__badges">
|
|
1500
|
+
<span class="tb-channel-kanban-card__badge tb-channel-kanban-card__badge--youtube">{youtube_label}</span>
|
|
1501
|
+
<span class="tb-channel-kanban-card__badge tb-channel-kanban-card__badge--{active_class}">{active_label}</span>
|
|
1502
|
+
</div>
|
|
1503
|
+
</div>
|
|
1504
|
+
<div class="tb-channel-kanban-card__rule"></div>
|
|
1505
|
+
<div class="tb-channel-kanban-card__metrics">
|
|
1506
|
+
<div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{subscribers}</div><div class="tb-channel-kanban-card__metric-label">{subscribers_label}</div></div>
|
|
1507
|
+
<div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{views}</div><div class="tb-channel-kanban-card__metric-label">{views_label}</div></div>
|
|
1508
|
+
<div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{videos}</div><div class="tb-channel-kanban-card__metric-label">{videos_label}</div></div>
|
|
1509
|
+
</div>
|
|
1510
|
+
<div class="tb-channel-kanban-card__status-row">
|
|
1511
|
+
<div class="tb-channel-kanban-card__status tb-channel-kanban-card__status--doing"><strong>{in_production}</strong>{production_label}</div>
|
|
1512
|
+
<div class="tb-channel-kanban-card__status tb-channel-kanban-card__status--done"><strong>{completed}</strong>{completed_label}</div>
|
|
1513
|
+
</div>
|
|
1514
|
+
<div class="tb-channel-kanban-card__titles">▣ <strong>{titled}</strong> {titles_label}</div>
|
|
1515
|
+
</div>
|
|
1516
|
+
""",
|
|
1517
|
+
unsafe_allow_html=True,
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def render_registered_channels_kanban(channels: list[dict[str, Any]]) -> None:
|
|
1522
|
+
"""Render all registered channels in a responsive three-column Kanban grid."""
|
|
1523
|
+
for row_start in range(0, len(channels), 3):
|
|
1524
|
+
channel_columns = st.columns(3)
|
|
1525
|
+
for column, channel in zip(channel_columns, channels[row_start:row_start + 3]):
|
|
1526
|
+
with column:
|
|
1527
|
+
_render_channel_kanban_card(channel)
|
|
1528
|
+
|
|
1529
|
+
|
|
1427
1530
|
def render_channels():
|
|
1428
1531
|
st.title("Canais Youtube")
|
|
1429
1532
|
st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
|
|
@@ -1439,7 +1542,11 @@ def render_channels():
|
|
|
1439
1542
|
with import_tab:
|
|
1440
1543
|
st.caption("A pesquisa pública funciona sem API Key. A Data API é opcional e fica disponível numa opção separada para métricas oficiais.")
|
|
1441
1544
|
source = st.text_input("URL, handle ou ID do canal", placeholder="https://youtube.com/@seucanal", key="youtube_channel_source")
|
|
1442
|
-
|
|
1545
|
+
lookup_cols = st.columns(2)
|
|
1546
|
+
with lookup_cols[0]:
|
|
1547
|
+
lookup_mode = st.radio("Método de consulta", ["Página pública — sem API Key", "YouTube Data API — API Key opcional"], horizontal=True, key="youtube_channel_lookup_mode")
|
|
1548
|
+
with lookup_cols[1]:
|
|
1549
|
+
st.radio("Apresentação dos canais", ["Lista", "Kanban"], horizontal=True, key="youtube_channels_view_mode")
|
|
1443
1550
|
col1, col2 = st.columns([1, 1])
|
|
1444
1551
|
with col1:
|
|
1445
1552
|
if st.button("Buscar no YouTube", type="primary", use_container_width=True, key="youtube_channel_lookup"):
|
|
@@ -1675,6 +1782,9 @@ def render_channels():
|
|
|
1675
1782
|
if not channels:
|
|
1676
1783
|
st.info("Nenhum canal cadastrado.")
|
|
1677
1784
|
return
|
|
1785
|
+
if st.session_state.get("youtube_channels_view_mode", "Lista") == "Kanban":
|
|
1786
|
+
render_registered_channels_kanban(channels)
|
|
1787
|
+
return
|
|
1678
1788
|
for channel in channels:
|
|
1679
1789
|
channel_id = str(channel["id"])
|
|
1680
1790
|
edit_key = f"edit_channel_{channel_id}"
|
|
@@ -2152,7 +2262,16 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
|
|
|
2152
2262
|
thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
|
|
2153
2263
|
if st.button("Gerar imagem com Nano Banana", key=f"{prefix}_general_generate_thumbnail_{channel['id']}", use_container_width=True):
|
|
2154
2264
|
try:
|
|
2155
|
-
thumbnail_path = str(
|
|
2265
|
+
thumbnail_path = str(
|
|
2266
|
+
generate_thumbnail_image(
|
|
2267
|
+
read_json("settings.json", {}),
|
|
2268
|
+
variant.get("image_prompt", ""),
|
|
2269
|
+
topic=str(payload.get("topic") or ""),
|
|
2270
|
+
variant_index=variant_index,
|
|
2271
|
+
lettering_text=str(variant.get("overlay_text") or payload.get("thumbnail_text") or ""),
|
|
2272
|
+
lettering_prompt=str(variant.get("lettering_prompt") or ""),
|
|
2273
|
+
)
|
|
2274
|
+
)
|
|
2156
2275
|
variant["image_path"] = thumbnail_path
|
|
2157
2276
|
payload["thumbnail_path"] = thumbnail_path
|
|
2158
2277
|
payload["thumbnail_status"] = "generated"
|
|
@@ -2216,7 +2335,16 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
|
|
|
2216
2335
|
thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
|
|
2217
2336
|
if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", use_container_width=True):
|
|
2218
2337
|
try:
|
|
2219
|
-
thumbnail_path = str(
|
|
2338
|
+
thumbnail_path = str(
|
|
2339
|
+
generate_thumbnail_image(
|
|
2340
|
+
read_json("settings.json", {}),
|
|
2341
|
+
variant.get("image_prompt", ""),
|
|
2342
|
+
topic=str(payload.get("topic") or ""),
|
|
2343
|
+
variant_index=variant_index,
|
|
2344
|
+
lettering_text=str(variant.get("overlay_text") or payload.get("thumbnail_text") or ""),
|
|
2345
|
+
lettering_prompt=str(variant.get("lettering_prompt") or ""),
|
|
2346
|
+
)
|
|
2347
|
+
)
|
|
2220
2348
|
variant["image_path"] = thumbnail_path
|
|
2221
2349
|
payload["thumbnail_path"] = thumbnail_path
|
|
2222
2350
|
payload["thumbnail_status"] = "generated"
|
|
@@ -266,7 +266,7 @@ def generate_thumbnail_prompt(
|
|
|
266
266
|
"És um director de arte de thumbnails para YouTube. Refaz um prompt de imagem forte e específico "
|
|
267
267
|
"para o tópico fornecido, mantendo a intenção visual quando já existir um prompt. "
|
|
268
268
|
"Separa rigorosamente a imagem sem texto do lettering: image_prompt nunca deve pedir texto renderizado, "
|
|
269
|
-
"enquanto overlay_text deve ser curto, legível e ter
|
|
269
|
+
"enquanto overlay_text é obrigatório, deve ser curto, legível e ter entre três e quatro palavras. "
|
|
270
270
|
"Responde apenas com JSON válido nas chaves concept, overlay_text, composition, color_palette, subject, "
|
|
271
271
|
"image_prompt, title_synergy e lettering_prompt."
|
|
272
272
|
)
|
|
@@ -279,8 +279,8 @@ def generate_thumbnail_prompt(
|
|
|
279
279
|
"reference_rules": reference_bundle(),
|
|
280
280
|
"requirements": {
|
|
281
281
|
"image_prompt": "cinematic visual direction, no words, letters, logos or watermarks rendered in the image",
|
|
282
|
-
"overlay_text": "
|
|
283
|
-
"lettering_prompt": "instructions for
|
|
282
|
+
"overlay_text": "required, 3 to 4 words, emotionally strong, not the full title",
|
|
283
|
+
"lettering_prompt": "required instructions for rendering the exact overlay_text after the base image exists",
|
|
284
284
|
},
|
|
285
285
|
},
|
|
286
286
|
ensure_ascii=False,
|
|
@@ -289,7 +289,11 @@ def generate_thumbnail_prompt(
|
|
|
289
289
|
image_prompt = str(result.get("image_prompt") or "").strip()
|
|
290
290
|
if not image_prompt:
|
|
291
291
|
raise CreativeGenerationError("O provider não devolveu um prompt de imagem válido.")
|
|
292
|
-
overlay_text = _short_overlay(result.get("overlay_text"))
|
|
292
|
+
overlay_text = _short_overlay(result.get("overlay_text")) or _short_overlay(topic) or "WATCH NOW"
|
|
293
|
+
lettering_prompt = str(result.get("lettering_prompt") or "").strip() or (
|
|
294
|
+
"Render the exact overlay_text as bold, readable, high-contrast sans-serif lettering with an outline or shadow, "
|
|
295
|
+
"inside a safe zone and away from the main face or subject."
|
|
296
|
+
)
|
|
293
297
|
return {
|
|
294
298
|
"concept": str(result.get("concept") or "Thumbnail renovada").strip(),
|
|
295
299
|
"overlay_text": overlay_text,
|
|
@@ -298,7 +302,7 @@ def generate_thumbnail_prompt(
|
|
|
298
302
|
"subject": str(result.get("subject") or topic).strip(),
|
|
299
303
|
"image_prompt": image_prompt,
|
|
300
304
|
"title_synergy": str(result.get("title_synergy") or "").strip(),
|
|
301
|
-
"lettering_prompt":
|
|
305
|
+
"lettering_prompt": lettering_prompt,
|
|
302
306
|
"status": "prompt_ready",
|
|
303
307
|
}
|
|
304
308
|
|
|
@@ -317,7 +321,7 @@ def generate_creative_package(
|
|
|
317
321
|
"És director editorial e de thumbnails para YouTube. Cria um pacote coerente de título e thumbnail "
|
|
318
322
|
"para o tópico fornecido. Gera exactamente pelo menos 20 títulos candidatos e entre 3 e 5 variantes de thumbnail. "
|
|
319
323
|
"O título deve carregar keywords no início, ter curiosidade, especificidade e emoção, sem clickbait falso. "
|
|
320
|
-
"A thumbnail deve ter no máximo três elementos, alto contraste, uma composição clara
|
|
324
|
+
"A thumbnail deve ter no máximo três elementos, alto contraste, uma composição clara e lettering obrigatório de 3 a 4 palavras, "
|
|
321
325
|
"safe zones e leitura em 120px. O texto da thumbnail não pode repetir o título integralmente. Remove AI tells. "
|
|
322
326
|
"Responde apenas com JSON válido nas chaves selected_title, title_candidates, keywords e thumbnail_variants."
|
|
323
327
|
)
|
|
@@ -337,12 +341,13 @@ def generate_creative_package(
|
|
|
337
341
|
},
|
|
338
342
|
"thumbnail_variant_schema": {
|
|
339
343
|
"concept": "string",
|
|
340
|
-
"overlay_text": "string,
|
|
344
|
+
"overlay_text": "required string, 3 to 4 words",
|
|
341
345
|
"composition": "string",
|
|
342
346
|
"color_palette": "string",
|
|
343
347
|
"subject": "string",
|
|
344
|
-
"image_prompt": "string,
|
|
348
|
+
"image_prompt": "string describing the clean image base, without rendered text; lettering is specified separately",
|
|
345
349
|
"title_synergy": "string",
|
|
350
|
+
"lettering_prompt": "string describing how to render the exact overlay_text",
|
|
346
351
|
},
|
|
347
352
|
},
|
|
348
353
|
ensure_ascii=False,
|
|
@@ -384,15 +389,21 @@ def generate_creative_package(
|
|
|
384
389
|
continue
|
|
385
390
|
if not str(item.get("concept") or "").strip() or not str(item.get("image_prompt") or "").strip():
|
|
386
391
|
continue
|
|
392
|
+
overlay_text = _short_overlay(item.get("overlay_text")) or _short_overlay(topic) or "WATCH NOW"
|
|
393
|
+
lettering_prompt = str(item.get("lettering_prompt") or "").strip() or (
|
|
394
|
+
"Render the exact overlay_text as bold, readable, high-contrast sans-serif lettering with an outline or shadow, "
|
|
395
|
+
"inside a safe zone and away from the main face or subject."
|
|
396
|
+
)
|
|
387
397
|
variants.append(
|
|
388
398
|
{
|
|
389
399
|
"concept": str(item.get("concept") or "").strip(),
|
|
390
|
-
"overlay_text":
|
|
400
|
+
"overlay_text": overlay_text,
|
|
391
401
|
"composition": str(item.get("composition") or "").strip(),
|
|
392
402
|
"color_palette": str(item.get("color_palette") or "").strip(),
|
|
393
403
|
"subject": str(item.get("subject") or "").strip(),
|
|
394
404
|
"image_prompt": str(item.get("image_prompt") or "").strip(),
|
|
395
405
|
"title_synergy": str(item.get("title_synergy") or "").strip(),
|
|
406
|
+
"lettering_prompt": lettering_prompt,
|
|
396
407
|
"status": "prompt_ready",
|
|
397
408
|
}
|
|
398
409
|
)
|
package/hermes_ui/languages.py
CHANGED
|
@@ -756,6 +756,18 @@ _CONTENT_TRANSLATION_ROWS = (
|
|
|
756
756
|
("Conteúdo Markdown", "Conteúdo Markdown", "Markdown content", "Markdown 内容", "Markdown-Inhalt", "Nội dung Markdown", "Markdown içeriği", "Содержимое Markdown", "Contenido Markdown", "Konten Markdown", "Contenuto Markdown"),
|
|
757
757
|
("Canais Youtube", "Canais Youtube", "YouTube Channels", "YouTube 频道", "YouTube-Kanäle", "Kênh YouTube", "YouTube Kanalları", "Каналы YouTube", "Canales de YouTube", "Kanal YouTube", "Canali YouTube"),
|
|
758
758
|
("Método de consulta", "Método de consulta", "Lookup method", "查询方式", "Abfragemethode", "Phương thức tra cứu", "Arama yöntemi", "Метод запроса", "Método de consulta", "Metode pencarian", "Metodo di ricerca"),
|
|
759
|
+
("Apresentação dos canais", "Apresentação dos canais", "Channel presentation", "频道展示", "Kanaldarstellung", "Hiển thị kênh", "Kanal görünümü", "Представление каналов", "Presentación de canales", "Tampilan kanal", "Presentazione dei canali"),
|
|
760
|
+
("Lista", "Lista", "List", "列表", "Liste", "Danh sách", "Liste", "Список", "Lista", "Daftar", "Elenco"),
|
|
761
|
+
("Kanban", "Kanban", "Kanban", "看板", "Kanban", "Kanban", "Kanban", "Канбан", "Kanban", "Kanban", "Kanban"),
|
|
762
|
+
("YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube"),
|
|
763
|
+
("Inscritos", "Inscritos", "Subscribers", "订阅者", "Abonnenten", "Người đăng ký", "Aboneler", "Подписчики", "Suscriptores", "Subscriber", "Iscritti"),
|
|
764
|
+
("Visualizações", "Visualizações", "Views", "观看次数", "Aufrufe", "Lượt xem", "Görüntülemeler", "Просмотры", "Visualizaciones", "Tayangan", "Visualizzazioni"),
|
|
765
|
+
("Vídeos", "Vídeos", "Videos", "视频", "Videos", "Video", "Videolar", "Видео", "Vídeos", "Video", "Video"),
|
|
766
|
+
("Em produção", "Em produção", "In production", "制作中", "In Produktion", "Đang sản xuất", "Üretimde", "В производстве", "En producción", "Sedang diproduksi", "In produzione"),
|
|
767
|
+
("Finalizados", "Finalizados", "Completed", "已完成", "Abgeschlossen", "Đã hoàn tất", "Tamamlandı", "Завершено", "Finalizados", "Selesai", "Completati"),
|
|
768
|
+
("Títulos", "Títulos", "Titles", "标题", "Titel", "Tiêu đề", "Başlık", "Заголовки", "Títulos", "Judul", "Titoli"),
|
|
769
|
+
("Activo", "Activo", "Active", "活跃", "Aktiv", "Đang hoạt động", "Aktif", "Активен", "Activo", "Aktif", "Attivo"),
|
|
770
|
+
("Inactivo", "Inactivo", "Inactive", "不活跃", "Inaktiv", "Không hoạt động", "Pasif", "Неактивен", "Inactivo", "Tidak aktif", "Inattivo"),
|
|
759
771
|
("Página pública — sem API Key", "Página pública — sem API Key", "Public page — no API Key", "公开页面 — 无 API 密钥", "Öffentliche Seite — kein API-Schlüssel", "Trang công khai — không cần API Key", "Herkese açık sayfa — API anahtarı yok", "Публичная страница — без API-ключа", "Página pública — sin clave API", "Halaman publik — tanpa Kunci API", "Pagina pubblica — senza API Key"),
|
|
760
772
|
("YouTube Data API — API Key opcional", "YouTube Data API — API Key opcional", "YouTube Data API — optional API Key", "YouTube Data API — 可选 API 密钥", "YouTube Data API — optionaler API-Schlüssel", "YouTube Data API — API Key tùy chọn", "YouTube Data API — isteğe bağlı API anahtarı", "YouTube Data API — необязательный API-ключ", "YouTube Data API — clave API opcional", "YouTube Data API — Kunci API opsional", "YouTube Data API — API Key opzionale"),
|
|
761
773
|
("Buscar no YouTube", "Buscar no YouTube", "Search YouTube", "搜索 YouTube", "YouTube durchsuchen", "Tìm trên YouTube", "YouTube'da ara", "Поиск на YouTube", "Buscar en YouTube", "Cari di YouTube", "Cerca su YouTube"),
|
|
@@ -223,12 +223,33 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
223
223
|
|
|
224
224
|
_update(task_id, stage="keywords", state="doing", progress=48)
|
|
225
225
|
variant = creative.get("thumbnail_variant") if isinstance(creative.get("thumbnail_variant"), dict) else {}
|
|
226
|
-
prompt_payload = {
|
|
226
|
+
prompt_payload = {
|
|
227
|
+
"topic": topic,
|
|
228
|
+
"title": title,
|
|
229
|
+
"keywords": keywords,
|
|
230
|
+
"thumbnail": variant,
|
|
231
|
+
"requirements": {
|
|
232
|
+
"aspect_ratio": "16:9",
|
|
233
|
+
"resolution": "1920x1080",
|
|
234
|
+
"max_elements": 3,
|
|
235
|
+
"max_overlay_words": 4,
|
|
236
|
+
"lettering_required": True,
|
|
237
|
+
"lettering_text": str(variant.get("overlay_text") or ""),
|
|
238
|
+
"lettering_prompt": str(variant.get("lettering_prompt") or ""),
|
|
239
|
+
},
|
|
240
|
+
}
|
|
227
241
|
prompt_artifact = _save_json_artifact(task_id, "thumbnail-prompt", prompt_payload)
|
|
228
242
|
artifacts = {**artifacts, "thumbnail_prompt_json": prompt_artifact}
|
|
229
243
|
_update(task_id, stage="thumbnail_prompt", state="doing", progress=52, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
|
|
230
244
|
_update(task_id, stage="thumbnail", state="doing", progress=56, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
|
|
231
|
-
thumbnail_path = generate_thumbnail_image(
|
|
245
|
+
thumbnail_path = generate_thumbnail_image(
|
|
246
|
+
settings,
|
|
247
|
+
str(variant.get("image_prompt") or ""),
|
|
248
|
+
topic=topic,
|
|
249
|
+
variant_index=0,
|
|
250
|
+
lettering_text=str(variant.get("overlay_text") or ""),
|
|
251
|
+
lettering_prompt=str(variant.get("lettering_prompt") or ""),
|
|
252
|
+
)
|
|
232
253
|
artifacts["thumbnail"] = str(thumbnail_path)
|
|
233
254
|
_update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=62)
|
|
234
255
|
|
|
@@ -4,6 +4,7 @@ import hashlib
|
|
|
4
4
|
import mimetypes
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
import tempfile
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
@@ -30,6 +31,51 @@ def _clean_detail(value: Any, api_key: str) -> str:
|
|
|
30
31
|
return detail.replace(api_key, "[REDACTED]") if api_key else detail
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
def _fallback_lettering(topic: str) -> str:
|
|
35
|
+
"""Return a short headline when an older task has no overlay_text."""
|
|
36
|
+
words = re.findall(r"[\wÀ-ÿ$%'-]+", str(topic or ""), flags=re.UNICODE)
|
|
37
|
+
headline = " ".join(words[:4]).strip()
|
|
38
|
+
return headline.upper() if headline else "WATCH NOW"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _normalise_lettering_text(value: Any, topic: str) -> str:
|
|
42
|
+
"""Keep the required thumbnail headline concise and never empty."""
|
|
43
|
+
words = re.findall(r"[\wÀ-ÿ$%'-]+", str(value or ""), flags=re.UNICODE)
|
|
44
|
+
if not words:
|
|
45
|
+
return _fallback_lettering(topic)
|
|
46
|
+
return " ".join(words[:4]).strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _compose_thumbnail_prompt(
|
|
50
|
+
base_prompt: str,
|
|
51
|
+
*,
|
|
52
|
+
topic: str = "",
|
|
53
|
+
lettering_text: str = "",
|
|
54
|
+
lettering_prompt: str = "",
|
|
55
|
+
) -> tuple[str, str]:
|
|
56
|
+
"""Combine the visual base and a mandatory, model-readable lettering layer."""
|
|
57
|
+
headline = _normalise_lettering_text(lettering_text, topic)
|
|
58
|
+
lettering_guidance = str(lettering_prompt or "").strip() or (
|
|
59
|
+
"Use a bold sans-serif headline with high contrast, a thick outline or shadow, "
|
|
60
|
+
"safe margins and placement that does not cover the face or main subject."
|
|
61
|
+
)
|
|
62
|
+
effective_prompt = (
|
|
63
|
+
"IMAGE BASE LAYER — create the requested cinematic YouTube thumbnail composition, subject, "
|
|
64
|
+
"lighting, colour palette and visual hierarchy. Keep the base image clean and uncluttered.\n"
|
|
65
|
+
f"{str(base_prompt or '').strip()}\n\n"
|
|
66
|
+
"MANDATORY LETTERING LAYER — the final image MUST visibly contain readable lettering. "
|
|
67
|
+
"If the image-base description says no text, no words or no lettering, that restriction is overridden "
|
|
68
|
+
"by this layer. Render the exact headline between the delimiters below; do not omit it, paraphrase it, "
|
|
69
|
+
"translate it, replace it with placeholder text or hide it.\n"
|
|
70
|
+
f"EXACT HEADLINE TO RENDER: <<<{headline}>>>\n"
|
|
71
|
+
f"LETTERING DESIGN: {lettering_guidance}\n"
|
|
72
|
+
"Use no more than three or four words, bold sans-serif typography, strong contrast, outline/shadow, "
|
|
73
|
+
"a safe-zone margin and a position that does not cover the face or main object. Do not add unrelated text, "
|
|
74
|
+
"logos or watermarks. The thumbnail must not be delivered without the exact headline visible."
|
|
75
|
+
)
|
|
76
|
+
return effective_prompt, headline
|
|
77
|
+
|
|
78
|
+
|
|
33
79
|
def _decode_image_data(value: Any) -> bytes | None:
|
|
34
80
|
if not isinstance(value, str) or not value.strip():
|
|
35
81
|
return None
|
|
@@ -105,6 +151,8 @@ def generate_thumbnail_image(
|
|
|
105
151
|
topic: str = "",
|
|
106
152
|
variant_index: int = 0,
|
|
107
153
|
reference_image: str | Path | None = None,
|
|
154
|
+
lettering_text: str = "",
|
|
155
|
+
lettering_prompt: str = "",
|
|
108
156
|
) -> Path:
|
|
109
157
|
api_key = str(settings.get("gemini_image_api_key") or "").strip()
|
|
110
158
|
if not api_key:
|
|
@@ -112,14 +160,20 @@ def generate_thumbnail_image(
|
|
|
112
160
|
clean_prompt = str(prompt or "").strip()
|
|
113
161
|
if not clean_prompt:
|
|
114
162
|
raise ThumbnailGenerationError("A thumbnail não tem um prompt de imagem para gerar.")
|
|
163
|
+
effective_prompt, headline = _compose_thumbnail_prompt(
|
|
164
|
+
clean_prompt,
|
|
165
|
+
topic=topic,
|
|
166
|
+
lettering_text=lettering_text,
|
|
167
|
+
lettering_prompt=lettering_prompt,
|
|
168
|
+
)
|
|
115
169
|
|
|
116
170
|
model = str(settings.get("gemini_image_model") or DEFAULT_GEMINI_IMAGE_MODEL).strip()
|
|
117
171
|
aspect_ratio = str(settings.get("gemini_image_aspect_ratio") or DEFAULT_ASPECT_RATIO).strip()
|
|
118
172
|
image_size = str(settings.get("gemini_image_size") or DEFAULT_IMAGE_SIZE).strip()
|
|
119
|
-
request_input: str | list[dict[str, str]] =
|
|
173
|
+
request_input: str | list[dict[str, str]] = effective_prompt
|
|
120
174
|
if reference_image:
|
|
121
175
|
request_input = [
|
|
122
|
-
{"type": "text", "text":
|
|
176
|
+
{"type": "text", "text": effective_prompt},
|
|
123
177
|
_image_input(Path(reference_image)),
|
|
124
178
|
]
|
|
125
179
|
body = {
|
|
@@ -161,7 +215,12 @@ def generate_thumbnail_image(
|
|
|
161
215
|
ensure_storage()
|
|
162
216
|
output_dir = STORAGE / "thumbnails"
|
|
163
217
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
164
|
-
destination = output_dir / _thumbnail_filename(
|
|
218
|
+
destination = output_dir / _thumbnail_filename(
|
|
219
|
+
f"{effective_prompt}\nEXACT HEADLINE TO RENDER: {headline}",
|
|
220
|
+
topic,
|
|
221
|
+
variant_index,
|
|
222
|
+
model,
|
|
223
|
+
)
|
|
165
224
|
fd, temp_name = tempfile.mkstemp(prefix=f".{destination.name}.", dir=output_dir)
|
|
166
225
|
try:
|
|
167
226
|
with os.fdopen(fd, "wb") as handle:
|
package/hermes_ui/thumbnails.py
CHANGED
|
@@ -205,6 +205,8 @@ def generate_thumbnail_for_task(task_id: str, settings: dict[str, Any]) -> tuple
|
|
|
205
205
|
record["prompt"],
|
|
206
206
|
topic=record["title"] or record["topic"],
|
|
207
207
|
variant_index=record["variant_index"],
|
|
208
|
+
lettering_text=record.get("thumbnail_text") or "",
|
|
209
|
+
lettering_prompt=record.get("lettering_prompt") or "",
|
|
208
210
|
)
|
|
209
211
|
_persist_thumbnail_result(tasks, task, record, image_path)
|
|
210
212
|
write_json("tasks.json", tasks)
|
|
@@ -258,6 +260,8 @@ def regenerate_thumbnail_prompt_and_image(
|
|
|
258
260
|
prompt,
|
|
259
261
|
topic=record["title"] or record["topic"],
|
|
260
262
|
variant_index=record["variant_index"],
|
|
263
|
+
lettering_text=str((variant or {}).get("overlay_text") or ""),
|
|
264
|
+
lettering_prompt=str((variant or {}).get("lettering_prompt") or ""),
|
|
261
265
|
)
|
|
262
266
|
_persist_thumbnail_result(tasks, task, record, image_path, variant=variant, source="prompt_regenerated")
|
|
263
267
|
write_json("tasks.json", tasks)
|
|
@@ -298,6 +302,7 @@ def regenerate_thumbnail_lettering(
|
|
|
298
302
|
topic=record["title"] or record["topic"],
|
|
299
303
|
variant_index=record["variant_index"],
|
|
300
304
|
reference_image=previous_image,
|
|
305
|
+
lettering_prompt=edit_prompt,
|
|
301
306
|
)
|
|
302
307
|
variant = _variant_for_record(record)
|
|
303
308
|
variant["image_prompt"] = base_prompt
|
package/package.json
CHANGED