@danhachuel/thunderbolt 0.3.61 → 0.3.62

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.
Files changed (40) hide show
  1. package/MANUAL-INSTALACAO.md +19 -5
  2. package/README.md +8 -2
  3. package/app/main.py +24 -5
  4. package/hermes_ui/automation_worker.py +6 -1
  5. package/hermes_ui/domain.py +1 -0
  6. package/hermes_ui/media_generation.py +5 -1
  7. package/hermes_ui/pipeline_worker.py +220 -13
  8. package/package.json +1 -3
  9. package/seed/skills/mpt_agent.py +13 -3
  10. package/storage/blueprints/importados/demo-content-hermes.json +0 -23
  11. package/storage/tiktok/prompts_master/2Dstickman.md +0 -193
  12. package/storage/tiktok/prompts_master/3Dskeletoncharacter.md +0 -1
  13. package/storage/tiktok/prompts_master/ANIMALMOUNTEDCAMERA.md +0 -299
  14. package/storage/tiktok/prompts_master/ASMR.md +0 -74
  15. package/storage/tiktok/prompts_master/ASMRDEBOCACOMDOCES.md +0 -130
  16. package/storage/tiktok/prompts_master/AnimalX-Ray.md +0 -136
  17. package/storage/tiktok/prompts_master/CAREVOLUTION.md +0 -176
  18. package/storage/tiktok/prompts_master/CINEMATICBUNKERSURVIVAL.md +0 -180
  19. package/storage/tiktok/prompts_master/CINEMATICSLUMTRANSFORMATIONENGINE.md +0 -212
  20. package/storage/tiktok/prompts_master/CINEMATOGR/303/201FICOS3D.md +0 -207
  21. package/storage/tiktok/prompts_master/CartoonNostalgia.md +0 -131
  22. package/storage/tiktok/prompts_master/EPOXYFLOORTRANSFORMATION.md +0 -254
  23. package/storage/tiktok/prompts_master/Edgar'sHouseofTerrorsChooseYourStory.md +0 -134
  24. package/storage/tiktok/prompts_master/FOODBABYFACECHARACTER.md +0 -141
  25. package/storage/tiktok/prompts_master/FuitNovels.md +0 -166
  26. package/storage/tiktok/prompts_master/KIDSSTORYCREATOR(PROMODE).md +0 -235
  27. package/storage/tiktok/prompts_master/MatchstickPrompt.md +0 -86
  28. package/storage/tiktok/prompts_master/PERSONAGENSABANDONADOS.md +0 -208
  29. package/storage/tiktok/prompts_master/Pixar-styleanimated.md +0 -193
  30. package/storage/tiktok/prompts_master/RESTORATIONTIMELAPSE.md +0 -262
  31. package/storage/tiktok/prompts_master/RUSTREMOVAL.md +0 -275
  32. package/storage/tiktok/prompts_master/ReceitasIndianas.md +0 -60
  33. package/storage/tiktok/prompts_master/SkyscraperMonster.md +0 -140
  34. package/storage/tiktok/prompts_master/TIMETRAVELVLOG.md +0 -264
  35. package/storage/tiktok/prompts_master/Tarot.md +0 -58
  36. package/storage/tiktok/prompts_master/UFOs(OVNIs).md +0 -63
  37. package/storage/tiktok/prompts_master/VisualCraftSequenceGenerator.md +0 -284
  38. package/storage/tiktok/prompts_master/craftingStorytellingAI.md +0 -79
  39. package/storage/tiktok/prompts_master/hist/303/263riasinfantishoradedormir.md +0 -71
  40. package/storage/tiktok/prompts_master/viralTikTokdance.md +0 -77
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.3.61
5
+ > **Versão deste manual:** 0.3.62
6
6
  > **Pacote npm:** `@danhachuel/thunderbolt`
7
7
  > **Porta padrão da UI:** `localhost:3030`
8
8
  > **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
@@ -41,6 +41,20 @@ A geração guarda prompt, caption, provider, modelo, plataforma, estado e camin
41
41
 
42
42
  O runtime do Thunderbolt adapta o comportamento dos workflows do episódio 35, mas não instala nem executa n8n. Formulários, Data Tables, Switch, Wait e subworkflows são substituídos por páginas Streamlit, repository Supabase/SQLite, adapters multimédia, worker, logs e notificações. Nunca coloque API keys nos JSONs dos workflows, em ficheiros versionados ou em mensagens de diagnóstico.
43
43
 
