@danhachuel/thunderbolt 0.3.55 → 0.3.57

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.
@@ -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.55
5
+ > **Versão deste manual:** 0.3.57
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)
@@ -114,13 +114,13 @@ Execute:
114
114
  Windows PowerShell ou MobaXterm:
115
115
 
116
116
  ```powershell
117
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.55 install
117
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.57 install
118
118
  ```
119
119
 
120
120
  Linux/macOS:
121
121
 
122
122
  ```bash
123
- npx --yes --prefer-online @danhachuel/thunderbolt@0.3.55 install
123
+ npx --yes --prefer-online @danhachuel/thunderbolt@0.3.57 install
124
124
  ```
125
125
 
126
126
  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 +128,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
128
128
  Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
129
129
 
130
130
  ```powershell
131
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.55 install --purge-data
131
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.57 install --purge-data
132
132
  ```
133
133
 
134
134
  O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
@@ -453,7 +453,7 @@ Se o launcher ou o worker for encerrado abruptamente, uma tarefa `doing` sem act
453
453
 
454
454
  O **Backlog Vídeos** e a lista **Automação > Automação Youtube > Vídeos cadastrados** usam o mesmo catálogo completo de `storage/state/tasks.json`. Por isso, tarefas criadas manualmente e tarefas criadas pelo worker diário aparecem nos dois locais, sem filtragem implícita por origem. O filtro do Backlog mostra os estados conhecidos e inclui automaticamente estados adicionais encontrados nos dados.
455
455
 
456
- Nos dois cards, o bloco de estado apresenta o valor técnico, o rótulo legível, a barra de progresso e a mensagem de erro quando existir. O bloco **Formato** resolve nesta ordem `format`, `style_wide`, `style` e, por fim, `wide`, mantendo a apresentação de formatos como `wide`, `shorts`, `music` ou `full_ia` mesmo em tarefas antigas.
456
+ Nos dois cards, o bloco de estado apresenta o valor técnico, o rótulo legível, a barra de progresso e a mensagem de erro quando existir. O bloco **Formato** resolve nesta ordem `format`, `style_wide`, `style` e, por fim, `wide`, mantendo a apresentação de formatos como `wide`, `shorts`, `music` ou `full_ia` mesmo em tarefas antigas. Em **Automação Youtube > Vídeos cadastrados**, o botão **Start** retoma a partir dos artefactos persistidos: reutiliza roteiro, título/keywords, vídeo, prompt e thumbnail prontos e só executa novamente uma etapa cujo resultado não exista. O botão **Apagar** remove o card da fila depois de confirmação e preserva os ficheiros de artefactos; uma tarefa em execução deve ser parada antes da remoção.
457
457
 
458
458
  ### Upload Música — JewelMusic, Pushtunes e ytmusicapi
459
459
 
@@ -542,7 +542,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
542
542
 
543
543
  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
544
 
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.55 install`; o instalador detecta e reutiliza o que já estiver válido.
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.57 install`; o instalador detecta e reutiliza o que já estiver válido.
546
546
 
547
547
  ### Niche Finder Apify
548
548
 
