@danhachuel/thunderbolt 0.4.41 → 0.4.43

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
@@ -937,6 +937,7 @@ def render_video_generation_settings(
937
937
  *,
938
938
  current_language: str = "",
939
939
  channel: dict[str, Any] | None = None,
940
+ default_aspect_ratio: str = "Landscape 16:9",
940
941
  generate_content_callback: Any | None = None,
941
942
  save_draft_callback: Any | None = None,
942
943
  sections: set[str] | None = None,
@@ -1048,7 +1049,10 @@ def render_video_generation_settings(
1048
1049
  settings["match_visuals_to_script_order"] = st.checkbox("Match Visuals to Script Order", value=False, key=f"{prefix}_match_visuals")
1049
1050
  settings["video_transition_mode"] = st.selectbox("Video Transition Mode", VIDEO_TRANSITION_OPTIONS, key=f"{prefix}_video_transition")
1050
1051
  with video_cols[1]:
1051
- settings["video_aspect_ratio"] = st.selectbox("Video Aspect Ratio", ["Portrait 9:16", "Landscape 16:9", "Square 1:1"], key=f"{prefix}_video_aspect_ratio")
1052
+ aspect_options = ["Portrait 9:16", "Landscape 16:9", "Square 1:1"]
1053
+ if f"{prefix}_video_aspect_ratio" not in st.session_state:
1054
+ st.session_state[f"{prefix}_video_aspect_ratio"] = default_aspect_ratio
1055
+ settings["video_aspect_ratio"] = st.selectbox("Proporção do vídeo", aspect_options, index=aspect_options.index(st.session_state[f"{prefix}_video_aspect_ratio"]) if st.session_state[f"{prefix}_video_aspect_ratio"] in aspect_options else 0, key=f"{prefix}_video_aspect_ratio")
1052
1056
  settings["maximum_clip_duration"] = st.selectbox("Maximum Clip Duration (seconds)", [3, 5, 8, 10, 15], key=f"{prefix}_maximum_clip_duration")
1053
1057
  settings["videos_per_run"] = st.selectbox("Videos per Run", list(range(1, 11)), key=f"{prefix}_videos_per_run")
1054
1058
  settings["video_encoder"] = st.selectbox("Video Encoder", VIDEO_ENCODER_OPTIONS, key=f"{prefix}_video_encoder")
@@ -1840,6 +1844,87 @@ def render_tiktok_prompt_masters():
1840
1844
  st.error(str(exc))
1841
1845
 
1842
1846
 
1847
+ def _tiktok_channel_records() -> list[dict[str, Any]]:
1848
+ return [channel for channel in read_json("channels.json", []) if isinstance(channel, dict) and channel.get("platform") == "tiktok"]
1849
+
1850
+
1851
+ def _tiktok_prompt_options() -> tuple[list[str], dict[str, str]]:
1852
+ files = list_prompt_master_files()
1853
+ labels = {path.name: get_display_name("prompt_masters", path, path.stem) for path in files}
1854
+ return [""] + [path.name for path in files], {"": "Sem Prompt Master padrão", **labels}
1855
+
1856
+
1857
+ def render_tiktok_channels():
1858
+ st.title("Canais Tiktok")
1859
+ st.caption("Canais de Shorts em formato Portrait 9:16. A pesquisa usa exclusivamente a página pública do TikTok, sem API.")
1860
+ settings = read_json("settings.json", {})
1861
+ channels = _tiktok_channel_records()
1862
+ import_tab, spreadsheet_tab, manual_tab = render_localized_tabs(["Importar do Tiktok", "Canais em lote Planilha", "Cadastro manual"])
1863
+ prompt_ids, prompt_labels = _tiktok_prompt_options()
1864
+ with import_tab:
1865
+ source = st.text_input("URL pública ou @handle", placeholder="https://www.tiktok.com/@conta", key="tiktok_channel_source")
1866
+ if st.button("Buscar no Tiktok", type="primary", use_container_width=True, key="tiktok_channel_lookup"):
1867
+ result = fetch_public_tiktok_profile(source)
1868
+ st.session_state["tiktok_channel_import"] = {"ok": result.ok, "message": result.message, "data": result.data}
1869
+ imported_state = st.session_state.get("tiktok_channel_import", {})
1870
+ if imported_state.get("message"):
1871
+ (st.success if imported_state.get("ok") else st.warning)(imported_state["message"])
1872
+ imported = imported_state.get("data") if imported_state.get("ok") else {}
1873
+ if imported:
1874
+ with st.form("tiktok_channel_import_form"):
1875
+ name = st.text_input("Nome canal", value=str(imported.get("name") or imported.get("username") or ""))
1876
+ url = st.text_input("URL canal", value=str(imported.get("public_url") or imported.get("url") or ""))
1877
+ handle = st.text_input("Handle canal", value=str(imported.get("handle") or ""))
1878
+ language = st.selectbox("Idioma", list(LANGUAGE_CODES), format_func=language_label, key="tiktok_import_language")
1879
+ niche = st.text_input("Nicho", key="tiktok_import_niche")
1880
+ prompt = st.selectbox("Prompt Master padrão", prompt_ids, format_func=lambda item: prompt_labels.get(item, item), key="tiktok_import_prompt")
1881
+ description = st.text_area("Descrição", value=str(imported.get("bio") or ""), key="tiktok_import_description")
1882
+ if st.form_submit_button("Cadastrar canal TikTok", type="primary"):
1883
+ channel = create_channel(name.strip(), url.strip(), {"platform": "tiktok", "handle": handle.strip(), "language": language, "niche": niche.strip(), "description": description.strip(), "default_prompt_master": prompt, "prompt_master": prompt, "style_wide": "portrait", "video_aspect_ratio": "Portrait 9:16", "metrics_source": "tiktok_public_page", "subscriber_count": imported.get("subscriber_count"), "video_count": imported.get("video_count")})
1884
+ update_channel(channel["id"], {"platform": "tiktok", "default_prompt_master": prompt, "prompt_master": prompt})
1885
+ st.session_state.pop("tiktok_channel_import", None)
1886
+ st.success(f"Canal {channel['name']} cadastrado.")
1887
+ st.rerun()
1888
+ with spreadsheet_tab:
1889
+ uploaded = st.file_uploader("Upload da planilha de canais TikTok", type=["xlsx", "xls"], key="tiktok_channel_spreadsheet")
1890
+ if uploaded and st.button("Ler e cadastrar planilha TikTok", type="primary", key="tiktok_read_sheet"):
1891
+ rows, warnings = parse_channel_workbook(uploaded.getvalue(), uploaded.name)
1892
+ for warning in warnings:
1893
+ st.warning(warning)
1894
+ created = 0
1895
+ for row in rows:
1896
+ candidate = {"name": row.get("name", ""), "handle": row.get("handle", ""), "url": row.get("url", "")}
1897
+ if find_duplicate_channel(candidate, channels):
1898
+ continue
1899
+ created_channel = create_channel(str(row.get("name") or row.get("handle") or "TikTok"), str(row.get("url") or ""), {"platform": "tiktok", "handle": row.get("handle", ""), "language": language_code(row.get("language") or "pt"), "niche": row.get("niche", ""), "description": row.get("description", ""), "default_prompt_master": row.get("prompt_master", ""), "prompt_master": row.get("prompt_master", ""), "style_wide": "portrait", "video_aspect_ratio": "Portrait 9:16", "automation_time": row.get("automation_time") or "00:00"})
1900
+ update_channel(created_channel["id"], {"platform": "tiktok"})
1901
+ created += 1
1902
+ st.success(f"{created} canal(is) TikTok cadastrado(s).")
1903
+ st.rerun()
1904
+ st.caption("A planilha usa os campos de URL, nome, handle, idioma, nicho, Prompt Master, descrição e horário.")
1905
+ with manual_tab:
1906
+ with st.form("tiktok_channel_manual_form"):
1907
+ url = st.text_input("URL canal", placeholder="https://www.tiktok.com/@conta", key="tiktok_manual_channel_url")
1908
+ name = st.text_input("Nome canal", key="tiktok_manual_channel_name")
1909
+ handle = st.text_input("Handle canal", key="tiktok_manual_channel_handle")
1910
+ language = st.selectbox("Idioma", list(LANGUAGE_CODES), format_func=language_label, key="tiktok_manual_channel_language")
1911
+ niche = st.text_input("Nicho", key="tiktok_manual_channel_niche")
1912
+ prompt = st.selectbox("Prompt Master padrão", prompt_ids, format_func=lambda item: prompt_labels.get(item, item), key="tiktok_manual_channel_prompt")
1913
+ description = st.text_area("Descrição", key="tiktok_manual_channel_description")
1914
+ if st.form_submit_button("Guardar canal manual", type="primary"):
1915
+ reference = normalize_tiktok_reference(url or handle)
1916
+ channel = create_channel(name.strip() or reference["username"], reference["url"], {"platform": "tiktok", "handle": reference["handle"], "language": language, "niche": niche.strip(), "description": description.strip(), "default_prompt_master": prompt, "prompt_master": prompt, "style_wide": "portrait", "video_aspect_ratio": "Portrait 9:16"})
1917
+ update_channel(channel["id"], {"platform": "tiktok"})
1918
+ st.success("Canal TikTok cadastrado.")
1919
+ st.rerun()
1920
+ st.divider()
1921
+ st.subheader(f"Canais TikTok cadastrados ({len(channels)})")
1922
+ if channels:
1923
+ st.dataframe([{k: c.get(v, "") for k, v in {"URL canal": "url", "Nome canal": "name", "Handle canal": "handle", "Idioma": "language", "Nicho": "niche", "Prompt Master": "default_prompt_master", "Proporção": "video_aspect_ratio", "Activo": "active", "Descrição": "description"}.items()} for c in channels], use_container_width=True, hide_index=True, height=360)
1924
+ else:
1925
+ st.info("Ainda não existem canais TikTok cadastrados.")
1926
+
1927
+
1843
1928
  def render_channels():
1844
1929
  st.title("Canais Youtube")
1845
1930
  st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
@@ -2575,25 +2660,29 @@ def render_video_from_draft() -> None:
2575
2660
  st.success(f"Tarefa criada a partir de {record.get('title') or 'Roteiro sem título'}: {tasks[0].get('id') if tasks else '—'}.")
2576
2661
 
2577
2662
 
2578
- def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "new_video"):
2663
+ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "new_video", *, channel_platform: str = "youtube", fixed_aspect_ratio: str | None = None):
2579
2664
  st.title(page_title)