44
+ ## Pipeline de vídeo: fontes, composição e ordem
45
+
46
+ Em **Pipeline Vídeos > Criação de Vídeos** e **Automação Youtube**, seleccione a fonte no próprio vídeo ou na automação. **Pexels/Pixabay** é a rota stock e usa a infraestrutura do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo): pesquisa por keywords, filtros de proporção e duração, cache e deduplicação de clips, descarga de materiais e composição MoviePy/FFmpeg com concatenação, transições, narração, legendas e música de fundo. A fonte escolhida é passada explicitamente ao processo (`pexels` ou `pixabay`) e as listas de API keys são sincronizadas no `config.toml`, permitindo a rotação configurada.
47
+
48
+ A execução segue sempre **Tema → Script → Título → Keywords (opcional) → Vídeo → Prompt Thumbnail (JSON) → Thumbnail (imagem) → Upload**. Keywords são opcionais: se a geração por LLM falhar, é usado um fallback determinístico para não bloquear a etapa de vídeo. O worker guarda o MP4 antes de iniciar prompt ou imagem da thumbnail; assim, uma falha de quota na imagem não elimina um vídeo concluído.
49
+
50
+ | Opção visível | Rota executada | Resultado esperado |
51
+ |---|---|---|
52
+ | Pexels/Pixabay | MoneyPrinterTurbo stock com a fonte efectiva e as chaves correspondentes | MP4 composto localmente |
53
+ | Full IA | Pool de vídeo limitado a FAL AI, KIE AI e Agnes AI | MP4 gerado pelo provider activo |
54
+ | Apenas Música | Áudio local/Suno já preparado | Ficheiro musical pronto para um upload de música; não gera MP4, thumbnail nem upload YouTube |
55
+
56
+ Antes de iniciar a rota stock, confirme em **Configurações > Configuração API > Fontes de materiais** que existe pelo menos uma API key para a fonte efectiva. Sem uma key Pexels/Pixabay, o erro é apresentado como configuração da fonte, em vez de uma falha genérica de vídeo. **Google Lyria não é apresentado como implementado nesta release**; a rota Apenas Música aceita áudio local e a integração Suno existente.
57
+
44
58
  ## 2. Pré-requisitos
45
59
 
46
60
  Instale os seguintes componentes antes de executar o instalador:
@@ -114,13 +128,13 @@ Execute:
114
128
  Windows PowerShell ou MobaXterm:
115
129
 
116
130
  ```powershell
117
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.61 install
131
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.62 install
118
132
  ```
119
133
 
120
134
  Linux/macOS:
121
135
 
122
136
  ```bash
123
- npx --yes --prefer-online @danhachuel/thunderbolt@0.3.61 install
137
+ npx --yes --prefer-online @danhachuel/thunderbolt@0.3.62 install
124
138
  ```
125
139
 
126
140
  A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
@@ -128,7 +142,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
128
142
  Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
129
143
 
130
144
  ```powershell
131
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.61 install --purge-data
145
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.62 install --purge-data
132
146
  ```
133
147
 
134
148
  O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
@@ -542,7 +556,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
542
556
 
543
557
  Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
544
558
 
545
- As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.61 install`; o instalador detecta e reutiliza o que já estiver válido.
559
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.62 install`; o instalador detecta e reutiliza o que já estiver válido.
546
560
 
547
561
  ### Niche Finder Apify
548
562
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Consulte o [Manual completo de instalação](MANUAL-INSTALACAO.md) antes do primeiro teste local.
4
4
 
5
- Thunderbolt — UI web local da Fase 3, baseada no fluxo Streamlit do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). A aplicação organiza canais, blueprints, lotes de vídeos, filas, artefactos e upload em armazenamento local JSON.
5
+ Thunderbolt — UI web local da Fase 3, baseada no fluxo Streamlit do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). A aplicação organiza canais, blueprints, lotes de vídeos, filas, artefactos e upload em armazenamento local JSON. O pacote npm distribui apenas os seeds versionados em `seed/`; não inclui `storage/` persistente, pelo que Blueprints, personagens, configurações e artefactos locais não são enviados durante uma actualização.
6
6
 
7
7
  ## Estado actual
8
8
 
@@ -38,6 +38,12 @@ A página **AI Influencers > Geração de Conteúdo IA** contém as subabas **Im
38
38
 
39
39
  A migração é baseada no workflow público [AI Agents A-Z — episódio 35](https://github.com/gyoridavid/ai_agents_az/tree/main/episode_35), mas o runtime do Thunderbolt não instala nem executa n8n. Os formulários, Data Tables, waits e subworkflows são substituídos pela UI Streamlit, pelo repositório dual Supabase/SQLite, pelos adapters multimédia e pelos logs/notificações locais. As API keys são sempre configuradas localmente e não devem ser colocadas nos JSONs do workflow ou no GitHub.
40
40
 
41
+ ## Pipeline de vídeo — fontes e ordem de execução
42
+
43
+ Em **Pipeline Vídeos > Criação de Vídeos** e **Automação Youtube**, a opção **Pexels/Pixabay** usa a rota stock do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo): as keywords do roteiro são encaminhadas para a pesquisa da fonte seleccionada, os clips são descarregados e reutilizados localmente, e o motor faz a composição com MoviePy/FFmpeg, respeitando proporção, duração máxima, concatenação, transições, correspondência visual ao roteiro, narração, legendas e música de fundo. As API keys de Pexels e Pixabay são exportadas para o `config.toml` do motor e a fonte efectiva é encaminhada por tarefa, sem depender apenas da fonte global guardada nas configurações.
44
+
45
+ A ordem persistida da criação é **Tema → Script → Título → Keywords opcional → Vídeo → Prompt Thumbnail em JSON → Thumbnail → Upload**. O vídeo é materializado antes do prompt e da imagem da thumbnail; uma falha posterior de thumbnail não invalida um MP4 já pronto. **Full IA** é uma rota separada e usa exclusivamente os cartões activos FAL AI, KIE AI e Agnes AI do pool de vídeo. **Apenas Música** não chama a pipeline de vídeo nem tenta gerar thumbnail: reutiliza o áudio local/Suno já descarregado e deixa-o pronto para a integração de upload musical.
46
+
41
47
  ## Upload-Post — publicação para múltiplas plataformas
