@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/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 integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
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
- channel_account_ids = list(youtube_account_ids)
595
- current_channel_account_id = str(channel.get("google_account_id", ""))
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=54)
848
+ st.image(channel["thumbnail_url"], width=64)
630
849
  else:
631
850
  st.markdown("### YT")
632
- with cols[1]:
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 cols[2]: st.metric("Inscritos", channel.get("subscriber_count") if channel.get("subscriber_count") is not None else "—")
636
- with cols[3]: st.metric("Vídeos", channel.get("video_count") if channel.get("video_count") is not None else "—")
637
- with cols[4]: st.metric("Backlog", channel.get("backlog_total", 0))
638
- with cols[5]:
639
- active = st.toggle("Activo", value=channel.get("active", True), key=f"active_{channel['id']}")
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(channel["id"], {"active": active})
862
+ update_channel(channel_id, {"active": active})
642
863
  st.rerun()
643
- delete_key = f"delete_pending_{channel['id']}"
644
- if not st.session_state.get(delete_key, False):
645
- if st.button("Apagar canal", key=f"delete_{channel['id']}", use_container_width=True):
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.warning("As tarefas, vídeos e artefactos relacionados serão preservados.")
650
- confirm_col, cancel_col = st.columns(2)
651
- with confirm_col:
652
- if st.button("Confirmar apagar", key=f"confirm_delete_{channel['id']}", type="primary", use_container_width=True):
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
- channels = channel_options()
692
- if not channels:
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("Modo de criação", ["Canal específico", "Lote no mesmo canal", "Lote geral"], horizontal=True, key="new_video_mode")
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 = st.multiselect("Canais incluídos", [c["id"] for c in channels], default=[c["id"] for c in channels], format_func=lambda cid: next(c["name"] for c in channels if c["id"] == cid), key="new_video_channels_general")
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
- selected_one = st.selectbox("Canal", channels, format_func=lambda c: c["name"], key="new_video_channel")
701
- selected = [selected_one["id"]]
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=topic if "topic" in locals() else "Thunderbolt music", key="new_video_suno_title")
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
- if not topic.strip() or not selected:
759
- st.error("Informe um tópico e seleccione pelo menos um canal.")
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
- quantity = int(quantity if mode == "same_channel" else 1)
762
- style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
763
- if style == "music" and not music_path:
764
- st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
765
- st.stop()
766
- batch = create_batch(mode, selected, topic, quantity, {"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})
767
- tasks = create_tasks_for_batch(batch)
768
- st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra a subaba Vídeos para acompanhar.")
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 tópico')}**")
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.")