2580
- tab_labels = ["Criar vídeo"] + (["Gerar de Rascunho"] if page_title == "Criação de Vídeos" else [])
2665
+ tab_labels = ["Criar vídeo"] + (["Gerar de Rascunho"] if page_title in {"Criação de Vídeos", "Criação de Shorts"} else [])
2581
2666
  tabs = render_localized_tabs(tab_labels)
2582
2667
  create_tab = tabs[0]
2583
2668
  draft_tab = tabs[1] if len(tabs) > 1 else None
2584
2669
  with create_tab:
2585
- all_channels = [c for c in read_json("channels.json", []) if isinstance(c, dict)]
2670
+ all_channels = [c for c in read_json("channels.json", []) if isinstance(c, dict) and ((channel_platform == "tiktok" and c.get("platform") == "tiktok") or (channel_platform != "tiktok" and c.get("platform", "youtube") != "tiktok"))]
2586
2671
  active_channels = [c for c in all_channels if c.get("active", True)]
2587
2672
  if not all_channels:
2588
2673
  st.warning("Cadastre pelo menos um canal antes de criar vídeos.")
2589
2674
  else:
2590
- mode_label = st.radio(
2591
- "Modo de criação",
2592
- ["Canal específico", "Lote no mesmo canal", "Lote geral"],
2593
- horizontal=True,
2594
- key=f"{prefix}_mode",
2595
- )
2596
- mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
2675
+ if fixed_aspect_ratio:
2676
+ mode = "single"
2677
+ st.info("Os Shorts são sempre criados para um canal TikTok específico e em formato Portrait 9:16.")
2678
+ else:
2679
+ mode_label = st.radio(
2680
+ "Modo de criação",
2681
+ ["Canal específico", "Lote no mesmo canal", "Lote geral"],
2682
+ horizontal=True,
2683
+ key=f"{prefix}_mode",
2684
+ )
2685
+ mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
2597
2686
  selected_one: dict[str, Any] | None = None