42
48
 
43
49
  A página **Upload** contém quatro subabas: **Upload convencional**, **Upload directo**, **Postiz** e **Upload-Post**. A quarta alternativa usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para enviar um vídeo local para uma ou mais plataformas ligadas ao perfil configurado. Seleccione as plataformas na própria subaba, confirme o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
@@ -79,7 +85,7 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
79
85
 
80
86
  A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` disponibiliza as variantes nomeadas **Dark** e **Light**, e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
81
87
 
82
- ## Navegação da UI 0.3.61
88
+ ## Navegação da UI 0.3.62
83
89
 
84
90
  A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Geração de Conteúdo IA**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. A página Geração de Conteúdo IA divide-se em **Imagens**, **Vídeos** e **Motion Control**; a subaba Motion Control permanece vazia nesta release.
85
91
  **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Notificações**, **Logs** e **Configuração API**, nessa ordem. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
package/app/main.py CHANGED
@@ -97,6 +97,7 @@ AI_STYLE_OPTIONS = [
97
97
  ]
98
98
 
99
99
  WIDE_STYLE_OPTIONS = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
100
+ MATERIAL_SOURCE_OPTIONS = ["Pexels", "Pixabay"]
100
101
 
101
102
  VIDEO_LANGUAGE_OPTIONS = [
102
103
  "00 – Apenas Música de Fundo (Sem Falas)",
@@ -993,6 +994,18 @@ def render_video_generation_settings(
993
994
  video_cols = st.columns(2)
994
995
  with video_cols[0]:
995
996
  settings["video_source"] = st.selectbox("Video Source", WIDE_STYLE_OPTIONS, key=f"{prefix}_video_source")
997
+ if settings["video_source"] == "Pexels/Pixabay":
998
+ configured_material_source = selected_material_source(read_json("settings.json", {}))
999
+ material_source_labels = {"pexels": "Pexels", "pixabay": "Pixabay"}
1000
+ configured_material_label = material_source_labels.get(configured_material_source, "Pexels")
1001
+ settings["material_source"] = st.selectbox(
1002
+ "Stock Material Source",
1003
+ MATERIAL_SOURCE_OPTIONS,
1004
+ index=MATERIAL_SOURCE_OPTIONS.index(configured_material_label),
1005
+ key=f"{prefix}_material_source",
1006
+ )
1007
+ else:
1008
+ settings["material_source"] = ""
996
1009
  if settings["video_source"] == "full_ia":
997
1010
  settings["style_ia"] = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key=f"{prefix}_style_ia")
998
1011
  else:
@@ -2037,6 +2050,7 @@ def _create_video_task_from_saved_script(record: dict[str, Any], channel: dict[s
2037
2050
  "format": settings.get("video_format", "wide"),
2038
2051
  "style_wide": style,
2039
2052
  "style_ia": settings.get("style_ia", ""),
2053
+ "material_source": settings.get("material_source", "") if style == "pexels" else "",
2040
2054
  "music_mode": style == "music",
2041
2055
  "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"),
2042
2056
  "voice": str(settings.get("voice") or channel.get("default_voice") or channel.get("voice") or ""),
@@ -2266,6 +2280,12 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2266
2280
  ),
2267
2281
  )
2268
2282
  wide_style_label = generation_settings["video_source"]
2283
+ style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
2284
+ material_source = (
2285
+ {"Pexels": "pexels", "Pixabay": "pixabay"}.get(str(generation_settings.get("material_source") or ""), "")
2286
+ if style == "pexels"
2287
+ else ""
2288
+ )
2269
2289
  style_ia = generation_settings.get("style_ia", "")
2270
2290
  music_path = ""
2271
2291
  music_source = ""
@@ -2469,7 +2489,6 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2469
2489
  submitted = st.form_submit_button("Criar tarefas", type="primary")
2470
2490
  if submitted:
2471
2491
  save_video_language(language)
2472
- style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
2473
2492
  if style == "music" and not music_path:
2474
2493
  st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
2475
2494
  st.stop()
@@ -2517,8 +2536,8 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2517
2536
  st.session_state[f"{prefix}_general_topics"] = {cid: {"topic": payload["topic"], "topic_source": payload.get("topic_source", "llm")} for cid, payload in payloads.items()}
2518
2537
  if len(payloads) == len(selected):
2519
2538
  batch_topic = "Lote geral — um vídeo independente por canal"
2520
- 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, "generation_settings": generation_settings} for cid, payload in payloads.items()}
2521
- 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, "generation_settings": generation_settings, "topic_source": "llm", "channel_payloads": channel_payloads})
2539
+ channel_payloads = {cid: {**payload, "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "material_source": material_source, "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, "generation_settings": generation_settings} for cid, payload in payloads.items()}
2540
+ batch = create_batch("general", selected, batch_topic, 1, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "material_source": material_source, "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, "generation_settings": generation_settings, "topic_source": "llm", "channel_payloads": channel_payloads})
2522
2541
  tasks = create_tasks_for_batch(batch)
2523
2542
  st.success(f"Lote geral {batch['id']} criado com {len(tasks)} tarefas independentes, uma por canal.")
2524
2543
  else:
@@ -2542,8 +2561,8 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2542
2561
  except CreativeGenerationError as exc:
2543
2562
  st.warning(f"Título/keywords automáticos pendentes: {exc} A tarefa será criada com o tópico como título; o prompt e a imagem da thumbnail serão gerados depois do vídeo.")
2544
2563
  payload = {"topic": topic_value, "title": topic_value, "topic_source": "manual", "thumbnail_status": "pending_provider", "thumbnail_variants": [], "thumbnail_variant": {}, "thumbnail_prompt": "", "thumbnail_text": ""}
2545
- payload.update({"topic": topic_value, "topic_source": payload.get("topic_source") or _video_topic_source(topic_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, "generation_settings": generation_settings})
2546
- 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, "generation_settings": generation_settings, "topic_source": payload.get("topic_source", "manual"), "channel_payloads": {selected[0]: payload}})
2564
+ payload.update({"topic": topic_value, "topic_source": payload.get("topic_source") or _video_topic_source(topic_value), "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "material_source": material_source, "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, "generation_settings": generation_settings})
2565
+ batch = create_batch(mode, selected, topic_value, quantity_value, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "material_source": material_source, "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, "generation_settings": generation_settings, "topic_source": payload.get("topic_source", "manual"), "channel_payloads": {selected[0]: payload}})
2547
2566
  tasks = create_tasks_for_batch(batch)
2548
2567
  st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra {ui_text('Backlog Vídeos', current_ui_language())} para acompanhar.")
2549
2568
 
@@ -10,6 +10,7 @@ from typing import Any
10
10
  from . import storage
11
11
  from .creative_generation import generate_title_and_keywords, generate_topic_for_channel
12
12
  from .domain import create_batch, create_tasks_for_batch
13
+ from .material_sources import selected_material_source
13
14
  from .notifications import record_notification
14
15
 
15
16
  WORKER_STATE_FILE = "automation_worker.json"
@@ -156,6 +157,8 @@ def _creative_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
156
157
  blueprint,
157
158
  language=str(channel.get("language") or "Português"),
158
159
  )
160
+ style_wide = str(channel.get("style_wide") or "pexels").strip().casefold()
161
+ material_source = selected_material_source(settings) if style_wide in {"pexels", "pexels/pixabay", "stock"} else ""
159
162
  payload = {
160
163
  "topic": topic_package["topic"],
161
164
  "topic_source": "llm",
@@ -169,7 +172,8 @@ def _creative_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
169
172
  "blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
170
173
  "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
171
174
  "voice": str(channel.get("default_voice") or channel.get("voice") or ""),
172
- "generation_settings": {"video_keywords": editorial.get("keywords", [])},
175
+ "material_source": material_source,
176
+ "generation_settings": {"video_keywords": editorial.get("keywords", []), "material_source": material_source},
173
177
  "ai_generation": {"topic": topic_package, "editorial": editorial},
174
178
  }
175
179
  return topic_package["topic"], payload
@@ -204,6 +208,7 @@ def _create_channel_batch(channel: dict[str, Any], when: datetime) -> dict[str,
204
208
  "format": "wide",
205
209
  "style_wide": style_wide,
206
210
  "style_ia": channel.get("style_ia") or "",
211
+ "material_source": payload.get("material_source") or "",
207
212
  "music_mode": music_mode,
208
213
  "background_mode": "none" if music_mode else ("ai" if style_wide == "full_ia" else "stock"),
209
214
  "music_path": channel.get("music_path") or "",
@@ -160,6 +160,7 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
160
160
  "language": payload.get("language", options.get("language", channel.get("language", "Português"))),
161
161
  "format": payload.get("format", options.get("format", "wide")),
162
162
  "style_wide": payload.get("style_wide", options.get("style_wide", channel.get("style_wide", "pexels"))),
163
+ "material_source": payload.get("material_source", options.get("material_source", channel.get("material_source", ""))),
163
164
  "style_ia": payload.get("style_ia", options.get("style_ia", "")),
164
165
  "music_mode": payload.get("music_mode", options.get("music_mode", False)),
165
166
  "music_path": payload.get("music_path", options.get("music_path", "")),
@@ -489,9 +489,13 @@ def generate_video_from_pool(
489
489
  *,
490
490
  image_url: str = "",
491
491
  output_path: Path | None = None,
492
+ allowed_providers: set[str] | None = None,
492
493
  ) -> Path:
493
- """Try eligible video cards in priority order, keeping image and video pools separate."""
494
+ """Try eligible video cards in priority order, optionally restricted to a route."""
494
495
  cards = media_cards_for_pool(settings, "video")
496
+ if allowed_providers is not None:
497
+ allowed = {str(provider).strip().lower() for provider in allowed_providers}
498
+ cards = [card for card in cards if str(card.get("provider") or "").strip().lower() in allowed]
495
499
  if not cards:
496
500
  raise MediaGenerationError("Não existem providers activos no pool de vídeo.")
497
501
  errors: list[str] = []
@@ -11,6 +11,7 @@ from datetime import datetime, timezone
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
+ from integrations.moneyprinter_config import sync_moneyprinter_config
14
15
  from integrations.upload_routing import upload_with_default_route
15
16
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_title_and_keywords, generate_thumbnail_prompt, generate_topic_for_channel
16
17
  from hermes_ui.script_documents import save_script_document
@@ -19,6 +20,7 @@ from hermes_ui.storage import STORAGE, ensure_storage, read_json, write_json
19
20
  from hermes_ui.llm_providers import active_llm_card, provider_definition
20
21
  from hermes_ui.media_generation import MediaGenerationError, _append_generation_constraints, generate_image_from_pool, generate_video_from_pool
21
22
  from hermes_ui.media_providers import media_cards_for_pool
23
+ from hermes_ui.material_sources import material_api_keys, selected_material_source
22
24
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
23
25
 
24
26
  PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
@@ -324,6 +326,156 @@ def _stop_process(process: subprocess.Popen[str]) -> None:
324
326
  process.wait()
325
327
 
326
328
 
329
+ def _normalise_video_route(task: dict[str, Any], settings: dict[str, Any]) -> str:
330
+ """Resolve the per-task source without conflating stock, AI and music routes."""
331
+ generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
332
+ if bool(task.get("music_mode")):
333
+ return "music"
334
+ explicit_source = str(
335
+ task.get("material_source")
336
+ or generation_settings.get("material_source")
337
+ or generation_settings.get("video_material_source")
338
+ or ""
339
+ ).strip().casefold()
340
+ if explicit_source in {"pexels", "pixabay", "local"}:
341
+ return explicit_source
342
+ raw = str(task.get("style_wide") or generation_settings.get("video_source") or "pexels").strip().casefold()
343
+ if raw in {"full_ia", "full ia", "full-ai", "ai", "ia"}:
344
+ return "full_ia"
345
+ if raw in {"music", "apenas música", "apenas musica", "only music"}:
346
+ return "music"
347
+ if raw in {"pixabay", "pixabay only"}:
348
+ return "pixabay"
349
+ if raw in {"pexels", "pexels/pixabay", "stock", "materials", "materiales"}:
350
+ configured = selected_material_source(settings)
351
+ return configured if configured in {"pexels", "pixabay"} else "pexels"
352
+ return raw if raw in {"pexels", "pixabay", "local"} else "pexels"
353
+
354
+
355
+ def _mpt_aspect_ratio(value: Any, format_value: Any = "") -> str:
356
+ raw = str(value or format_value or "").strip().casefold()
357
+ if raw in {"landscape 16:9", "wide", "16:9", "landscape"}:
358
+ return "16:9"
359
+ if raw in {"square 1:1", "square", "1:1"}:
360
+ return "1:1"
361
+ return "9:16"
362
+
363
+
364
+ def _mpt_concat_mode(value: Any) -> str:
365
+ raw = str(value or "").strip().casefold()
366
+ return "sequential" if raw in {"sequential", "sequential concatenation", "ordem sequencial"} else "random"
367
+
368
+
369
+ def _mpt_transition_mode(value: Any) -> str:
370
+ raw = str(value or "").strip().casefold()
371
+ return {"fade": "fade-in", "dissolve": "fade-out", "fade-in": "fade-in", "fade-out": "fade-out"}.get(raw, "none")
372
+
373
+
374
+ def _mpt_percent(value: Any, fallback: float = 1.0) -> str:
375
+ raw = str(value or "").strip().replace("%", "")
376
+ try:
377
+ return str(max(0.0, min(2.0, float(raw) / 100.0)))
378
+ except ValueError:
379
+ return str(fallback)
380
+
381
+
382
+ def _valid_audio_artifact(value: Any) -> Path | None:
383
+ path = _valid_artifact_path(value)
384
+ if path is None or path.suffix.lower() not in {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}:
385
+ return None
386
+ return path
387
+
388
+
389
+ def _mpt_rate(value: Any, fallback: float = 1.0) -> str:
390
+ raw = str(value or "").strip().casefold().replace("x", "")
391
+ try:
392
+ return str(max(0.1, float(raw)))
393
+ except ValueError:
394
+ return str(fallback)
395
+
396
+
397
+ def _mpt_video_language(value: Any) -> str:
398
+ """Convert common Thunderbolt labels to the language codes accepted by MPT."""
399
+ raw = str(value or "").strip()
400
+ folded = raw.casefold()
401
+ aliases = {
402
+ "português": "pt-BR", "portugues": "pt-BR", "português (brasil)": "pt-BR",
403
+ "portuguese": "pt-BR", "inglês": "en-US", "ingles": "en-US", "english": "en-US",
404
+ "espanhol": "es-ES", "español": "es-ES", "francês": "fr-FR", "frances": "fr-FR",
405
+ "alemão": "de-DE", "alemao": "de-DE", "italiano": "it-IT", "japonês": "ja-JP",
406
+ "japones": "ja-JP", "mandarim": "zh-CN", "chinês": "zh-CN", "chines": "zh-CN",
407
+ }
408
+ if folded in aliases:
409
+ return aliases[folded]
410
+ return raw if re.fullmatch(r"[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?", raw) else ""
411
+
412
+
413
+ def _moneyprinter_cli_args(task: dict[str, Any], route: str) -> list[str]:
414
+ """Build the explicit MPT CLI contract for the stock Pexels/Pixabay route."""
415
+ generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
416
+ args: list[str] = ["--video-source", route]
417
+ script = str(task.get("video_script") or generation_settings.get("video_script") or "").strip()
418
+ if script:
419
+ args.extend(["--video-script", script])
420
+ keywords = task.get("video_keywords") or generation_settings.get("video_keywords") or task.get("keywords") or task.get("tags")
421
+ if isinstance(keywords, str):
422
+ keywords = [part.strip() for part in re.split(r"[,;|\n]+", keywords) if part.strip()]
423
+ if isinstance(keywords, list):
424
+ terms = [str(item).strip() for item in keywords if str(item).strip()]
425
+ if terms:
426
+ args.extend(["--video-terms", ",".join(terms[:15])])
427
+
428
+ language = _mpt_video_language(task.get("language"))
429
+ if language:
430
+ args.extend(["--video-language", language])
431
+ format_value = str(task.get("format") or generation_settings.get("video_format") or "").strip().casefold()
432
+ if format_value in {"wide", "landscape", "16:9"}:
433
+ aspect_ratio = "16:9"
434
+ elif format_value in {"shorts", "portrait", "9:16"}:
435
+ aspect_ratio = "9:16"
436
+ else:
437
+ aspect_ratio = _mpt_aspect_ratio(generation_settings.get("video_aspect_ratio"), task.get("format"))
438
+ args.extend(["--video-aspect", aspect_ratio])
439
+ args.extend(["--video-concat-mode", _mpt_concat_mode(generation_settings.get("video_concatenation_mode"))])
440
+ args.extend(["--video-transition-mode", _mpt_transition_mode(generation_settings.get("video_transition_mode"))])
441
+ clip_duration = generation_settings.get("maximum_clip_duration")
442
+ if str(clip_duration or "").strip().isdigit() and int(clip_duration) > 0:
443
+ args.extend(["--video-clip-duration", str(int(clip_duration))])
444
+ if bool(generation_settings.get("match_visuals_to_script_order")):
445
+ args.append("--match-materials-to-script")
446
+
447
+ voice_mode = str(generation_settings.get("voiceover_mode") or "").strip().casefold()
448
+ voice = str(task.get("voice") or generation_settings.get("voice") or "").strip()
449
+ if voice_mode == "none" or voice_mode == "upload":
450
+ args.extend(["--voice-name", "no-voice"])
451
+ elif voice:
452
+ args.extend(["--voice-name", voice])
453
+ volume = generation_settings.get("voiceover_volume")
454
+ speed = generation_settings.get("voiceover_speed")
455
+ if volume is not None:
456
+ args.extend(["--voice-volume", _mpt_percent(volume)])
457
+ if speed is not None:
458
+ args.extend(["--voice-rate", _mpt_rate(speed)])
459
+
460
+ subtitles = generation_settings.get("enable_subtitles")
461
+ if subtitles is not None:
462
+ args.append("--subtitle-enabled" if bool(subtitles) else "--no-subtitle-enabled")
463
+ subtitle_position = str(generation_settings.get("subtitle_position") or "").strip().casefold()
464
+ if subtitle_position in {"top", "center", "bottom", "custom"}:
465
+ args.extend(["--subtitle-position", subtitle_position])
466
+ font_name = str(generation_settings.get("subtitle_font") or "").strip()
467
+ if font_name:
468
+ args.extend(["--font-name", font_name])
469
+ bgm_source = str(generation_settings.get("background_music_source") or "").strip().casefold()
470
+ bgm_type = {"sem música": "none", "sem musica": "none", "random background music": "random", "ficheiro existente": "custom"}.get(bgm_source)
471
+ if bgm_type:
472
+ args.extend(["--bgm-type", bgm_type])
473
+ bgm_volume = generation_settings.get("background_music_volume")
474
+ if bgm_volume is not None:
475
+ args.extend(["--bgm-volume", _mpt_percent(bgm_volume)])
476
+ return args
477
+
478
+
327
479
  def _run_video_helper(task: dict[str, Any]) -> Path:
328
480
  helper_dir = Path(__file__).resolve().parents[1] / "seed" / "skills"
329
481
  helper = helper_dir / "mpt_agent.py"
@@ -341,12 +493,27 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
341
493
  card = active_llm_card(settings)
342
494
  provider = str(card.get("provider") or "openai").strip()
343
495
  definition = provider_definition(provider)
496
+ route = _normalise_video_route(task, settings)
497
+ source_keys = material_api_keys(settings, route) if route in {"pexels", "pixabay"} else []
498
+ if route in {"pexels", "pixabay"} and not source_keys:
499
+ source_label = "Pexels" if route == "pexels" else "Pixabay"
500
+ raise PipelineError(
501
+ f"Configure pelo menos uma API key de {source_label} em Configurações > Configuração API > Fontes de materiais."
502
+ )
503
+ if configured_root:
504
+ try:
505
+ sync_moneyprinter_config(settings, str(configured_root))
506
+ except OSError as exc:
507
+ raise PipelineError(f"Não foi possível sincronizar a configuração do MoneyPrinterTurbo: {exc}") from exc
344
508
  env_values = {
345
509
  "MPT_LLM_PROVIDER": provider,
346
510
  "MPT_LLM_API_KEY": str(card.get("api_key") or "").strip(),
347
511
  "MPT_LLM_BASE_URL": str(card.get("base_url") or definition.default_base_url or "").strip(),
348
512
  "MPT_LLM_MODEL_NAME": str(card.get("model") or "").strip(),
349
- "MPT_PEXELS_API_KEY": str(settings.get("pexels_api_key") or "").strip(),
513
+ "MPT_PEXELS_API_KEY": source_keys[0] if route == "pexels" and source_keys else "",
514
+ "MPT_PEXELS_API_KEYS": json.dumps(source_keys, ensure_ascii=False) if route == "pexels" and source_keys else "",
515
+ "MPT_PIXABAY_API_KEY": source_keys[0] if route == "pixabay" and source_keys else "",
516
+ "MPT_PIXABAY_API_KEYS": json.dumps(source_keys, ensure_ascii=False) if route == "pixabay" and source_keys else "",
350
517
  }
351
518
  for key, value in env_values.items():
352
519
  if value:
@@ -356,6 +523,7 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
356
523
  command.extend(["--root", str(configured_root)])
357
524
  command.extend(["--subject", subject])
358
525
  generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
526
+ command.extend(_moneyprinter_cli_args(task, route))
359
527
  if str(generation_settings.get("voiceover_mode") or "").strip().casefold() == "upload":
360
528
  voiceover_file = Path(str(generation_settings.get("voiceover_file") or "").strip()).expanduser()
361
529
  if not str(voiceover_file) or not voiceover_file.is_file() or voiceover_file.stat().st_size <= 0:
@@ -516,8 +684,9 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
516
684
  channel = _channel_for_task(task)
517
685
  settings = _settings()
518
686
  blueprint = _blueprint_for_channel(channel)
687
+ route = _normalise_video_route(task, settings)
519
688
  topic = str(task.get("topic") or "").strip()
520
- if not topic or str(task.get("topic_source") or "") in {"auto", "llm_pending"}:
689
+ if route != "music" and (not topic or str(task.get("topic_source") or "") in {"auto", "llm_pending"}):
521
690
  _update(task_id, stage="topic", state="doing", progress=5, error=None)
522
691
  topic_result = generate_topic_for_channel(settings, channel, blueprint, user_context=str(task.get("topic_context") or ""))
523
692
  topic = str(topic_result.get("topic") or "").strip()
@@ -527,6 +696,32 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
527
696
 
528
697
  generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
529
698
  artifacts = dict(task.get("artifacts") or {})
699
+ if route == "music":
700
+ # Apenas Música não é uma tarefa de vídeo: reutiliza o áudio preparado e
701
+ # termina pronta para a integração de upload musical, sem chamar LLM,
702
+ # Pexels/Pixabay, MoviePy de vídeo, thumbnail ou upload de vídeo.
703
+ music_candidates = [
704
+ task.get("music_path"),
705
+ generation_settings.get("music_path"),
706
+ artifacts.get("music"),
707
+ ]
708
+ music_path = next((path for path in (_valid_audio_artifact(item) for item in music_candidates) if path is not None), None)
709
+ if music_path is None:
710
+ raise PipelineError("A fonte Apenas Música exige um ficheiro de áudio local válido ou uma música Suno já descarregada antes de criar a tarefa.")
711
+ artifacts["music"] = str(music_path)
712
+ _update(
713
+ task_id,
714
+ stage="upload",
715
+ state="done",
716
+ progress=100,
717
+ artifacts=artifacts,
718
+ music_ready=True,
719
+ video_ready=False,
720
+ thumbnail_status="not_applicable",
721
+ error=None,
722
+ )
723
+ return _task_by_id(task_id) or task
724
+
530
725
  script = _read_persisted_script(task, channel, blueprint, topic)
531
726
  if script is None:
532
727
  _update(task_id, stage="script", state="doing", progress=18, error=None)
@@ -618,18 +813,30 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
618
813
  existing_video = _valid_artifact_path(artifacts.get("video"))
619
814
  if existing_video is None:
620
815
  _update(task_id, stage="video", state="doing", progress=max(52, int(task.get("progress") or 0)), error=None)
621
- video_cards = media_cards_for_pool(settings, "video")
622
- if bool(settings.get("media_video_pool_enabled")) and video_cards:
623
- try:
624
- video_prompt = _append_generation_constraints(
625
- f"Título: {title}\n\nRoteiro:\n{str(script.get('content') or '')[:12000]}",
626
- kind="video",
816
+ try:
817
+ video_prompt = _append_generation_constraints(
818
+ f"Título: {title}\n\nRoteiro:\n{str(script.get('content') or '')[:12000]}",
819
+ kind="video",
820
+ )
821
+ if route == "full_ia":
822
+ video_path = generate_video_from_pool(
823
+ settings,
824
+ video_prompt,
825
+ allowed_providers={"fal_ai", "kie_ai", "agnes"},
627
826
  )
628
- video_path = generate_video_from_pool(settings, video_prompt)
629
- except MediaGenerationError as exc:
630
- raise PipelineError(f"Pool de vídeo externo: {exc}") from exc
631
- else:
632
- video_path = _run_video_helper({**task, "topic": topic, "title": title})
827
+ else:
828
+ video_path = _run_video_helper({
829
+ **task,
830
+ "topic": topic,
831
+ "title": title,
832
+ "video_script": str(script.get("content") or ""),
833
+ "video_keywords": keywords,
834
+ "style_wide": route,
835
+ })
836
+ except MediaGenerationError as exc:
837
+ if route == "full_ia":
838
+ raise PipelineError(f"Pool Full IA (FAL/KIE AI/Agnes AI): {exc}") from exc
839
+ raise PipelineError(f"Pipeline MoneyPrinterTurbo ({route}): {exc}") from exc
633
840
  current_after_video = _task_by_id(task_id) or {}
634
841
  artifacts = dict(current_after_video.get("artifacts") or artifacts)
635
842
  artifacts["video"] = str(video_path)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.61",
3
+ "version": "0.3.62",
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",
@@ -16,8 +16,6 @@
16
16
  "integrations/*.py",
17
17
  "integrations/data/*.json",
18
18
  "scripts/*.mjs",
19
- "storage/blueprints/**/*.json",
20
- "storage/tiktok/prompts_master/**/*.md",
21
19
  "seed/prompt_masters/**/*.md",
22
20
  "seed/blueprints/**/*.json",
23
21
  "seed/skills/*.md",
@@ -206,7 +206,14 @@ def apply_environment_config(config_path: Path) -> None:
206
206
  base_url = os.environ.get("MPT_LLM_BASE_URL", "").strip()
207
207
  model_name = os.environ.get("MPT_LLM_MODEL_NAME", "").strip()
208
208
  pexels_key = os.environ.get("MPT_PEXELS_API_KEY", "").strip()
209
- if not any((provider, llm_key, base_url, model_name, pexels_key)):
209
+ pixabay_key = os.environ.get("MPT_PIXABAY_API_KEY", "").strip()
210
+ pexels_keys = _parse_string_list(os.environ.get("MPT_PEXELS_API_KEYS", ""))
211
+ pixabay_keys = _parse_string_list(os.environ.get("MPT_PIXABAY_API_KEYS", ""))
212
+ if not pexels_keys and pexels_key:
213
+ pexels_keys = [pexels_key]
214
+ if not pixabay_keys and pixabay_key:
215
+ pixabay_keys = [pixabay_key]
216
+ if not any((provider, llm_key, base_url, model_name, pexels_keys, pixabay_keys)):
210
217
  return
211
218
 
212
219
  text = config_path.read_text(encoding="utf-8")
@@ -225,9 +232,12 @@ def apply_environment_config(config_path: Path) -> None:
225
232
  if model_name:
226
233
  text = _replace_config_value(text, f"{provider}_model_name", model_name)
227
234
  changes.append(f"{provider}_model_name")
228
- if pexels_key:
229
- text = _replace_config_value(text, "pexels_api_keys", [pexels_key])
235
+ if pexels_keys:
236
+ text = _replace_config_value(text, "pexels_api_keys", pexels_keys)
230
237
  changes.append("pexels_api_keys")
238
+ if pixabay_keys:
239
+ text = _replace_config_value(text, "pixabay_api_keys", pixabay_keys)
240
+ changes.append("pixabay_api_keys")
231
241
  config_path.write_text(text, encoding="utf-8")
232
242
  log("updated configuration fields: " + ", ".join(changes))
233
243
 
@@ -1,23 +0,0 @@
1
- {
2
- "id": "demo-content-hermes",
3
- "name": "Blueprint de demonstração",
4
- "channel_name": "Canal Hermes Demo",
5
- "language": "Português",
6
- "niche": "Conteúdo faceless educativo",
7
- "style": {
8
- "wide": "pexels",
9
- "shorts": "vertical"
10
- },
11
- "script": {
12
- "target_characters": "7000-9000",
13
- "hook_patterns": ["prediction-stakes", "before-after"],
14
- "humanization": true
15
- },
16
- "thumbnail": {
17
- "aspect_ratio": "16:9",
18
- "max_words": 4,
19
- "mobile_test": true
20
- },
21
- "source": "demo",
22
- "version": 1
23
- }