@@ -580,7 +580,7 @@ O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`,
580
580
 
581
581
  A aba **Roteiros**, colocada entre **Criação de Músicas** e **Upload**, permite seleccionar um canal opcional, um Blueprint, **Roteiro de vídeo** ou **Letra de música**, idioma, tema e estrutura. O botão **Gerar com IA a partir do Blueprint** usa o provider LLM configurado e devolve um rascunho Markdown editável; o utilizador deve rever e clicar em **Guardar documento no storage**. Os ficheiros são guardados em `storage/scripts/` e o índice em `storage/state/scripts.json`; o caminho absoluto aparece no topo da página. Na subaba **Vídeos** de **Criação de Vídeos**, a aplicação mostra a frase `Os vídeos são guardados em <storage>/videos`, que identifica a pasta local dos vídeos.
582
582
 
583
- A aba **Automação Youtube**, dentro do menu expansível **Automação**, apresenta os cartões no layout compacto de duas linhas: na primeira ficam avatar, nome, handle, toggle **Automação ligada** e horário; na segunda ficam **Idioma Padrão**, **Nicho Padrão**, **Blueprint Padrão**, **Narrador/Voz Padrão** e o botão **Guardar**. Blueprint e Narrador/Voz continuam editáveis no card; Idioma e Nicho são mostrados a partir da configuração guardada no canal. A aba também guarda **Automação ligada** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais Youtube** e são copiados para novas tarefas. O launcher inicia o worker local, que consulta o relógio do computador, gera um briefing, título e pacote de thumbnail específicos com o provider LLM configurado, cria o lote agendado na fila e evita duplicar o mesmo canal no mesmo dia. Se a geração não puder ser executada, o erro fica registado e o placeholder antigo não é usado.
583
+ A aba **Automação Youtube**, dentro do menu expansível **Automação**, apresenta os cartões no layout compacto de duas linhas: na primeira ficam avatar, nome, handle, toggle **Automação ligada** e horário; na segunda ficam **Idioma Padrão**, **Nicho Padrão**, **Blueprint Padrão**, **Narrador/Voz Padrão** e o botão **Guardar**. Blueprint e Narrador/Voz continuam editáveis no card; Idioma e Nicho são mostrados a partir da configuração guardada no canal. A aba também guarda **Automação ligada** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais Youtube** e são copiados para novas tarefas. O launcher inicia o worker local, que consulta o relógio do computador, gera um briefing, título e pacote de thumbnail específicos com o provider LLM configurado, cria o lote agendado na fila e evita duplicar o mesmo canal no mesmo dia. Se a geração não puder ser executada, o erro fica registado e o placeholder antigo não é usado. Ao iniciar um vídeo cadastrado, o worker retoma o ponto persistido sem regenerar as etapas concluídas; se um roteiro não estiver guardado, ele é criado antes de continuar. O card inclui **Start**, **Stop** e **Apagar**, sendo a remoção protegida por confirmação.
584
584
 
585
585
  A área **Teste de vozes**, dentro de **Configurações > Configuração API**, é isolada da pipeline. O preview pode ser reproduzido e descarregado de `storage/voice_previews/`. Caminhos vazios, directórios, ficheiros vazios e previews sem permissões de leitura são ignorados com uma mensagem clara, sem traceback. Se uma instalação antiga mostrar que `edge-tts` está em falta, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o detector reinstala apenas a dependência ausente.
586
586
 
package/README.md CHANGED
@@ -79,7 +79,7 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
79
79
 
80
80
  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
81
 
82
- ## Navegação da UI 0.3.55
82
+ ## Navegação da UI 0.3.57
83
83
 
84
84
  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
85
  **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.
@@ -118,6 +118,8 @@ A página **Edição > Download Mídia** utiliza a API Python do [yt-dlp](https:
118
118
 
119
119
  Os dois cards mostram de forma consistente o estado técnico, o rótulo legível, a barra de progresso, a etapa e o formato do vídeo. São suportados estados como `to_do`, `doing`, `blocked`, `done`, `failed` e `cancelled`; formatos ausentes usam `format`, `style_wide` ou `wide` como fallback. Quando existe `artifacts.video`, o Backlog mostra que o vídeo está pronto e disponibiliza a descarga do MP4 mesmo que a thumbnail esteja pendente.
120
120
 
121
+ Em **Automação Youtube > Vídeos cadastrados**, o botão **Start** retoma a tarefa a partir dos artefactos persistidos. O worker reutiliza o roteiro, título/keywords, MP4, prompt de thumbnail e imagem já existentes; só volta a gerar uma etapa quando o respectivo resultado não está disponível, e repete o upload apenas quando ele ainda não foi concluído. O botão **Apagar** remove o vídeo da fila e de `tasks.json` após confirmação, preservando os ficheiros de artefactos; tarefas em execução devem ser paradas antes de serem removidas.
122
+
121
123
  ## Canais Youtube — edição por cartão e vídeos recentes
122
124
 
123
125
  A página **Canais Youtube** mantém o cadastro e a importação existentes, mas cada cartão agora tem o botão **Editar**. O editor permite alterar nome, URL, handle, idioma, estilo wide, **Nicho**, **Blueprint Padrão**, **Narrador/Voz Padrão**, conta Google do Upload directo, descrição e Automação ON/horário. O nicho aparece imediatamente abaixo do nome do canal no cartão; quando não existe, a UI mostra **SEM NICHO CONFIGURADO**.
@@ -356,7 +358,7 @@ A subaba **Roteiros**, entre **Criação de Músicas** e **Upload**, permite esc
356
358
 
357
359
  Na subaba **Vídeos** de **Criação de Vídeos**, a frase `Os vídeos são guardados em <storage>/videos` identifica a pasta real onde os artefactos de vídeo devem ser procurados.
358
360
 
359
- O agente musical guarda os ficheiros em `storage/music/`, aceita formatos de áudio comuns e pode descarregar uma URL de áudio devolvida por um endpoint Suno compatível. Em **Canais Youtube**, abra **Definir Blueprint e voz padrão** no cartão do canal para guardar os defaults; em **Automação Youtube**, os mesmos dois selectores aparecem no cartão e são sincronizados. Esses valores são usados automaticamente em novas tarefas criadas para o canal. A aba **Automação Youtube** também guarda `Automação ON` e um horário diário `HH:MM` por canal e lista os vídeos cadastrados; por definição, esta entrega não executa workers de fundo.
361
+ O agente musical guarda os ficheiros em `storage/music/`, aceita formatos de áudio comuns e pode descarregar uma URL de áudio devolvida por um endpoint Suno compatível. Em **Canais Youtube**, abra **Definir Blueprint e voz padrão** no cartão do canal para guardar os defaults; em **Automação Youtube**, os mesmos dois selectores aparecem no cartão e são sincronizados. Esses valores são usados automaticamente em novas tarefas criadas para o canal. A aba **Automação Youtube** também guarda `Automação ON` e um horário diário `HH:MM` por canal e lista os vídeos cadastrados. Nos cards, **Start** retoma o processamento sem regenerar etapas já prontas, enquanto **Apagar** remove o card da fila após confirmação e preserva os artefactos locais. O worker continua a ser executado pelo processo local configurado.
360
362
 
361
363
  ## Teste de vozes
362
364
 
@@ -153,7 +153,7 @@ def render_ai_influencer_characters(
153
153
  if repository is None:
154
154
  return
155
155
 
156
- with st.form("influencer_create_form", clear_on_submit=False):
156
+ with st.form("influencer_create_form", clear_on_submit=True):
157
157
  st.subheader("Novo personagem")
158
158
  cols = st.columns(3)
159
159
  with cols[0]:
package/app/main.py CHANGED
@@ -22,7 +22,7 @@ try:
22
22
  except (OSError, json.JSONDecodeError):
23
23
  APP_VERSION = ""
24
24
 
25
- 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
25
+ from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, delete_task, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
26
26
  from hermes_ui.drafts import list_drafts, save_draft
27
27
  from hermes_ui.automation_worker import load_worker_status
28
28
  from hermes_ui.pipeline_worker import load_pipeline_worker_status, recover_stale_tasks, STALE_TASK_SECONDS, WORKER_HEARTBEAT_TIMEOUT_SECONDS
@@ -3887,6 +3887,7 @@ def render_automation():
3887
3887
 
3888
3888
  st.divider()
3889
3889
  st.subheader("Vídeos cadastrados")
3890
+ st.caption("Start retoma as etapas já concluídas e só gera novamente o que ainda não estiver pronto. Apagar remove o card da fila após confirmação e preserva os artefactos locais.")
3890
3891
  tasks = load_video_tasks_for_catalog()
3891
3892
  if not tasks:
3892
3893
  st.info("Ainda não existem vídeos cadastrados.")
@@ -3903,7 +3904,7 @@ def render_automation():
3903
3904
  st.write(_video_task_format(task))
3904
3905
  with task_cols[3]:
3905
3906
  state = str(task.get("state") or "")
3906
- start_col, stop_col = st.columns(2)
3907
+ start_col, stop_col, delete_col = st.columns(3)
3907
3908
  with start_col:
3908
3909
  if st.button("Start", key=f"automation_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
3909
3910
  transition_task(task["id"], "doing")
@@ -3912,6 +3913,26 @@ def render_automation():
3912
3913
  if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
3913
3914
  transition_task(task["id"], "blocked")
3914
3915
  st.rerun()
3916
+ with delete_col:
3917
+ confirm_delete_key = f"automation_confirm_delete_{task['id']}"
3918
+ if st.button("Apagar", key=f"automation_delete_{task['id']}", use_container_width=True, disabled=state == "doing"):
3919
+ st.session_state[confirm_delete_key] = True
3920
+ st.rerun()
3921
+ if st.session_state.get(confirm_delete_key):
3922
+ st.warning("Remover este vídeo da fila? Os ficheiros de artefactos serão preservados.")
3923
+ confirm_col, cancel_col = st.columns(2)
3924
+ with confirm_col:
3925
+ if st.button("Confirmar", key=f"automation_confirm_delete_button_{task['id']}", use_container_width=True, type="primary"):
3926
+ try:
3927
+ delete_task(task["id"])
3928
+ st.session_state.pop(confirm_delete_key, None)
3929
+ st.rerun()
3930
+ except ValueError as exc:
3931
+ st.error(str(exc))
3932
+ with cancel_col:
3933
+ if st.button("Cancelar", key=f"automation_cancel_delete_{task['id']}", use_container_width=True):
3934
+ st.session_state.pop(confirm_delete_key, None)
3935
+ st.rerun()
3915
3936
 
3916
3937
 
3917
3938
  def render_upload_direct():
@@ -259,6 +259,29 @@ def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
259
259
  return None
260
260
 
261
261
 
262
+ def delete_task(task_id: str) -> dict[str, Any] | None:
263
+ """Remove uma tarefa de vídeo da fila sem apagar os ficheiros dos artefactos."""
264
+ normalized_id = str(task_id or "").strip()
265
+ if not normalized_id:
266
+ return None
267
+ tasks = read_json("tasks.json", [])
268
+ if not isinstance(tasks, list):
269
+ return None
270
+ removed = next((task for task in tasks if isinstance(task, dict) and str(task.get("id") or "") == normalized_id), None)
271
+ if removed is None:
272
+ return None
273
+ if str(removed.get("state") or "") == "doing":
274
+ raise ValueError("Pare a tarefa antes de a remover da fila.")
275
+ write_json("tasks.json", [task for task in tasks if not (isinstance(task, dict) and str(task.get("id") or "") == normalized_id)])
276
+ queues = read_json("queues.json", {})
277
+ if isinstance(queues, dict):
278
+ for queue_name, queue_items in list(queues.items()):
279
+ if isinstance(queue_items, list):
280
+ queues[queue_name] = [item for item in queue_items if str(item) != normalized_id]
281
+ write_json("queues.json", queues)
282
+ return removed
283
+
284
+
262
285
  def transition_task(task_id: str, state: str | None = None, stage: str | None = None, error: str | None = None) -> dict[str, Any] | None:
263
286
  if state and state not in VALID_STATES:
264
287
  raise ValueError(f"Estado inválido: {state}")
@@ -459,6 +459,58 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
459
459
  return video_path
460
460
 
461
461
 
462
+ def _read_persisted_script(task: dict[str, Any], channel: dict[str, Any], blueprint: dict[str, Any], topic: str) -> dict[str, Any] | None:
463
+ """Load a previously saved script so retries do not regenerate it."""
464
+ artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
465
+ script_path = Path(str(artifacts.get("script") or "")).expanduser()
466
+ if not script_path.is_file() or script_path.stat().st_size <= 0:
467
+ return None
468
+ try:
469
+ raw = script_path.read_text(encoding="utf-8").strip()
470
+ except OSError:
471
+ return None
472
+ if not raw:
473
+ return None
474
+ content = raw
475
+ if raw.startswith("---"):
476
+ parts = raw.split("---", 2)
477
+ if len(parts) == 3:
478
+ content = parts[2].strip()
479
+ if content.startswith(">"):
480
+ content = "\n".join(line[1:].lstrip() if line.startswith(">") else line for line in content.splitlines()).strip()
481
+ return {
482
+ "document_type": "video_script",
483
+ "title": str(task.get("title") or topic),
484
+ "summary": topic,
485
+ "content": content,
486
+ "language": str(task.get("language") or channel.get("language") or "Português"),
487
+ "blueprint_id": str(blueprint.get("id") or ""),
488
+ "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
489
+ "channel_id": str(channel.get("id") or ""),
490
+ "channel_name": str(channel.get("name") or "Canal sem nome"),
491
+ "generated_by": "persisted_pipeline_artifact",
492
+ }
493
+
494
+
495
+ def _valid_artifact_path(value: Any) -> Path | None:
496
+ path = Path(str(value or "")).expanduser()
497
+ try:
498
+ return path if path.is_file() and path.stat().st_size > 0 else None
499
+ except OSError:
500
+ return None
501
+
502
+
503
+ def _read_json_artifact(value: Any) -> dict[str, Any] | None:
504
+ path = _valid_artifact_path(value)
505
+ if path is None:
506
+ return None
507
+ try:
508
+ payload = json.loads(path.read_text(encoding="utf-8"))
509
+ except (OSError, json.JSONDecodeError):
510
+ return None
511
+ return payload if isinstance(payload, dict) else None
512
+
513
+
462
514
  def _run_task(task: dict[str, Any]) -> dict[str, Any]:
463
515
  task_id = str(task.get("id") or "")
464
516
  channel = _channel_for_task(task)
@@ -473,116 +525,130 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
473
525
  raise PipelineError("A IA não devolveu um tema válido.")
474
526
  _update(task_id, topic=topic, topic_source="llm", ai_generation={"topic": topic_result}, progress=12)
475
527
 
476
- _update(task_id, stage="script", state="doing", progress=18, error=None)
477
528
  generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
478
- provided_script = str(generation_settings.get("video_script") or "").strip()
479
- if provided_script:
480
- script = {
481
- "document_type": "video_script",
482
- "title": str(task.get("title") or topic),
483
- "summary": topic,
484
- "content": provided_script,
485
- "language": str(task.get("language") or channel.get("language") or "Português"),
486
- "blueprint_id": str(blueprint.get("id") or ""),
487
- "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
488
- "channel_id": str(channel.get("id") or ""),
489
- "channel_name": str(channel.get("name") or "Canal sem nome"),
490
- "generation_settings": generation_settings,
491
- "generated_by": "video_creation_form",
492
- }
493
- else:
494
- script = generate_script_document(
495
- settings,
496
- document_type="Roteiro de vídeo",
497
- title=str(task.get("title") or topic),
498
- brief=topic,
499
- language=str(task.get("language") or channel.get("language") or "Português"),
500
- channel=channel,
501
- blueprint=blueprint,
502
- structure_notes=str(generation_settings.get("script_structure_notes") or ""),
503
- generation_settings=generation_settings,
504
- )
505
- script_record = save_script_document(script)
506
529
  artifacts = dict(task.get("artifacts") or {})
507
- artifacts["script"] = script_record.get("path", "")
508
- _update(task_id, artifacts=artifacts, progress=30)
530
+ script = _read_persisted_script(task, channel, blueprint, topic)
531
+ if script is None:
532
+ _update(task_id, stage="script", state="doing", progress=18, error=None)
533
+ provided_script = str(generation_settings.get("video_script") or "").strip()
534
+ if provided_script:
535
+ script = {
536
+ "document_type": "video_script",
537
+ "title": str(task.get("title") or topic),
538
+ "summary": topic,
539
+ "content": provided_script,
540
+ "language": str(task.get("language") or channel.get("language") or "Português"),
541
+ "blueprint_id": str(blueprint.get("id") or ""),
542
+ "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
543
+ "channel_id": str(channel.get("id") or ""),
544
+ "channel_name": str(channel.get("name") or "Canal sem nome"),
545
+ "generation_settings": generation_settings,
546
+ "generated_by": "video_creation_form",
547
+ }
548
+ else:
549
+ script = generate_script_document(
550
+ settings,
551
+ document_type="Roteiro de vídeo",
552
+ title=str(task.get("title") or topic),
553
+ brief=topic,
554
+ language=str(task.get("language") or channel.get("language") or "Português"),
555
+ channel=channel,
556
+ blueprint=blueprint,
557
+ structure_notes=str(generation_settings.get("script_structure_notes") or ""),
558
+ generation_settings=generation_settings,
559
+ )
560
+ script_record = save_script_document(script)
561
+ artifacts["script"] = script_record.get("path", "")
562
+ _update(task_id, artifacts=artifacts, progress=max(30, int(task.get("progress") or 0)))
563
+ else:
564
+ _update(task_id, artifacts=artifacts, error=None, progress=max(30, int(task.get("progress") or 0)))
509
565
 
510
- _update(task_id, stage="title", state="doing", progress=35)
511
- provided_title = str(task.get("title") or "").strip()
512
- provided_keywords = generation_settings.get("video_keywords") or task.get("keywords")
566
+ title = str(task.get("title") or "").strip()
567
+ provided_keywords = generation_settings.get("video_keywords") or task.get("keywords") or task.get("tags")
513
568
  if isinstance(provided_keywords, str):
514
569
  provided_keywords = re.split(r"[,\n;|]+", provided_keywords)
515
- provided_keywords = [str(item).strip() for item in provided_keywords or [] if str(item).strip()]
570
+ keywords = [str(item).strip() for item in provided_keywords or [] if str(item).strip()][:15]
516
571
  title_candidates = task.get("title_candidates") if isinstance(task.get("title_candidates"), list) else []
517
- editorial: dict[str, Any] = {
518
- "title": provided_title or topic,
519
- "title_candidates": title_candidates,
520
- "keywords": provided_keywords,
521
- }
522
- if not provided_title:
523
- try:
524
- editorial = generate_title_and_keywords(
525
- settings,
526
- channel,
527
- topic,
528
- blueprint,
529
- language=str(task.get("language") or channel.get("language") or "Português"),
530
- )
531
- except CreativeGenerationError:
532
- # A falha editorial não deve impedir a geração do vídeo: usa o tema
533
- # como título e keywords determinísticas, deixando o erro de imagem
534
- # para a etapa posterior e independente da criação do MP4.
535
- editorial = {"title": topic, "title_candidates": [], "keywords": []}
536
- title = str(editorial.get("title") or provided_title or topic).strip()
537
- keywords = provided_keywords[:15] or (
538
- editorial.get("keywords") if isinstance(editorial.get("keywords"), list) else []
539
- )
540
- keywords = [str(item).strip() for item in keywords if str(item).strip()][:15] or _keywords(
541
- topic,
542
- title,
543
- str(channel.get("niche") or ""),
544
- )
545
- title_candidates = editorial.get("title_candidates") if isinstance(editorial.get("title_candidates"), list) else title_candidates
546
- title_artifact = _save_json_artifact(
547
- task_id,
548
- "title-keywords",
549
- {"topic": topic, "title": title, "keywords": keywords, "title_candidates": title_candidates},
550
- )
551
- artifacts = {**artifacts, "title_keywords": title_artifact}
552
- _update(task_id, title=title, tags=keywords, artifacts=artifacts, title_candidates=title_candidates, progress=45)
572
+ if not title or not keywords:
573
+ _update(task_id, stage="title", state="doing", progress=max(35, int(task.get("progress") or 0)), error=None)
574
+ editorial: dict[str, Any] = {
575
+ "title": title or topic,
576
+ "title_candidates": title_candidates,
577
+ "keywords": keywords,
578
+ }
579
+ if not title:
580
+ try:
581
+ editorial = generate_title_and_keywords(
582
+ settings,
583
+ channel,
584
+ topic,
585
+ blueprint,
586
+ language=str(task.get("language") or channel.get("language") or "Português"),
587
+ )
588
+ except CreativeGenerationError:
589
+ # A falha editorial não deve impedir a geração do vídeo: usa o tema
590
+ # como título e keywords determinísticas, deixando o erro de imagem
591
+ # para a etapa posterior e independente da criação do MP4.
592
+ editorial = {"title": topic, "title_candidates": [], "keywords": []}
593
+ title = str(editorial.get("title") or title or topic).strip()
594
+ keywords = keywords or (
595
+ editorial.get("keywords") if isinstance(editorial.get("keywords"), list) else []
596
+ )
597
+ keywords = [str(item).strip() for item in keywords if str(item).strip()][:15] or _keywords(
598
+ topic,
599
+ title,
600
+ str(channel.get("niche") or ""),
601
+ )
602
+ title_candidates = editorial.get("title_candidates") if isinstance(editorial.get("title_candidates"), list) else title_candidates
603
+ title_artifact = _save_json_artifact(
604
+ task_id,
605
+ "title-keywords",
606
+ {"topic": topic, "title": title, "keywords": keywords, "title_candidates": title_candidates},
607
+ )
608
+ artifacts["title_keywords"] = title_artifact
609
+ _update(task_id, title=title, tags=keywords, artifacts=artifacts, title_candidates=title_candidates, progress=max(45, int(task.get("progress") or 0)))
610
+ else:
611
+ _update(task_id, title=title, tags=keywords, artifacts=artifacts, title_candidates=title_candidates, error=None, progress=max(50, int(task.get("progress") or 0)))
553
612
 
554
- _update(task_id, stage="keywords", state="doing", progress=48)
555
- _update(task_id, tags=keywords, progress=50)
613
+ _update(task_id, stage="keywords", state="doing", progress=max(50, int(task.get("progress") or 0)), error=None)
614
+ _update(task_id, tags=keywords, progress=max(50, int(task.get("progress") or 0)))
556
615
 
557
616
  # O vídeo é deliberadamente concluído antes de qualquer chamada ao provider
558
617
  # de imagem. O artefacto fica persistido mesmo que a quota da thumbnail falhe.
559
- _update(task_id, stage="video", state="doing", progress=52, error=None)
560
- video_cards = media_cards_for_pool(settings, "video")
561
- if bool(settings.get("media_video_pool_enabled")) and video_cards:
562
- try:
563
- video_prompt = _append_generation_constraints(
564
- f"Título: {title}\n\nRoteiro:\n{str(script.get('content') or '')[:12000]}",
565
- kind="video",
566
- )
567
- video_path = generate_video_from_pool(settings, video_prompt)
568
- except MediaGenerationError as exc:
569
- raise PipelineError(f"Pool de vídeo externo: {exc}") from exc
618
+ existing_video = _valid_artifact_path(artifacts.get("video"))
619
+ if existing_video is None:
620
+ _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",
627
+ )
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})
633
+ current_after_video = _task_by_id(task_id) or {}
634
+ artifacts = dict(current_after_video.get("artifacts") or artifacts)
635
+ artifacts["video"] = str(video_path)
636
+ _update(task_id, artifacts=artifacts, video_ready=True, progress=max(80, int(task.get("progress") or 0)))
570
637
  else:
571
- video_path = _run_video_helper({**task, "topic": topic, "title": title})
572
- current_after_video = _task_by_id(task_id) or {}
573
- artifacts = dict(current_after_video.get("artifacts") or artifacts)
574
- artifacts["video"] = str(video_path)
575
- _update(task_id, artifacts=artifacts, video_ready=True, progress=80)
638
+ video_path = existing_video
639
+ artifacts["video"] = str(existing_video)
640
+ _update(task_id, stage="video", state="doing", artifacts=artifacts, video_ready=True, error=None, progress=max(80, int(task.get("progress") or 0)))
576
641
 
577
- _update(task_id, stage="thumbnail_prompt", state="doing", progress=82, error=None)
642
+ persisted_prompt = _read_json_artifact(artifacts.get("thumbnail_prompt_json"))
643
+ persisted_variant = persisted_prompt.get("thumbnail") if isinstance(persisted_prompt, dict) and isinstance(persisted_prompt.get("thumbnail"), dict) else {}
578
644
  existing_variant = task.get("thumbnail_variant") if isinstance(task.get("thumbnail_variant"), dict) else {}
579
- existing_variant = dict(existing_variant)
580
- if not str(existing_variant.get("image_prompt") or "").strip() and str(task.get("thumbnail_prompt") or "").strip():
581
- existing_variant["image_prompt"] = str(task.get("thumbnail_prompt") or "").strip()
582
- if not str(existing_variant.get("overlay_text") or "").strip() and str(task.get("thumbnail_text") or "").strip():
583
- existing_variant["overlay_text"] = str(task.get("thumbnail_text") or "").strip()
584
- variant = existing_variant
645
+ variant = {**persisted_variant, **dict(existing_variant)}
646
+ if not str(variant.get("image_prompt") or "").strip() and str(task.get("thumbnail_prompt") or "").strip():
647
+ variant["image_prompt"] = str(task.get("thumbnail_prompt") or "").strip()
648
+ if not str(variant.get("overlay_text") or "").strip() and str(task.get("thumbnail_text") or "").strip():
649
+ variant["overlay_text"] = str(task.get("thumbnail_text") or "").strip()
585
650
  if not str(variant.get("image_prompt") or "").strip():
651
+ _update(task_id, stage="thumbnail_prompt", state="doing", progress=max(82, int(task.get("progress") or 0)), error=None)
586
652
  try:
587
653
  variant = generate_thumbnail_prompt(
588
654
  settings,
@@ -608,24 +674,25 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
608
674
  "lettering_prompt": str(variant.get("lettering_prompt") or ""),
609
675
  },
610
676
  }
611
- prompt_artifact = _save_json_artifact(task_id, "thumbnail-prompt", prompt_payload)
612
- artifacts["thumbnail_prompt_json"] = prompt_artifact
677
+ if _valid_artifact_path(artifacts.get("thumbnail_prompt_json")) is None:
678
+ artifacts["thumbnail_prompt_json"] = _save_json_artifact(task_id, "thumbnail-prompt", prompt_payload)
613
679
  _update(
614
680
  task_id,
615
681
  stage="thumbnail_prompt",
616
682
  state="doing",
617
- progress=84,
683
+ progress=max(84, int(task.get("progress") or 0)),
618
684
  thumbnail_variant=variant,
619
685
  thumbnail_prompt=str(variant.get("image_prompt") or ""),
620
686
  thumbnail_text=str(variant.get("overlay_text") or ""),
621
687
  thumbnail_status="prompt_ready",
622
688
  artifacts=artifacts,
689
+ error=None,
623
690
  )
624
691
 
625
- _update(task_id, stage="thumbnail", state="doing", progress=86, error=None)
692
+ _update(task_id, stage="thumbnail", state="doing", progress=max(86, int(task.get("progress") or 0)), error=None)
626
693
  current_artifacts = dict((_task_by_id(task_id) or {}).get("artifacts") or artifacts)
627
- existing_thumbnail = Path(str(current_artifacts.get("thumbnail") or variant.get("image_path") or "")).expanduser()
628
- if existing_thumbnail.is_file() and existing_thumbnail.stat().st_size > 0:
694
+ existing_thumbnail = _valid_artifact_path(current_artifacts.get("thumbnail") or variant.get("image_path"))
695
+ if existing_thumbnail is not None:
629
696
  thumbnail_path = existing_thumbnail
630
697
  else:
631
698
  try:
@@ -641,9 +708,13 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
641
708
  raise PipelineError(f"A thumbnail não foi gerada; o vídeo já está disponível em {video_path}: {exc}") from exc
642
709
  artifacts = dict((_task_by_id(task_id) or {}).get("artifacts") or current_artifacts)
643
710
  artifacts["thumbnail"] = str(thumbnail_path)
644
- _update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=90)
711
+ _update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=max(90, int(task.get("progress") or 0)))
712
+
713
+ stored_upload = artifacts.get("upload")
714
+ if isinstance(stored_upload, dict) and stored_upload:
715
+ return _update(task_id, stage="upload", state="done", progress=100, artifacts=artifacts, video_ready=True, error=None)
645
716
 
646
- _update(task_id, stage="upload", state="doing", progress=94, error=None)
717
+ _update(task_id, stage="upload", state="doing", progress=max(94, int(task.get("progress") or 0)), error=None)
647
718
  result = upload_with_default_route(
648
719
  settings,
649
720
  storage_root=STORAGE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.55",
3
+ "version": "0.3.57",
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",