2598
2687
  legacy_language = st.session_state.get("video_language") or read_json("settings.json", {}).get("video_language")
2599
2688
  if str(legacy_language or "").strip().casefold() in {"music", "00 – apenas música de fundo (sem falas)", "00 - apenas música de fundo (sem falas)"}:
@@ -2644,6 +2733,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2644
2733
  generation_settings = render_video_generation_settings(
2645
2734
  prefix,
2646
2735
  current_language=str(st.session_state.get("video_language") or ""),
2736
+ default_aspect_ratio=fixed_aspect_ratio or "Landscape 16:9",
2647
2737
  save_draft_callback=lambda: _save_pipeline_draft_callback(
2648
2738
  prefix,
2649
2739
  "music" if page_title == "Criação de Músicas" else "video",
@@ -2666,6 +2756,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2666
2756
  prefix,
2667
2757
  current_language=str(st.session_state.get("video_language") or ""),
2668
2758
  channel=selected_one,
2759
+ default_aspect_ratio=fixed_aspect_ratio or "Landscape 16:9",
2669
2760
  generate_content_callback=lambda: _generate_video_content_callback(
2670
2761
  prefix,
2671
2762
  selected_one,
@@ -2684,6 +2775,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2684
2775
  generation_settings = render_video_generation_settings(
2685
2776
  prefix,
2686
2777
  current_language=str(st.session_state.get("video_language") or ""),
2778
+ default_aspect_ratio=fixed_aspect_ratio or "Landscape 16:9",
2687
2779
  save_draft_callback=lambda: _save_pipeline_draft_callback(
2688
2780
  prefix,
2689
2781
  "music" if page_title == "Criação de Músicas" else "video",
@@ -4445,6 +4537,31 @@ def render_thumbnails():
4445
4537
  st.error(str(exc))
4446
4538
 
4447
4539
 
4540
+ def render_tiktok_automation():
4541
+ st.title("Automação Tiktok")
4542
+ st.caption("Fila exclusiva de Shorts TikTok em Portrait 9:16, usando Prompt Master em vez de Blueprint.")
4543
+ channels = _tiktok_channel_records()
4544
+ prompt_ids, prompt_labels = _tiktok_prompt_options()
4545
+ if not channels:
4546
+ st.info("Cadastre primeiro um canal TikTok.")
4547
+ return
4548
+ for channel in channels:
4549
+ channel_id = str(channel["id"])
4550
+ with st.container(border=True):
4551
+ st.write(f"**{channel.get('name', 'Sem nome')}** · {channel.get('handle', '')}")
4552
+ enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"tiktok_automation_on_{channel_id}")
4553
+ schedule_time = st.text_input("Horário diário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"tiktok_automation_time_{channel_id}")
4554
+ prompt = st.selectbox("Prompt Master padrão", prompt_ids, index=prompt_ids.index(channel.get("default_prompt_master", "")) if channel.get("default_prompt_master", "") in prompt_ids else 0, format_func=lambda item: prompt_labels.get(item, item), key=f"tiktok_automation_prompt_{channel_id}")
4555
+ st.caption("Formato da fila: Portrait 9:16 · Pool de vídeo: TikTok")
4556
+ if st.button("Guardar automação TikTok", key=f"tiktok_automation_save_{channel_id}", type="primary"):
4557
+ if not valid_hhmm(schedule_time):
4558
+ st.error("Use o formato HH:MM, por exemplo 08:30.")
4559
+ else:
4560
+ update_channel(channel_id, {"automation_on": bool(enabled), "automation_time": schedule_time.strip(), "default_prompt_master": prompt, "prompt_master": prompt, "platform": "tiktok", "video_aspect_ratio": "Portrait 9:16"})
4561
+ st.success("Automação TikTok guardada.")
4562
+ st.rerun()
4563
+
4564
+
4448
4565
  def render_automation():
4449
4566
  st.title("Automação Youtube")
4450
4567
  st.caption("Agendamento diário da geração por canal. O worker verifica o relógio local do computador e coloca os lotes agendados na fila.")
@@ -4459,7 +4576,7 @@ def render_automation():
4459
4576
  st.caption(f"Última verificação do worker: {last_tick}")
4460
4577
  if worker_status.get("last_error"):
4461
4578
  st.error(f"Último erro do worker: {worker_status['last_error']}")
4462
- channels = read_json("channels.json", [])
4579
+ channels = [channel for channel in read_json("channels.json", []) if isinstance(channel, dict) and channel.get("platform", "youtube") != "tiktok"]
4463
4580
  if not channels:
4464
4581
  st.info("Nenhum canal cadastrado para configurar.")
4465
4582
  for channel in channels:
@@ -7409,6 +7526,7 @@ def render_pipeline():
7409
7526
  def main():
7410
7527
  pipeline_video_items = [
7411
7528
  ("Criação de Vídeos", ":material/add_circle:", "Criação de Vídeos"),
7529
+ ("Criação de Shorts", ":material/phone_android:", "Criação de Shorts"),
7412
7530
  ("Backlog Vídeos", ":material/video_library:", "Backlog Vídeos"),
7413
7531
  ("Roteiros", ":material/article:", "Roteiros"),
7414
7532
  ("Thumbnails", ":material/image:", "Thumbnails"),
@@ -7416,6 +7534,7 @@ def main():
7416
7534
  ]
7417
7535
  channel_profile_items = [
7418
7536
  ("Canais YouTube", ":material/ondemand_video:", "Canais YouTube"),
7537
+ ("Canais Tiktok", ":material/music_video:", "Canais Tiktok"),
7419
7538
  ("Blueprints Youtube", ":material/library_books:", "Blueprints Youtube"),
7420
7539
  ("Thumbnail Blueprints", ":material/image:", "Thumbnail Blueprints"),
7421
7540
  ("Brandings Youtube", ":material/brush:", "Brandings Youtube"),
@@ -7463,6 +7582,7 @@ def main():
7463
7582
  ]
7464
7583
  automation_items = [
7465
7584
  ("Automação Youtube", ":material/schedule:", "Automação Youtube"),
7585
+ ("Automação Tiktok", ":material/schedule:", "Automação Tiktok"),
7466
7586
  ]
7467
7587
  edition_items = [
7468
7588
  ("Limpador de Metadados", ":material/edit_note:", "Limpador de Metadados"),
@@ -7496,11 +7616,11 @@ def main():
7496
7616
  "Configurações": settings_items,
7497
7617
  }
7498
7618
  nav_paths = {
7499
- "Início": "/inicio", "Automação": "/automacao", "Automação Youtube": "/automacao/youtube",
7619
+ "Início": "/inicio", "Automação": "/automacao", "Automação Youtube": "/automacao/youtube", "Automação Tiktok": "/automacao/tiktok",
7500
7620
  "Niche Finder": "/niche-finder", "Niche Finder Kaggle": "/niche-finder/kaggle", "Niche Finder Apify": "/niche-finder/apify",
7501
- "Pipeline Vídeos": "/pipeline-videos", "Criação de Vídeos": "/pipeline-videos/criacao", "Backlog Vídeos": "/pipeline-videos/backlog", "Roteiros": "/pipeline-videos/roteiros", "Thumbnails": "/pipeline-videos/thumbnails", "Upload": "/pipeline-videos/upload",
7621
+ "Pipeline Vídeos": "/pipeline-videos", "Criação de Vídeos": "/pipeline-videos/criacao", "Criação de Shorts": "/pipeline-videos/shorts", "Backlog Vídeos": "/pipeline-videos/backlog", "Roteiros": "/pipeline-videos/roteiros", "Thumbnails": "/pipeline-videos/thumbnails", "Upload": "/pipeline-videos/upload",
7502
7622
  "Pipeline Música": "/pipeline-musica", "Criação de Músicas": "/pipeline-musica/criacao", "Music Backlog": "/pipeline-musica/backlog", "Vozes Personalizadas": "/pipeline-musica/vozes-personalizadas", "Upload Música": "/pipeline-musica/upload",
7503
- "Canais/Perfis (Vídeos)": "/canais-perfis-videos", "Canais YouTube": "/canais-perfis-videos/canais-youtube", "Blueprints Youtube": "/canais-perfis-videos/blueprints-youtube", "Thumbnail Blueprints": "/canais-perfis-videos/thumbnail-blueprints", "Brandings Youtube": "/canais-perfis-videos/brandings-youtube", "Contas TikTok": "/canais-perfis-videos/contas-tiktok", "Prompt Masters": "/canais-perfis-videos/prompt-masters", "Facebook Pages": "/canais-perfis-videos/facebook-pages",
7623
+ "Canais/Perfis (Vídeos)": "/canais-perfis-videos", "Canais YouTube": "/canais-perfis-videos/canais-youtube", "Canais Tiktok": "/canais-perfis-videos/canais-tiktok", "Blueprints Youtube": "/canais-perfis-videos/blueprints-youtube", "Thumbnail Blueprints": "/canais-perfis-videos/thumbnail-blueprints", "Brandings Youtube": "/canais-perfis-videos/brandings-youtube", "Contas TikTok": "/canais-perfis-videos/contas-tiktok", "Prompt Masters": "/canais-perfis-videos/prompt-masters", "Facebook Pages": "/canais-perfis-videos/facebook-pages",
7504
7624
  "AI Influencers": "/ai-influencers", "Personagens": "/ai-influencers/personagens", "Geração de Conteúdo IA": "/ai-influencers/geracao-conteudo", "Motion Control": "/ai-influencers/motion-control", "UGC Products": "/ai-influencers/ugc-products", "Redes Sociais": "/ai-influencers/redes-sociais",
7505
7625
  "Edição": "/edicao", "Limpador de Metadados": "/edicao/limpador-metadados", "Cortes": "/edicao/cortes", "Editor Python": "/edicao/editor-python", "Download Mídia": "/edicao/download-midia",
7506
7626
  "Growth": "/growth", "Analista Growth Youtube": "/growth/youtube", "Analista Growth Tiktok": "/growth/tiktok", "Analista Growth Instagram": "/growth/instagram", "Analista Facebook Pages": "/growth/facebook-pages", "Analista Bilibili": "/growth/bilibili",
@@ -7576,6 +7696,7 @@ def main():
7576
7696
  "Início": render_dashboard,
7577
7697
  "Pipeline Vídeos": render_pipeline,
7578
7698
  "Criação de Vídeos": render_new_video,
7699
+ "Criação de Shorts": lambda: render_new_video("Criação de Shorts", "new_shorts", channel_platform="tiktok", fixed_aspect_ratio="Portrait 9:16"),
7579
7700
  "Backlog Vídeos": render_videos,
7580
7701
  "Criação de Músicas": render_music_creation,
7581
7702
  "Music Backlog": render_music_backlog,
@@ -7589,9 +7710,11 @@ def main():
7589
7710
  "Brandings Youtube": render_youtube_brandings,
7590
7711
  "Prompt Masters": render_tiktok_prompt_masters,
7591
7712
  "Canais YouTube": render_channels,
7713
+ "Canais Tiktok": render_tiktok_channels,
7592
7714
  "Contas TikTok": render_tiktok_accounts,
7593
7715
  "Facebook Pages": lambda: render_edit_placeholder("Facebook Pages", ""),
7594
7716
  "Automação Youtube": render_automation,
7717
+ "Automação Tiktok": render_tiktok_automation,
7595
7718
  "Niche Finder Kaggle": render_niche_finder,
7596
7719
  "Tutorial Kaggle": lambda: render_niche_tutorial("kaggle"),
7597
7720
  "Niche Finder Apify": render_niche_finder_apify,
@@ -246,7 +246,9 @@ def _create_channel_batch(channel: dict[str, Any], when: datetime) -> dict[str,
246
246
  topic, payload = _pending_payload(channel)
247
247
  options = {
248
248
  "language": payload.get("language") or channel.get("language") or "pt",
249
- "format": "wide",
249
+ "format": "portrait" if channel.get("platform") == "tiktok" else "wide",
250
+ "platform": channel.get("platform", "youtube"),
251
+ "queue": "tiktok_shorts" if channel.get("platform") == "tiktok" else "youtube",
250
252
  "style_wide": style_wide,
251
253
  "style_ia": channel.get("style_ia") or "",
252
254
  "material_source": payload.get("material_source") or "",
@@ -169,11 +169,12 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
169
169
  "creation_mode": batch.get("mode", "single"),
170
170
  "channel_id": channel_id,
171
171
  "channel_name": channel.get("name", "Canal"),
172
+ "platform": channel.get("platform", "youtube"),
172
173
  "topic": topic,
173
174
  "title": title,
174
175
  "topic_source": topic_source,
175
176
  "language": payload.get("language", options.get("language", channel.get("language", "Português"))),
176
- "format": payload.get("format", options.get("format", "wide")),
177
+ "format": payload.get("format", options.get("format", "portrait" if channel.get("platform") == "tiktok" else "wide")),
177
178
  "style_wide": payload.get("style_wide", options.get("style_wide", channel.get("style_wide", "pexels"))),
178
179
  "material_source": payload.get("material_source", options.get("material_source", channel.get("material_source", ""))),
179
180
  "style_ia": payload.get("style_ia", options.get("style_ia", "")),
@@ -44,6 +44,10 @@ from .thumbnail_generation import _compose_thumbnail_prompt, generate_thumbnail_
44
44
  class MediaGenerationError(RuntimeError):
45
45
  """Raised when an image/video adapter cannot produce a usable artifact."""
46
46
 
47
+ def __init__(self, message: str, *, provider_errors: list[str] | None = None) -> None:
48
+ super().__init__(message)
49
+ self.provider_errors = list(provider_errors or [])
50
+
47
51
 
48
52
  AGNES_IMAGE_MODEL = "agnes-image-2.1-flash"
49
53
  AGNES_IMAGE_TIMEOUT_SECONDS = 120
@@ -374,7 +378,11 @@ def generate_image_from_pool(
374
378
  errors.append(f"{card.get('provider')}: {str(exc)[:180]}")
375
379
  if not _is_retryable_media_error(exc):
376
380
  raise
377
- raise MediaGenerationError("Todos os providers do pool de imagem falharam: " + " | ".join(errors))
381
+ details = "\n".join(f"- {item}" for item in errors) if errors else "- Nenhum detalhe foi devolvido pelos providers."
382
+ raise MediaGenerationError(
383
+ "Todos os providers do pool de imagem falharam. Tentativas realizadas:\n" + details,
384
+ provider_errors=errors,
385
+ )
378
386
 
379
387
 
380
388
  def _video_endpoint(card: Mapping[str, Any]) -> str:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.41",
3
+ "version": "0.4.43",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",