@danhachuel/thunderbolt 0.2.49 → 0.2.51
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/MANUAL-INSTALACAO.md +36 -18
- package/README.md +24 -6
- package/app/main.py +572 -110
- package/hermes_ui/automation_worker.py +49 -10
- package/hermes_ui/creative_generation.py +278 -0
- package/hermes_ui/domain.py +64 -21
- package/hermes_ui/storage.py +3 -2
- package/integrations/platforms.py +77 -0
- package/package.json +2 -1
- package/seed/references/ai-tells.md +336 -0
- package/seed/references/humanize-integration.md +147 -0
- package/seed/references/thumbnail-checklist.md +178 -0
- package/seed/references/title-formulas.md +288 -0
- package/seed/references/trend-intelligence.md +99 -0
- package/seed/references/viral-thumbnails.md +470 -0
- package/seed/references/viral-titles.md +440 -0
package/app/main.py
CHANGED
|
@@ -20,7 +20,7 @@ try:
|
|
|
20
20
|
except (OSError, json.JSONDecodeError):
|
|
21
21
|
APP_VERSION = ""
|
|
22
22
|
|
|
23
|
-
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel
|
|
23
|
+
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
|
|
24
24
|
from hermes_ui.automation_worker import load_worker_status
|
|
25
25
|
from hermes_ui.storage import BLUEPRINTS, STORAGE, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
|
|
26
26
|
from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
|
|
@@ -34,7 +34,8 @@ from hermes_ui.mcp import detect_local_service, install_skill_locally, load_inte
|
|
|
34
34
|
from hermes_ui.mcp_server import server_status, start_server, stop_server
|
|
35
35
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
36
36
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
37
|
-
from
|
|
37
|
+
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
|
|
38
|
+
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
|
|
38
39
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
39
40
|
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
40
41
|
from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels, loopback_redirect_uri
|
|
@@ -191,6 +192,82 @@ def blueprint_catalog() -> list[tuple[str, str]]:
|
|
|
191
192
|
return options
|
|
192
193
|
|
|
193
194
|
|
|
195
|
+
def blueprint_for_channel(channel: dict) -> dict[str, Any]:
|
|
196
|
+
"""Resolve a channel Blueprint by id, filename stem or display name."""
|
|
197
|
+
blueprint_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
|
|
198
|
+
if not blueprint_id:
|
|
199
|
+
return {}
|
|
200
|
+
for path in list_blueprint_files():
|
|
201
|
+
try:
|
|
202
|
+
data = load_blueprint_file(path)
|
|
203
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
204
|
+
continue
|
|
205
|
+
identifiers = {str(data.get("id") or ""), path.stem, str(data.get("name") or "")}
|
|
206
|
+
if blueprint_id in identifiers:
|
|
207
|
+
resolved = dict(data)
|
|
208
|
+
resolved.setdefault("id", blueprint_id)
|
|
209
|
+
resolved.setdefault("name", str(data.get("name") or path.stem))
|
|
210
|
+
return resolved
|
|
211
|
+
return {"id": blueprint_id, "name": blueprint_id}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def channel_blueprint_summary(channel: dict) -> dict[str, str]:
|
|
215
|
+
blueprint = blueprint_for_channel(channel)
|
|
216
|
+
configured_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
|
|
217
|
+
if not configured_id:
|
|
218
|
+
return {"id": "", "name": "SEM BLUEPRINT CONFIGURADO", "voice": str(channel.get("default_voice") or channel.get("voice") or "")}
|
|
219
|
+
return {
|
|
220
|
+
"id": configured_id,
|
|
221
|
+
"name": str(blueprint.get("name") or configured_id),
|
|
222
|
+
"voice": str(channel.get("default_voice") or channel.get("voice") or ""),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def render_channel_blueprint_panel(channel: dict, *, compact: bool = False) -> None:
|
|
227
|
+
summary = channel_blueprint_summary(channel)
|
|
228
|
+
voice = summary["voice"] or "Sem voz padrão"
|
|
229
|
+
if summary["name"] == "SEM BLUEPRINT CONFIGURADO":
|
|
230
|
+
st.warning("**SEM BLUEPRINT CONFIGURADO** · configure um Blueprint padrão na aba Canais.")
|
|
231
|
+
elif compact:
|
|
232
|
+
st.caption(f"**Blueprint:** {summary['name']} · **Voz:** {voice}")
|
|
233
|
+
else:
|
|
234
|
+
st.info(f"**Blueprint utilizado pelo canal:** {summary['name']} · `{summary['id']}` · **Voz:** {voice}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def creative_payload_from_result(channel: dict, topic: str, creative: dict, topic_source: str = "manual") -> dict[str, Any]:
|
|
238
|
+
variant = creative.get("thumbnail_variant") or {}
|
|
239
|
+
return {
|
|
240
|
+
"topic": topic.strip(),
|
|
241
|
+
"topic_source": topic_source,
|
|
242
|
+
"title": str(creative.get("title") or topic).strip(),
|
|
243
|
+
"title_candidates": creative.get("title_candidates") or [],
|
|
244
|
+
"thumbnail_variant": variant,
|
|
245
|
+
"thumbnail_variants": creative.get("thumbnail_variants") or [],
|
|
246
|
+
"thumbnail_prompt": str(variant.get("image_prompt") or ""),
|
|
247
|
+
"thumbnail_text": str(variant.get("overlay_text") or ""),
|
|
248
|
+
"thumbnail_status": str(creative.get("thumbnail_status") or "prompt_ready"),
|
|
249
|
+
"blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
|
|
250
|
+
"blueprint_name": str(channel_blueprint_summary(channel).get("name") or "SEM BLUEPRINT CONFIGURADO"),
|
|
251
|
+
"voice": str(channel.get("default_voice") or channel.get("voice") or ""),
|
|
252
|
+
"ai_generation": {"creative": creative},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def generate_topic_for_ui(settings: dict[str, Any], channel: dict, user_context: str = "") -> dict[str, Any]:
|
|
257
|
+
return generate_topic_for_channel(settings, channel, blueprint_for_channel(channel), user_context=user_context)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def generate_creative_for_ui(settings: dict[str, Any], channel: dict, topic: str, topic_source: str = "manual") -> dict[str, Any]:
|
|
261
|
+
creative = generate_creative_package(
|
|
262
|
+
settings,
|
|
263
|
+
channel,
|
|
264
|
+
topic,
|
|
265
|
+
blueprint_for_channel(channel),
|
|
266
|
+
language=str(channel.get("language") or "Português"),
|
|
267
|
+
)
|
|
268
|
+
return creative_payload_from_result(channel, topic, creative, topic_source=topic_source)
|
|
269
|
+
|
|
270
|
+
|
|
194
271
|
def valid_hhmm(value: str) -> bool:
|
|
195
272
|
return bool(re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", str(value or "").strip()))
|
|
196
273
|
|
|
@@ -223,6 +300,172 @@ def channel_default_options(channel: dict) -> tuple[list[str], dict[str, str], s
|
|
|
223
300
|
return blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice
|
|
224
301
|
|
|
225
302
|
|
|
303
|
+
def channel_niche_label(channel: dict) -> str:
|
|
304
|
+
"""Return the channel niche/reference label shown under the channel name."""
|
|
305
|
+
niche = str(channel.get("niche") or "").strip()
|
|
306
|
+
if niche:
|
|
307
|
+
return niche
|
|
308
|
+
references = channel.get("reference_channels")
|
|
309
|
+
if isinstance(references, list):
|
|
310
|
+
values = [str(item).strip() for item in references if str(item).strip()]
|
|
311
|
+
if values:
|
|
312
|
+
return ", ".join(values)
|
|
313
|
+
blueprint = blueprint_for_channel(channel)
|
|
314
|
+
metadata = blueprint.get("metadata") if isinstance(blueprint.get("metadata"), dict) else {}
|
|
315
|
+
return str(blueprint.get("target_niche") or blueprint.get("niche") or metadata.get("target_niche") or metadata.get("niche") or "SEM NICHO CONFIGURADO").strip()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _merge_channel_videos(channel_id: str, videos: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
319
|
+
existing = read_json("channel_videos.json", [])
|
|
320
|
+
if not isinstance(existing, list):
|
|
321
|
+
existing = []
|
|
322
|
+
other_channels = [item for item in existing if isinstance(item, dict) and str(item.get("channel_id")) != str(channel_id)]
|
|
323
|
+
current_by_id = {str(item.get("id")): item for item in existing if isinstance(item, dict) and str(item.get("channel_id")) == str(channel_id)}
|
|
324
|
+
merged: list[dict[str, Any]] = []
|
|
325
|
+
for video in videos:
|
|
326
|
+
item = dict(current_by_id.get(str(video.get("id")), {}))
|
|
327
|
+
item.update(video)
|
|
328
|
+
item["channel_id"] = str(channel_id)
|
|
329
|
+
item.setdefault("status", "publicado")
|
|
330
|
+
merged.append(item)
|
|
331
|
+
write_json("channel_videos.json", other_channels + merged)
|
|
332
|
+
return merged
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def channel_videos_for(channel: dict, limit: int = 10) -> list[dict[str, Any]]:
|
|
336
|
+
channel_id = str(channel.get("id") or "")
|
|
337
|
+
stored = read_json("channel_videos.json", [])
|
|
338
|
+
videos = [item for item in stored if isinstance(item, dict) and str(item.get("channel_id")) == channel_id]
|
|
339
|
+
videos.sort(key=lambda item: str(item.get("published_at") or item.get("created_at") or ""), reverse=True)
|
|
340
|
+
return videos[:limit]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def render_channel_video_editor(video: dict, channel_id: str) -> None:
|
|
344
|
+
video_id = str(video.get("id") or "")
|
|
345
|
+
edit_key = f"channel_video_edit_{video_id}"
|
|
346
|
+
if st.button("Editar vídeo", key=f"edit_channel_video_{video_id}", use_container_width=True):
|
|
347
|
+
st.session_state[edit_key] = True
|
|
348
|
+
st.rerun()
|
|
349
|
+
if st.session_state.get(edit_key):
|
|
350
|
+
with st.form(f"channel_video_form_{video_id}"):
|
|
351
|
+
edited_title = st.text_input("Título", value=str(video.get("title") or ""))
|
|
352
|
+
edited_status = st.selectbox("Estado", ["planejamento", "produção", "finalizado", "agendado", "publicado"], index=["planejamento", "produção", "finalizado", "agendado", "publicado"].index(str(video.get("status") or "publicado")) if str(video.get("status") or "publicado") in {"planejamento", "produção", "finalizado", "agendado", "publicado"} else 4)
|
|
353
|
+
edited_date = st.text_input("Data de publicação", value=str(video.get("published_at") or ""))
|
|
354
|
+
edited_url = st.text_input("URL", value=str(video.get("url") or ""))
|
|
355
|
+
edited_notes = st.text_area("Notas", value=str(video.get("notes") or ""), height=80)
|
|
356
|
+
save_video = st.form_submit_button("Guardar alteração", type="primary")
|
|
357
|
+
if save_video:
|
|
358
|
+
update_channel_video(video_id, {"title": edited_title.strip(), "status": edited_status, "published_at": edited_date.strip(), "url": edited_url.strip(), "notes": edited_notes.strip()})
|
|
359
|
+
st.session_state.pop(edit_key, None)
|
|
360
|
+
st.success("Vídeo actualizado.")
|
|
361
|
+
st.rerun()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def render_channel_videos(channel: dict) -> None:
|
|
365
|
+
channel_id = str(channel.get("id") or "")
|
|
366
|
+
st.markdown("### Últimos 10 vídeos publicados")
|
|
367
|
+
st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
|
|
368
|
+
refresh_col, view_col = st.columns([1.4, 1])
|
|
369
|
+
with refresh_col:
|
|
370
|
+
if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
|
|
371
|
+
result = fetch_channel_videos_public(channel, limit=10)
|
|
372
|
+
if result.ok:
|
|
373
|
+
videos = _merge_channel_videos(channel_id, result.data.get("videos", []))
|
|
374
|
+
st.session_state[f"channel_videos_{channel_id}"] = videos
|
|
375
|
+
st.success(result.message)
|
|
376
|
+
st.rerun()
|
|
377
|
+
else:
|
|
378
|
+
st.warning(result.message)
|
|
379
|
+
with view_col:
|
|
380
|
+
view_mode = st.radio("Vista", ["Lista", "Kanban"], horizontal=True, key=f"channel_videos_view_{channel_id}", label_visibility="collapsed")
|
|
381
|
+
videos = st.session_state.get(f"channel_videos_{channel_id}") or channel_videos_for(channel, limit=10)
|
|
382
|
+
if not videos:
|
|
383
|
+
st.info("Ainda não existem vídeos sincronizados. Clique em **Actualizar últimos 10 vídeos**.")
|
|
384
|
+
return
|
|
385
|
+
if view_mode == "Lista":
|
|
386
|
+
for video in videos[:10]:
|
|
387
|
+
with st.container(border=True):
|
|
388
|
+
cols = st.columns([0.7, 3.4, 1.4, 1.1])
|
|
389
|
+
with cols[0]:
|
|
390
|
+
if video.get("thumbnail_url"):
|
|
391
|
+
st.image(video["thumbnail_url"], width=64)
|
|
392
|
+
else:
|
|
393
|
+
st.markdown("### YT")
|
|
394
|
+
with cols[1]:
|
|
395
|
+
st.write(f"**{video.get('title', 'Vídeo sem título')}**")
|
|
396
|
+
st.caption(f"{video.get('published_at') or 'Sem data'} · {video.get('url') or 'Sem URL'}")
|
|
397
|
+
with cols[2]:
|
|
398
|
+
st.caption(str(video.get("status") or "publicado").title())
|
|
399
|
+
with cols[3]:
|
|
400
|
+
render_channel_video_editor(video, channel_id)
|
|
401
|
+
else:
|
|
402
|
+
columns = st.columns(4)
|
|
403
|
+
groups = [("planejamento", "Planejamento"), ("produção", "Produção"), ("finalizado", "Finalizado"), ("publicado", "Agendado/Publicado")]
|
|
404
|
+
for column, (status_key, label) in zip(columns, groups):
|
|
405
|
+
with column:
|
|
406
|
+
st.markdown(f"**{label}**")
|
|
407
|
+
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"})]
|
|
408
|
+
if not group:
|
|
409
|
+
st.caption("Sem vídeos")
|
|
410
|
+
for video in group:
|
|
411
|
+
with st.container(border=True):
|
|
412
|
+
st.write(f"**{video.get('title', 'Vídeo sem título')}**")
|
|
413
|
+
st.caption(video.get("published_at") or "Sem data")
|
|
414
|
+
render_channel_video_editor(video, channel_id)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
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:
|
|
418
|
+
channel_id = str(channel["id"])
|
|
419
|
+
blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
|
|
420
|
+
account_ids = list(youtube_account_ids)
|
|
421
|
+
current_account = str(channel.get("google_account_id") or "")
|
|
422
|
+
if current_account and current_account not in account_ids:
|
|
423
|
+
account_ids.append(current_account)
|
|
424
|
+
youtube_account_labels[current_account] = "Conta Google não configurada"
|
|
425
|
+
with st.form(f"channel_edit_form_{channel_id}"):
|
|
426
|
+
st.subheader("Editar canal")
|
|
427
|
+
edit_cols = st.columns(2)
|
|
428
|
+
with edit_cols[0]:
|
|
429
|
+
edited_name = st.text_input("Nome do canal", value=str(channel.get("name") or ""))
|
|
430
|
+
edited_url = st.text_input("URL", value=str(channel.get("url") or ""))
|
|
431
|
+
edited_handle = st.text_input("Handle", value=str(channel.get("handle") or ""))
|
|
432
|
+
edited_language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=["Português", "English", "Español", "Français", "Deutsch"].index(channel.get("language")) if channel.get("language") in {"Português", "English", "Español", "Français", "Deutsch"} else 0)
|
|
433
|
+
style_options = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
|
|
434
|
+
style_value = {"pexels": "Pexels/Pixabay", "music": "Apenas Música"}.get(str(channel.get("style_wide") or "pexels"), str(channel.get("style_wide") or "Pexels/Pixabay"))
|
|
435
|
+
edited_style = st.selectbox("Estilo wide", style_options, index=style_options.index(style_value) if style_value in style_options else 0)
|
|
436
|
+
edited_niche = st.text_input("Canais de Referência / Nicho", value=str(channel.get("niche") or channel_niche_label(channel) if channel_niche_label(channel) != "SEM NICHO CONFIGURADO" else "") )
|
|
437
|
+
with edit_cols[1]:
|
|
438
|
+
edited_blueprint = st.selectbox("Prompts do Canal", blueprint_ids, index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"))
|
|
439
|
+
edited_voice = st.selectbox("Narrador", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão")
|
|
440
|
+
edited_account = st.selectbox("Conta Google para Upload directo", account_ids, index=account_ids.index(current_account) if current_account in account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"))
|
|
441
|
+
edited_description = st.text_area("Descrição", value=str(channel.get("description") or ""), height=100)
|
|
442
|
+
edited_automation = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"edit_automation_{channel_id}")
|
|
443
|
+
edited_time = st.text_input("Horário diário (HH:MM)", value=str(channel.get("automation_time") or "00:00"))
|
|
444
|
+
save_channel = st.form_submit_button("Guardar alterações", type="primary", use_container_width=True)
|
|
445
|
+
cancel_edit = st.form_submit_button("Cancelar edição")
|
|
446
|
+
if cancel_edit:
|
|
447
|
+
st.session_state.pop(f"edit_channel_{channel_id}", None)
|
|
448
|
+
st.rerun()
|
|
449
|
+
if save_channel:
|
|
450
|
+
if not edited_name.strip():
|
|
451
|
+
st.error("Informe o nome do canal.")
|
|
452
|
+
elif not valid_hhmm(edited_time):
|
|
453
|
+
st.error("O horário diário deve estar no formato HH:MM.")
|
|
454
|
+
else:
|
|
455
|
+
update_channel(channel_id, {
|
|
456
|
+
"name": edited_name.strip(), "url": edited_url.strip(), "handle": edited_handle.strip(), "language": edited_language,
|
|
457
|
+
"style_wide": {"Pexels/Pixabay": "pexels", "Apenas Música": "music"}.get(edited_style, edited_style),
|
|
458
|
+
"niche": edited_niche.strip(), "reference_channels": [item.strip() for item in re.split(r"[,|]", edited_niche) if item.strip()],
|
|
459
|
+
"default_blueprint_id": edited_blueprint.strip(), "blueprint_id": edited_blueprint.strip(),
|
|
460
|
+
"default_voice": edited_voice.strip(), "voice": edited_voice.strip(),
|
|
461
|
+
"google_account_id": edited_account.strip(), "google_account_email": str(youtube_accounts_by_id.get(edited_account, {}).get("email", "")),
|
|
462
|
+
"description": edited_description.strip(), "automation_on": bool(edited_automation), "automation_time": edited_time.strip(),
|
|
463
|
+
})
|
|
464
|
+
st.session_state.pop(f"edit_channel_{channel_id}", None)
|
|
465
|
+
st.success("Canal actualizado.")
|
|
466
|
+
st.rerun()
|
|
467
|
+
|
|
468
|
+
|
|
226
469
|
def render_dashboard():
|
|
227
470
|
st.title("Thunderbolt")
|
|
228
471
|
st.caption("Interface local para operação e automação de conteúdo faceless")
|
|
@@ -405,6 +648,7 @@ def render_channels():
|
|
|
405
648
|
automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
|
|
406
649
|
automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
|
|
407
650
|
description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
|
|
651
|
+
niche = st.text_input("Canais de Referência / Nicho", value=imported.get("niche", ""), key="yt_import_niche")
|
|
408
652
|
metrics = st.columns(3)
|
|
409
653
|
with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=int(imported.get("subscriber_count") or 0), key="yt_import_subscribers")
|
|
410
654
|
with metrics[1]: video_count = st.number_input("Vídeos", min_value=0, value=int(imported.get("video_count") or 0), key="yt_import_videos")
|
|
@@ -420,6 +664,8 @@ def render_channels():
|
|
|
420
664
|
**imported,
|
|
421
665
|
"handle": handle.strip(),
|
|
422
666
|
"description": description.strip(),
|
|
667
|
+
"niche": niche.strip(),
|
|
668
|
+
"reference_channels": [item.strip() for item in re.split(r"[,|]", niche) if item.strip()],
|
|
423
669
|
"language": language,
|
|
424
670
|
"style_wide": {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style, style),
|
|
425
671
|
"blueprint_id": blueprint.strip(),
|
|
@@ -538,6 +784,7 @@ def render_channels():
|
|
|
538
784
|
url = st.text_input("URL do canal", placeholder="https://youtube.com/@seucanal", key="manual_channel_url")
|
|
539
785
|
handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
|
|
540
786
|
description = st.text_area("Descrição", key="manual_channel_description")
|
|
787
|
+
niche = st.text_input("Canais de Referência / Nicho", placeholder="Ex.: História militar, mistérios, ciência", key="manual_channel_niche")
|
|
541
788
|
language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="manual_channel_language")
|
|
542
789
|
style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="manual_channel_style")
|
|
543
790
|
manual_blueprint_items = blueprint_catalog()
|
|
@@ -565,6 +812,8 @@ def render_channels():
|
|
|
565
812
|
channel = create_channel(name, url, {
|
|
566
813
|
"handle": handle.strip(),
|
|
567
814
|
"description": description.strip(),
|
|
815
|
+
"niche": niche.strip(),
|
|
816
|
+
"reference_channels": [item.strip() for item in re.split(r"[,|]", niche) if item.strip()],
|
|
568
817
|
"language": language,
|
|
569
818
|
"style_wide": {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style, style),
|
|
570
819
|
"blueprint_id": blueprint.strip(),
|
|
@@ -590,115 +839,187 @@ def render_channels():
|
|
|
590
839
|
st.info("Nenhum canal cadastrado.")
|
|
591
840
|
return
|
|
592
841
|
for channel in channels:
|
|
842
|
+
channel_id = str(channel["id"])
|
|
843
|
+
edit_key = f"edit_channel_{channel_id}"
|
|
593
844
|
with st.container(border=True):
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
if current_channel_account_id and current_channel_account_id not in channel_account_ids:
|
|
597
|
-
channel_account_ids.append(current_channel_account_id)
|
|
598
|
-
youtube_account_labels[current_channel_account_id] = "Conta Google não configurada"
|
|
599
|
-
st.markdown("**Upload directo — documento da conta deste canal**")
|
|
600
|
-
st.caption("O DELEGATED_SESSION_ID deste canal é individual, mas fica apenas no documento JSON da conta Google. A UI não mostra nem edita esse valor; associe apenas o canal à conta que contém o documento.")
|
|
601
|
-
with st.form(f"channel_direct_credentials_{channel['id']}"):
|
|
602
|
-
channel_account_id = st.selectbox("Conta Google do documento deste canal", channel_account_ids, index=channel_account_ids.index(current_channel_account_id) if current_channel_account_id in channel_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"), key=f"channel_account_{channel['id']}")
|
|
603
|
-
save_channel_direct_credentials = st.form_submit_button("Associar conta Google ao canal", type="primary", use_container_width=True)
|
|
604
|
-
selected_channel_account = youtube_accounts_by_id.get(channel_account_id)
|
|
605
|
-
if selected_channel_account:
|
|
606
|
-
selected_account_status = document_status(STORAGE, selected_channel_account, channel, settings, channels)
|
|
607
|
-
if selected_account_status["ready"]:
|
|
608
|
-
st.success("Documento da conta completo e DELEGATED_SESSION_ID deste canal encontrado no documento.")
|
|
609
|
-
elif not selected_account_status["document_exists"]:
|
|
610
|
-
st.warning("A conta seleccionada ainda não tem documento JSON de credenciais.")
|
|
611
|
-
else:
|
|
612
|
-
missing_channel_parts = list(selected_account_status["missing_cookies"])
|
|
613
|
-
if not selected_account_status["has_session_info"]:
|
|
614
|
-
missing_channel_parts.append("sessionInfo")
|
|
615
|
-
if not selected_account_status["has_innertube_api_key"]:
|
|
616
|
-
missing_channel_parts.append("INNERTUBE_API_KEY")
|
|
617
|
-
if not selected_account_status["has_delegated_session_id"]:
|
|
618
|
-
missing_channel_parts.append("DELEGATED_SESSION_ID deste canal")
|
|
619
|
-
st.warning(f"Documento incompleto: {', '.join(missing_channel_parts)}")
|
|
620
|
-
else:
|
|
621
|
-
st.info("Associe este canal a uma conta Google para validar o documento de credenciais.")
|
|
622
|
-
if save_channel_direct_credentials:
|
|
623
|
-
update_channel(channel["id"], {"google_account_id": channel_account_id.strip(), "google_account_email": str(youtube_accounts_by_id.get(channel_account_id, {}).get("email", ""))})
|
|
624
|
-
st.success("Conta Google associada ao canal. O DELEGATED_SESSION_ID continua exclusivamente no documento da conta.")
|
|
625
|
-
st.rerun()
|
|
626
|
-
cols = st.columns([0.6, 2.2, 1.2, 1.2, 1.2, 1])
|
|
627
|
-
with cols[0]:
|
|
845
|
+
header_cols = st.columns([0.7, 3.5, 1.3, 1.3, 1.5])
|
|
846
|
+
with header_cols[0]:
|
|
628
847
|
if channel.get("thumbnail_url"):
|
|
629
|
-
st.image(channel["thumbnail_url"], width=
|
|
848
|
+
st.image(channel["thumbnail_url"], width=64)
|
|
630
849
|
else:
|
|
631
850
|
st.markdown("### YT")
|
|
632
|
-
with
|
|
851
|
+
with header_cols[1]:
|
|
633
852
|
st.write(f"**{channel.get('name', 'Sem nome')}**")
|
|
853
|
+
st.caption(channel_niche_label(channel))
|
|
634
854
|
st.caption(f"{channel.get('handle') or channel.get('url') or 'sem URL'} · {channel.get('metrics_source', 'manual')}")
|
|
635
|
-
with
|
|
636
|
-
|
|
637
|
-
with
|
|
638
|
-
|
|
639
|
-
|
|
855
|
+
with header_cols[2]:
|
|
856
|
+
st.metric("Inscritos", channel.get("subscriber_count") if channel.get("subscriber_count") is not None else "—")
|
|
857
|
+
with header_cols[3]:
|
|
858
|
+
st.metric("Vídeos", channel.get("video_count") if channel.get("video_count") is not None else "—")
|
|
859
|
+
with header_cols[4]:
|
|
860
|
+
active = st.toggle("Activo", value=channel.get("active", True), key=f"active_{channel_id}")
|
|
640
861
|
if active != channel.get("active"):
|
|
641
|
-
update_channel(
|
|
862
|
+
update_channel(channel_id, {"active": active})
|
|
642
863
|
st.rerun()
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
if st.button("
|
|
864
|
+
action_cols = st.columns(2)
|
|
865
|
+
with action_cols[0]:
|
|
866
|
+
if st.button("Editar", key=f"edit_channel_button_{channel_id}", use_container_width=True):
|
|
867
|
+
st.session_state[edit_key] = True
|
|
868
|
+
st.rerun()
|
|
869
|
+
with action_cols[1]:
|
|
870
|
+
delete_key = f"delete_pending_{channel_id}"
|
|
871
|
+
if st.button("Apagar", key=f"delete_{channel_id}", use_container_width=True):
|
|
646
872
|
st.session_state[delete_key] = True
|
|
647
873
|
st.rerun()
|
|
874
|
+
if st.session_state.get(edit_key):
|
|
875
|
+
render_channel_edit_form(channel, youtube_account_ids, youtube_account_labels, youtube_accounts_by_id)
|
|
876
|
+
else:
|
|
877
|
+
summary = channel_blueprint_summary(channel)
|
|
878
|
+
block_cols = st.columns(3)
|
|
879
|
+
with block_cols[0]:
|
|
880
|
+
st.markdown(f"**Prompts do Canal**\n\n{summary['name']}")
|
|
881
|
+
if st.button("Editar Prompts", key=f"edit_prompts_{channel_id}", use_container_width=True):
|
|
882
|
+
st.session_state[edit_key] = True
|
|
883
|
+
st.rerun()
|
|
884
|
+
with block_cols[1]:
|
|
885
|
+
st.markdown(f"**Canais de Referência**\n\n{channel_niche_label(channel)}")
|
|
886
|
+
if st.button("Editar Nicho", key=f"edit_niche_{channel_id}", use_container_width=True):
|
|
887
|
+
st.session_state[edit_key] = True
|
|
888
|
+
st.rerun()
|
|
889
|
+
with block_cols[2]:
|
|
890
|
+
st.markdown(f"**Narrador**\n\n{summary['voice'] or 'Sem voz padrão'}")
|
|
891
|
+
if st.button("Configurar Narrador", key=f"edit_voice_{channel_id}", use_container_width=True):
|
|
892
|
+
st.session_state[edit_key] = True
|
|
893
|
+
st.rerun()
|
|
894
|
+
|
|
895
|
+
channel_account_ids = list(youtube_account_ids)
|
|
896
|
+
current_channel_account_id = str(channel.get("google_account_id", ""))
|
|
897
|
+
if current_channel_account_id and current_channel_account_id not in channel_account_ids:
|
|
898
|
+
channel_account_ids.append(current_channel_account_id)
|
|
899
|
+
youtube_account_labels[current_channel_account_id] = "Conta Google não configurada"
|
|
900
|
+
with st.expander("Upload directo — documento da conta deste canal", expanded=False):
|
|
901
|
+
st.caption("O DELEGATED_SESSION_ID deste canal é individual, mas fica apenas no documento JSON da conta Google. A UI não mostra nem edita esse valor; associe apenas o canal à conta que contém o documento.")
|
|
902
|
+
with st.form(f"channel_direct_credentials_{channel_id}"):
|
|
903
|
+
channel_account_id = st.selectbox("Conta Google do documento deste canal", channel_account_ids, index=channel_account_ids.index(current_channel_account_id) if current_channel_account_id in channel_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"), key=f"channel_account_{channel_id}")
|
|
904
|
+
save_channel_direct_credentials = st.form_submit_button("Associar conta Google ao canal", type="primary", use_container_width=True)
|
|
905
|
+
selected_channel_account = youtube_accounts_by_id.get(channel_account_id)
|
|
906
|
+
if selected_channel_account:
|
|
907
|
+
selected_account_status = document_status(STORAGE, selected_channel_account, channel, settings, channels)
|
|
908
|
+
if selected_account_status["ready"]:
|
|
909
|
+
st.success("Documento da conta completo e DELEGATED_SESSION_ID deste canal encontrado no documento.")
|
|
910
|
+
elif not selected_account_status["document_exists"]:
|
|
911
|
+
st.warning("A conta seleccionada ainda não tem documento JSON de credenciais.")
|
|
912
|
+
else:
|
|
913
|
+
missing_channel_parts = list(selected_account_status["missing_cookies"])
|
|
914
|
+
if not selected_account_status["has_session_info"]:
|
|
915
|
+
missing_channel_parts.append("sessionInfo")
|
|
916
|
+
if not selected_account_status["has_innertube_api_key"]:
|
|
917
|
+
missing_channel_parts.append("INNERTUBE_API_KEY")
|
|
918
|
+
if not selected_account_status["has_delegated_session_id"]:
|
|
919
|
+
missing_channel_parts.append("DELEGATED_SESSION_ID deste canal")
|
|
920
|
+
st.warning(f"Documento incompleto: {', '.join(missing_channel_parts)}")
|
|
648
921
|
else:
|
|
649
|
-
st.
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
removed = delete_channel(channel["id"])
|
|
654
|
-
st.session_state.pop(delete_key, None)
|
|
655
|
-
if removed:
|
|
656
|
-
st.success(f"Canal {removed.get('name', 'seleccionado')} apagado.")
|
|
657
|
-
st.rerun()
|
|
658
|
-
with cancel_col:
|
|
659
|
-
if st.button("Cancelar", key=f"cancel_delete_{channel['id']}", use_container_width=True):
|
|
660
|
-
st.session_state.pop(delete_key, None)
|
|
661
|
-
st.rerun()
|
|
662
|
-
blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
|
|
663
|
-
with st.expander("Definir Blueprint e voz padrão", expanded=False):
|
|
664
|
-
editor_cols = st.columns(2)
|
|
665
|
-
with editor_cols[0]:
|
|
666
|
-
channel_blueprint = st.selectbox(
|
|
667
|
-
"Blueprint padrão",
|
|
668
|
-
blueprint_ids,
|
|
669
|
-
index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0,
|
|
670
|
-
format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
|
|
671
|
-
key=f"channel_default_blueprint_{channel['id']}",
|
|
672
|
-
)
|
|
673
|
-
with editor_cols[1]:
|
|
674
|
-
channel_voice = st.selectbox(
|
|
675
|
-
"Voz padrão",
|
|
676
|
-
voice_options,
|
|
677
|
-
index=voice_options.index(current_voice) if current_voice in voice_options else 0,
|
|
678
|
-
format_func=lambda item: item or "Sem voz padrão",
|
|
679
|
-
key=f"channel_default_voice_{channel['id']}",
|
|
680
|
-
)
|
|
681
|
-
if st.button("Guardar Blueprint e voz", key=f"save_channel_defaults_{channel['id']}", type="primary"):
|
|
682
|
-
set_channel_defaults(channel["id"], channel_blueprint, channel_voice)
|
|
683
|
-
st.success("Blueprint padrão e voz padrão guardados para este canal.")
|
|
922
|
+
st.info("Associe este canal a uma conta Google para validar o documento de credenciais.")
|
|
923
|
+
if save_channel_direct_credentials:
|
|
924
|
+
update_channel(channel_id, {"google_account_id": channel_account_id.strip(), "google_account_email": str(youtube_accounts_by_id.get(channel_account_id, {}).get("email", ""))})
|
|
925
|
+
st.success("Conta Google associada ao canal. O DELEGATED_SESSION_ID continua exclusivamente no documento da conta.")
|
|
684
926
|
st.rerun()
|
|
685
927
|
|
|
928
|
+
render_channel_videos(channel)
|
|
929
|
+
|
|
686
930
|
|
|
687
931
|
def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
688
932
|
st.title(page_title)
|
|
689
933
|
create_tab, videos_tab = st.tabs(["Criar vídeo", "Vídeos"])
|
|
690
934
|
with create_tab:
|
|
691
|
-
|
|
692
|
-
if
|
|
935
|
+
all_channels = [c for c in read_json("channels.json", []) if isinstance(c, dict)]
|
|
936
|
+
active_channels = [c for c in all_channels if c.get("active", True)]
|
|
937
|
+
if not all_channels:
|
|
693
938
|
st.warning("Cadastre pelo menos um canal antes de criar vídeos.")
|
|
694
939
|
else:
|
|
695
|
-
mode_label = st.radio(
|
|
940
|
+
mode_label = st.radio(
|
|
941
|
+
"Modo de criação",
|
|
942
|
+
["Canal específico", "Lote no mesmo canal", "Lote geral"],
|
|
943
|
+
horizontal=True,
|
|
944
|
+
key="new_video_mode",
|
|
945
|
+
)
|
|
696
946
|
mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
|
|
947
|
+
selected_one: dict[str, Any] | None = None
|
|
697
948
|
if mode == "general":
|
|
698
|
-
selected =
|
|
949
|
+
selected = [str(channel["id"]) for channel in all_channels if channel.get("id")]
|
|
950
|
+
st.info(f"**Lote geral:** será criada exactamente uma tarefa para cada um dos {len(selected)} canais cadastrados. Cada canal receberá um tema, título e thumbnail próprios; não existe selecção parcial.")
|
|
951
|
+
with st.container(border=True):
|
|
952
|
+
st.subheader("Canais que serão processados")
|
|
953
|
+
for channel in all_channels:
|
|
954
|
+
summary = channel_blueprint_summary(channel)
|
|
955
|
+
status = "Activo" if channel.get("active", True) else "Inactivo"
|
|
956
|
+
st.caption(f"**{channel.get('name', 'Canal')}** · {status} · Blueprint: **{summary['name']}** · Voz: {summary['voice'] or 'Sem voz padrão'}")
|
|
957
|
+
general_context = st.text_area(
|
|
958
|
+
"Contexto opcional para todos os canais",
|
|
959
|
+
value=st.session_state.get("new_video_general_context", ""),
|
|
960
|
+
key="new_video_general_context",
|
|
961
|
+
placeholder="Opcional: campanha, época, evento ou restrição editorial comum. O tema final será individual por canal.",
|
|
962
|
+
)
|
|
963
|
+
if st.button("Gerar tópicos individuais para todos os canais", key="new_video_generate_general_topics", use_container_width=True):
|
|
964
|
+
settings = read_json("settings.json", {})
|
|
965
|
+
generated_topics: dict[str, dict[str, Any]] = {}
|
|
966
|
+
errors: list[str] = []
|
|
967
|
+
with st.spinner("A gerar um briefing específico para cada canal…"):
|
|
968
|
+
for channel in all_channels:
|
|
969
|
+
try:
|
|
970
|
+
generated_topics[channel["id"]] = generate_topic_for_ui(settings, channel, general_context)
|
|
971
|
+
except CreativeGenerationError as exc:
|
|
972
|
+
errors.append(f"{channel.get('name', 'Canal')}: {exc}")
|
|
973
|
+
if errors:
|
|
974
|
+
for error in errors:
|
|
975
|
+
st.error(error)
|
|
976
|
+
else:
|
|
977
|
+
st.session_state["new_video_general_topics"] = generated_topics
|
|
978
|
+
st.success(f"Foram gerados {len(generated_topics)} briefings independentes.")
|
|
979
|
+
general_topics = st.session_state.get("new_video_general_topics", {})
|
|
980
|
+
if general_topics:
|
|
981
|
+
st.subheader("Briefings por canal")
|
|
982
|
+
for channel in all_channels:
|
|
983
|
+
result = general_topics.get(channel["id"])
|
|
984
|
+
if result:
|
|
985
|
+
st.write(f"**{channel.get('name', 'Canal')}**")
|
|
986
|
+
st.caption(f"{result.get('niche', '')} · {result.get('angle', '')}")
|
|
987
|
+
st.text_area("Briefing gerado", value=result.get("topic", ""), key=f"new_video_general_topic_{channel['id']}", height=80)
|
|
699
988
|
else:
|
|
700
|
-
|
|
701
|
-
|
|
989
|
+
if not active_channels:
|
|
990
|
+
st.warning("Não existem canais activos disponíveis para os modos de canal específico.")
|
|
991
|
+
selected = []
|
|
992
|
+
else:
|
|
993
|
+
selected_one = st.selectbox("Canal", active_channels, format_func=lambda c: c["name"], key="new_video_channel")
|
|
994
|
+
selected = [selected_one["id"]]
|
|
995
|
+
# Intentionally sits between Canal and Estilo wide, as requested.
|
|
996
|
+
render_channel_blueprint_panel(selected_one)
|
|
997
|
+
topic = st.text_area(
|
|
998
|
+
"Tópico ou briefing",
|
|
999
|
+
value=st.session_state.get("new_video_topic", ""),
|
|
1000
|
+
key="new_video_topic",
|
|
1001
|
+
placeholder="Escreva um briefing ou gere-o com IA; não é obrigatório escrever manualmente.",
|
|
1002
|
+
help="Pode escrever o tema ou usar o botão abaixo para gerar um briefing específico com o Blueprint e o nicho do canal.",
|
|
1003
|
+
)
|
|
1004
|
+
topic_cols = st.columns([1, 1.8])
|
|
1005
|
+
with topic_cols[0]:
|
|
1006
|
+
if st.button("Gerar tópico/briefing com IA", key="new_video_generate_topic", use_container_width=True):
|
|
1007
|
+
if selected_one is None:
|
|
1008
|
+
st.error("Seleccione primeiro um canal.")
|
|
1009
|
+
else:
|
|
1010
|
+
try:
|
|
1011
|
+
result = generate_topic_for_ui(read_json("settings.json", {}), selected_one, topic)
|
|
1012
|
+
st.session_state["new_video_topic"] = result["topic"]
|
|
1013
|
+
st.session_state["new_video_topic_meta"] = result
|
|
1014
|
+
st.success("Briefing gerado; reveja e edite o texto antes de criar as tarefas.")
|
|
1015
|
+
st.rerun()
|
|
1016
|
+
except CreativeGenerationError as exc:
|
|
1017
|
+
st.error(str(exc))
|
|
1018
|
+
with topic_cols[1]:
|
|
1019
|
+
if st.session_state.get("new_video_topic_meta"):
|
|
1020
|
+
meta = st.session_state["new_video_topic_meta"]
|
|
1021
|
+
st.caption(f"Origem: IA · Nicho: {meta.get('niche', '—')} · Ângulo: {meta.get('angle', '—')}")
|
|
1022
|
+
|
|
702
1023
|
wide_style_label = st.selectbox("Estilo wide", WIDE_STYLE_OPTIONS, key="new_video_style_wide")
|
|
703
1024
|
style_ia = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key="new_video_style_ia") if wide_style_label == "full_ia" else ""
|
|
704
1025
|
music_path = ""
|
|
@@ -725,7 +1046,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
725
1046
|
music_path = st.session_state.get("new_video_music_path", "")
|
|
726
1047
|
else:
|
|
727
1048
|
suno_prompt = st.text_area("Prompt musical Suno", placeholder="Instrumental cinematográfico, calmo, sem voz...", key="new_video_suno_prompt")
|
|
728
|
-
suno_title = st.text_input("Título da música", value=
|
|
1049
|
+
suno_title = st.text_input("Título da música", value=st.session_state.get("new_video_topic") or "Thunderbolt music", key="new_video_suno_title")
|
|
729
1050
|
if st.button("Solicitar música no Suno", key="new_video_suno_request", use_container_width=True):
|
|
730
1051
|
suno_result = request_suno_generation(read_json("settings.json", {}), suno_prompt, suno_title)
|
|
731
1052
|
(st.success if suno_result["ok"] else st.error)(suno_result["message"])
|
|
@@ -740,32 +1061,165 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
740
1061
|
except (OSError, requests.RequestException, ValueError) as exc:
|
|
741
1062
|
st.warning(f"Pedido criado, mas não foi possível descarregar o áudio: {exc}")
|
|
742
1063
|
music_path = st.session_state.get("new_video_music_path", "")
|
|
1064
|
+
|
|
1065
|
+
payloads: dict[str, dict[str, Any]] = {}
|
|
1066
|
+
if mode == "general":
|
|
1067
|
+
existing_topics = st.session_state.get("new_video_general_topics", {})
|
|
1068
|
+
payloads = dict(st.session_state.get("new_video_general_payloads", {}))
|
|
1069
|
+
if st.button("Gerar títulos e thumbnails para todos os canais", key="new_video_generate_general_creative", use_container_width=True):
|
|
1070
|
+
settings = read_json("settings.json", {})
|
|
1071
|
+
new_payloads: dict[str, dict[str, Any]] = {}
|
|
1072
|
+
errors: list[str] = []
|
|
1073
|
+
with st.spinner("A gerar títulos e thumbnails independentes por canal…"):
|
|
1074
|
+
for channel in all_channels:
|
|
1075
|
+
try:
|
|
1076
|
+
topic_result = existing_topics.get(channel["id"])
|
|
1077
|
+
if not topic_result:
|
|
1078
|
+
topic_result = generate_topic_for_ui(settings, channel, general_context)
|
|
1079
|
+
generated = generate_creative_for_ui(settings, channel, topic_result["topic"], topic_source="llm")
|
|
1080
|
+
generated["ai_generation"]["topic"] = topic_result
|
|
1081
|
+
new_payloads[channel["id"]] = generated
|
|
1082
|
+
except CreativeGenerationError as exc:
|
|
1083
|
+
errors.append(f"{channel.get('name', 'Canal')}: {exc}")
|
|
1084
|
+
if errors:
|
|
1085
|
+
for error in errors:
|
|
1086
|
+
st.error(error)
|
|
1087
|
+
else:
|
|
1088
|
+
st.session_state["new_video_general_topics"] = {cid: {"topic": item["topic"], "topic_source": "llm"} for cid, item in new_payloads.items()}
|
|
1089
|
+
st.session_state["new_video_general_payloads"] = new_payloads
|
|
1090
|
+
payloads = new_payloads
|
|
1091
|
+
st.success(f"Pacote criativo pronto para {len(new_payloads)} canais.")
|
|
1092
|
+
payloads = st.session_state.get("new_video_general_payloads", payloads)
|
|
1093
|
+
for channel in all_channels:
|
|
1094
|
+
payload = payloads.get(channel["id"])
|
|
1095
|
+
if not payload:
|
|
1096
|
+
continue
|
|
1097
|
+
with st.expander(f"{channel.get('name', 'Canal')} — título e thumbnail", expanded=False):
|
|
1098
|
+
title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
|
|
1099
|
+
if title_options:
|
|
1100
|
+
selected_title = st.selectbox("Título escolhido", title_options, index=max(0, title_options.index(payload.get("title")) if payload.get("title") in title_options else 0), key=f"new_video_general_title_{channel['id']}")
|
|
1101
|
+
payload["title"] = selected_title
|
|
1102
|
+
variants = payload.get("thumbnail_variants", [])
|
|
1103
|
+
if variants:
|
|
1104
|
+
labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
|
|
1105
|
+
selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"new_video_general_thumbnail_{channel['id']}")
|
|
1106
|
+
variant = variants[labels.index(selected_variant_label)]
|
|
1107
|
+
payload["thumbnail_variant"] = variant
|
|
1108
|
+
payload["thumbnail_prompt"] = variant.get("image_prompt", "")
|
|
1109
|
+
payload["thumbnail_text"] = variant.get("overlay_text", "")
|
|
1110
|
+
st.caption(f"{variant.get('composition', '')} · {variant.get('color_palette', '')}")
|
|
1111
|
+
st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
|
|
1112
|
+
else:
|
|
1113
|
+
topic_for_creative = str(st.session_state.get("new_video_topic", "") or "").strip()
|
|
1114
|
+
if st.button("Gerar títulos e thumbnails com IA", key="new_video_generate_creative", use_container_width=True):
|
|
1115
|
+
if selected_one is None:
|
|
1116
|
+
st.error("Seleccione primeiro um canal.")
|
|
1117
|
+
elif not topic_for_creative:
|
|
1118
|
+
st.error("Escreva ou gere primeiro um tópico/briefing.")
|
|
1119
|
+
else:
|
|
1120
|
+
try:
|
|
1121
|
+
generated = generate_creative_for_ui(read_json("settings.json", {}), selected_one, topic_for_creative, topic_source="llm" if st.session_state.get("new_video_topic_meta") else "manual")
|
|
1122
|
+
st.session_state["new_video_creative_payload"] = generated
|
|
1123
|
+
st.success("Título e thumbnails gerados; escolha a variante antes de criar as tarefas.")
|
|
1124
|
+
st.rerun()
|
|
1125
|
+
except CreativeGenerationError as exc:
|
|
1126
|
+
st.error(str(exc))
|
|
1127
|
+
payload = st.session_state.get("new_video_creative_payload")
|
|
1128
|
+
if payload:
|
|
1129
|
+
st.subheader("Título e Thumbnail automáticos")
|
|
1130
|
+
title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
|
|
1131
|
+
if title_options:
|
|
1132
|
+
selected_title = st.selectbox("Título escolhido", title_options, index=max(0, title_options.index(payload.get("title")) if payload.get("title") in title_options else 0), key="new_video_title_choice")
|
|
1133
|
+
payload["title"] = selected_title
|
|
1134
|
+
with st.expander(f"Ver {len(title_options)} candidatos de título"):
|
|
1135
|
+
st.dataframe(payload.get("title_candidates", []), use_container_width=True, hide_index=True)
|
|
1136
|
+
variants = payload.get("thumbnail_variants", [])
|
|
1137
|
+
if variants:
|
|
1138
|
+
labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
|
|
1139
|
+
selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key="new_video_thumbnail_choice")
|
|
1140
|
+
variant = variants[labels.index(selected_variant_label)]
|
|
1141
|
+
payload["thumbnail_variant"] = variant
|
|
1142
|
+
payload["thumbnail_prompt"] = variant.get("image_prompt", "")
|
|
1143
|
+
payload["thumbnail_text"] = variant.get("overlay_text", "")
|
|
1144
|
+
st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
|
|
1145
|
+
st.code(variant.get("image_prompt", ""), language="text")
|
|
1146
|
+
st.info("Prompt de thumbnail pronto — a imagem será criada quando existir um provider de imagem configurado; não é criado um ficheiro falso.")
|
|
1147
|
+
st.session_state["new_video_creative_payload"] = payload
|
|
1148
|
+
|
|
743
1149
|
with st.form("new_video_form"):
|
|
744
|
-
topic = st.text_area("Tópico ou briefing", placeholder="Ex.: A história pouco conhecida por trás de...")
|
|
745
1150
|
quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
|
|
746
1151
|
legacy_language = st.session_state.get("video_language")
|
|
747
|
-
legacy_language_map = {
|
|
748
|
-
"Português": "36 – Português (Brasil)",
|
|
749
|
-
"English": "01 – Inglês",
|
|
750
|
-
"Español": "41 – Espanhol (LatAm)",
|
|
751
|
-
}
|
|
1152
|
+
legacy_language_map = {"Português": "36 – Português (Brasil)", "English": "01 – Inglês", "Español": "41 – Espanhol (LatAm)"}
|
|
752
1153
|
if legacy_language not in VIDEO_LANGUAGE_OPTIONS:
|
|
753
1154
|
st.session_state["video_language"] = legacy_language_map.get(legacy_language, VIDEO_LANGUAGE_OPTIONS[0])
|
|
754
1155
|
language = st.selectbox("Idioma", VIDEO_LANGUAGE_OPTIONS, key="video_language")
|
|
755
|
-
fmt = st.selectbox("Formato", ["wide", "shorts", "music"])
|
|
1156
|
+
fmt = st.selectbox("Formato", ["wide", "shorts", "music"], key="new_video_format")
|
|
756
1157
|
submitted = st.form_submit_button("Criar tarefas", type="primary")
|
|
757
1158
|
if submitted:
|
|
758
|
-
|
|
759
|
-
|
|
1159
|
+
style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
|
|
1160
|
+
if style == "music" and not music_path:
|
|
1161
|
+
st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
|
|
1162
|
+
st.stop()
|
|
1163
|
+
if mode == "general":
|
|
1164
|
+
payloads = dict(st.session_state.get("new_video_general_payloads", {}))
|
|
1165
|
+
topics = dict(st.session_state.get("new_video_general_topics", {}))
|
|
1166
|
+
channels_by_id = {str(channel["id"]): channel for channel in all_channels}
|
|
1167
|
+
payloads_need_refresh = len(payloads) != len(selected) or any(
|
|
1168
|
+
str(st.session_state.get(f"new_video_general_topic_{channel_id}", "") or "").strip()
|
|
1169
|
+
and str(st.session_state.get(f"new_video_general_topic_{channel_id}", "") or "").strip() != str((payloads.get(channel_id) or {}).get("topic") or "").strip()
|
|
1170
|
+
for channel_id in selected
|
|
1171
|
+
)
|
|
1172
|
+
if payloads_need_refresh:
|
|
1173
|
+
settings = read_json("settings.json", {})
|
|
1174
|
+
generated_payloads: dict[str, dict[str, Any]] = {}
|
|
1175
|
+
errors: list[str] = []
|
|
1176
|
+
with st.spinner("A gerar automaticamente um pacote criativo independente para cada canal…"):
|
|
1177
|
+
for channel_id in selected:
|
|
1178
|
+
channel = channels_by_id[channel_id]
|
|
1179
|
+
edited_topic = str(st.session_state.get(f"new_video_general_topic_{channel_id}", "") or "").strip()
|
|
1180
|
+
topic_result = topics.get(channel_id) or {}
|
|
1181
|
+
try:
|
|
1182
|
+
if not edited_topic:
|
|
1183
|
+
topic_result = generate_topic_for_ui(settings, channel, general_context)
|
|
1184
|
+
edited_topic = topic_result["topic"]
|
|
1185
|
+
else:
|
|
1186
|
+
topic_result = {**topic_result, "topic": edited_topic, "topic_source": topic_result.get("topic_source", "manual")}
|
|
1187
|
+
generated = generate_creative_for_ui(settings, channel, edited_topic, topic_source=topic_result.get("topic_source", "llm"))
|
|
1188
|
+
generated["ai_generation"]["topic"] = topic_result
|
|
1189
|
+
generated_payloads[channel_id] = generated
|
|
1190
|
+
except CreativeGenerationError as exc:
|
|
1191
|
+
errors.append(f"{channel.get('name', 'Canal')}: {exc}")
|
|
1192
|
+
if errors:
|
|
1193
|
+
for error in errors:
|
|
1194
|
+
st.error(error)
|
|
1195
|
+
st.error("O Lote geral não foi criado porque faltou gerar o conteúdo específico de pelo menos um canal.")
|
|
1196
|
+
else:
|
|
1197
|
+
payloads = generated_payloads
|
|
1198
|
+
st.session_state["new_video_general_payloads"] = payloads
|
|
1199
|
+
st.session_state["new_video_general_topics"] = {cid: {"topic": payload["topic"], "topic_source": payload.get("topic_source", "llm")} for cid, payload in payloads.items()}
|
|
1200
|
+
if len(payloads) == len(selected):
|
|
1201
|
+
batch_topic = "Lote geral — um vídeo independente por canal"
|
|
1202
|
+
channel_payloads = {cid: {**payload, "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source} for cid, payload in payloads.items()}
|
|
1203
|
+
batch = create_batch("general", selected, batch_topic, 1, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "topic_source": "llm", "channel_payloads": channel_payloads})
|
|
1204
|
+
tasks = create_tasks_for_batch(batch)
|
|
1205
|
+
st.success(f"Lote geral {batch['id']} criado com {len(tasks)} tarefas independentes, uma por canal.")
|
|
760
1206
|
else:
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1207
|
+
topic_value = str(st.session_state.get("new_video_topic", "") or "").strip()
|
|
1208
|
+
if not topic_value or not selected:
|
|
1209
|
+
st.error("Escreva ou gere um tópico e seleccione um canal.")
|
|
1210
|
+
else:
|
|
1211
|
+
quantity_value = int(quantity if mode == "same_channel" else 1)
|
|
1212
|
+
payload = dict(st.session_state.get("new_video_creative_payload") or {})
|
|
1213
|
+
if not payload.get("title") or not payload.get("thumbnail_variants"):
|
|
1214
|
+
try:
|
|
1215
|
+
payload = generate_creative_for_ui(read_json("settings.json", {}), selected_one or {}, topic_value, topic_source="llm" if st.session_state.get("new_video_topic_meta") else "manual")
|
|
1216
|
+
except CreativeGenerationError as exc:
|
|
1217
|
+
st.warning(f"Título/thumbnail automáticos pendentes: {exc} A tarefa será criada com o tópico como título e sem ficheiro de thumbnail.")
|
|
1218
|
+
payload = {"topic": topic_value, "title": topic_value, "topic_source": "manual", "thumbnail_status": "pending_provider", "thumbnail_variants": [], "thumbnail_variant": {}, "thumbnail_prompt": "", "thumbnail_text": ""}
|
|
1219
|
+
payload.update({"topic": topic_value, "topic_source": payload.get("topic_source") or ("llm" if st.session_state.get("new_video_topic_meta") else "manual"), "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source})
|
|
1220
|
+
batch = create_batch(mode, selected, topic_value, quantity_value, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "topic_source": payload.get("topic_source", "manual"), "channel_payloads": {selected[0]: payload}})
|
|
1221
|
+
tasks = create_tasks_for_batch(batch)
|
|
1222
|
+
st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra a subaba Vídeos para acompanhar.")
|
|
769
1223
|
with videos_tab:
|
|
770
1224
|
render_videos()
|
|
771
1225
|
|
|
@@ -1173,8 +1627,16 @@ def render_videos():
|
|
|
1173
1627
|
with st.container(border=True):
|
|
1174
1628
|
cols = st.columns([2.2, 1, 1, 1.2, 1.8])
|
|
1175
1629
|
with cols[0]:
|
|
1176
|
-
st.write(f"**{task.get('topic', 'Sem
|
|
1630
|
+
st.write(f"**{task.get('title') or task.get('topic', 'Sem título')}**")
|
|
1631
|
+
st.caption(f"Tópico: {task.get('topic', 'Sem tópico')}")
|
|
1177
1632
|
st.caption(f"{task.get('channel_name')} · {task.get('id')}")
|
|
1633
|
+
thumbnail_path = (task.get('artifacts') or {}).get('thumbnail', '')
|
|
1634
|
+
if thumbnail_path and Path(thumbnail_path).is_file():
|
|
1635
|
+
st.image(thumbnail_path, width=180)
|
|
1636
|
+
else:
|
|
1637
|
+
status = task.get('thumbnail_status', 'not_generated')
|
|
1638
|
+
prompt_note = ' · prompt pronto' if task.get('thumbnail_prompt') else ''
|
|
1639
|
+
st.caption(f"Thumbnail: {status}{prompt_note}")
|
|
1178
1640
|
with cols[1]: st.write(task.get("format", "wide"))
|
|
1179
1641
|
with cols[2]: st.write(task.get("stage", "—"))
|
|
1180
1642
|
with cols[3]: st.write(task.get("state", "—"))
|
|
@@ -1312,7 +1774,7 @@ def render_upload_direct():
|
|
|
1312
1774
|
st.caption(f"Documento de credenciais pronto: {account.get('email', 'conta Google')} · dados do canal encontrados no documento")
|
|
1313
1775
|
direct_cols = st.columns([2.2, 1, 1])
|
|
1314
1776
|
with direct_cols[0]:
|
|
1315
|
-
title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
|
|
1777
|
+
title = st.text_input("Título", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
|
|
1316
1778
|
with direct_cols[1]:
|
|
1317
1779
|
privacy = st.selectbox("Privacidade", ["private", "unlisted", "public"], key=f"direct_privacy_{task['id']}")
|
|
1318
1780
|
with direct_cols[2]:
|
|
@@ -1387,7 +1849,7 @@ def render_upload_conventional():
|
|
|
1387
1849
|
captions_path = artifacts.get("captions") or artifacts.get("subtitle", "")
|
|
1388
1850
|
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
1389
1851
|
if "YouTube" in destination:
|
|
1390
|
-
title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
|
|
1852
|
+
title = st.text_input("Título", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
|
|
1391
1853
|
description = st.text_area("Descrição", value=task.get("description", ""), key=f"yt_description_{task['id']}", height=100)
|
|
1392
1854
|
tags_raw = st.text_input("Tags separadas por vírgula", value=task.get("tags", "") if isinstance(task.get("tags", ""), str) else ", ".join(task.get("tags", [])), key=f"yt_tags_{task['id']}")
|
|
1393
1855
|
yt_cols = st.columns(3)
|
|
@@ -1426,7 +1888,7 @@ def render_upload_conventional():
|
|
|
1426
1888
|
with st.expander("Detalhes dos mecanismos de upload"):
|
|
1427
1889
|
st.json(result.data["attempts"])
|
|
1428
1890
|
if "TikTok" in destination and st.button("Enviar para TikTok", key=f"upload_tiktok_{task['id']}"):
|
|
1429
|
-
result = TikTokAdapter(settings).upload_video(video_path, task.get("topic", ""))
|
|
1891
|
+
result = TikTokAdapter(settings).upload_video(video_path, task.get("title") or task.get("topic", ""))
|
|
1430
1892
|
(st.success if result.ok else st.warning)(result.message)
|
|
1431
1893
|
if "Instagram" in destination:
|
|
1432
1894
|
st.button("Preparar Instagram", key=f"upload_instagram_{task['id']}", disabled=True, help="UI preparada; publicação Instagram ainda não está activa.")
|