@danhachuel/thunderbolt 0.2.52 → 0.2.54

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.2.52
5
+ > **Versão deste manual:** 0.2.54
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)
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.2.52 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.54 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.52 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.54 install
110
110
  ```
111
111
 
112
112
  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.
@@ -345,7 +345,7 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
345
345
 
346
346
  ## 8. Configuração inicial da UI
347
347
 
348
- Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. O menu expansível **Edição** contém **Limpador de Metadados**, **Cortes** e **Editor Python**. O menu expansível **Models AI** contém **Personagens** e **Redes Sociais**, nessa ordem; ambas estão reservadas para desenvolvimento futuro e não executam operações. O menu expansível **Niche Finder** contém **Niche Finder Kaggle** e **Niche Finder Apify**. A aba **Niche Finder Kaggle** mantém a análise do dataset Kaggle; **Niche Finder Apify** é uma segunda alternativa independente, com actor, dataset, parâmetros, credencial, execução e resultados próprios. Abra **Configurações > Configurações Técnicas** e reveja. A página está organizada em três subabas sem numeração visível, na ordem **Contas Google/YouTube — canais em lote**, **API Keys** e **Teste de vozes**. A subaba de contas contém os registos Google, sessionInfo e documentos de Upload directo; **API Keys** reúne execução local, OAuth, LLM, TTS, materiais e restantes credenciais; **Teste de vozes** contém apenas o preview de áudio. O menu **Pipeline** contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. O menu **Configurações** contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica numa área opcional, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
348
+ Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. O menu expansível **Automação** contém a subaba **Automação Youtube**. O menu expansível **Edição** contém **Limpador de Metadados**, **Cortes** e **Editor Python**. O menu expansível **Models AI** contém **Personagens** e **Redes Sociais**, nessa ordem; ambas estão reservadas para desenvolvimento futuro e não executam operações. O menu expansível **Niche Finder** contém **Niche Finder Kaggle** e **Niche Finder Apify**. A aba **Niche Finder Kaggle** mantém a análise do dataset Kaggle; **Niche Finder Apify** é uma segunda alternativa independente, com actor, dataset, parâmetros, credencial, execução e resultados próprios. Abra **Configurações > Configurações Técnicas** e reveja. A página está organizada em três subabas sem numeração visível, na ordem **Contas Google/YouTube — canais em lote**, **API Keys** e **Teste de vozes**. A subaba de contas contém os registos Google, sessionInfo e documentos de Upload directo; **API Keys** reúne execução local, OAuth, LLM, TTS, materiais e restantes credenciais; **Teste de vozes** contém apenas o preview de áudio. O menu **Pipeline** contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. O menu **Configurações** contém **Canais Youtube**, **Blueprints Youtube**, **MCP** e **Configurações Técnicas**. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica numa área opcional, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
349
349
 
350
350
  | Configuração | Finalidade |
351
351
  |---|---|
@@ -396,17 +396,17 @@ Após iniciar a aplicação, valide o seguinte percurso:
396
396
  11. **Configurações > Configurações Técnicas:** confirme que os caminhos e credenciais estão locais e não aparecem no Git. Na secção **Contas Google/YouTube — canais em lote**, adicione cada conta com o seu e-mail/Gmail, OAuth Client ID, OAuth Client Secret e `sessionInfo` próprios; use **Repetir campos para nova conta** para preparar contas adicionais e o ícone **Apagar conta** para eliminar individualmente a conta, tokens, documento JSON e associações de canais.
397
397
  12. **OpenAI/ NVIDIA NIM:** em **LLM — providers e modelos**, seleccione `openai`, confirme a Base URL, introduza a API key e clique em **Consultar/actualizar modelos NIM**. Escolha um modelo da lista ou utilize o fallback manual e guarde as configurações. Confirme que a consulta só ocorre após o clique e que um erro de endpoint não interrompe a UI.
398
398
  13. **Criação de Vídeos:** confirme que `Pexels/Pixabay` substitui o label antigo, que `Estilo IA` aparece apenas em `full_ia` e que `Apenas Música` exige áudio e guarda `background_mode=none`.
399
- 14. **Canais:** abra **Configurações > Canais**, clique em **Editar** num cartão, altere o nicho, Blueprint/Prompts do Canal, Narrador/Voz padrão e horário, guarde e confirme que o cartão é actualizado sem duplicar o canal. Clique em **Actualizar últimos 10 vídeos**, valide a vista Lista, mude para Kanban e abra **Editar vídeo** para testar título, estado, data, URL e notas.
400
- 15. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas. Inicie o Thunderbolt pelo launcher, confirme o aviso verde **Worker activo** e verifique que o relógio apresentado corresponde ao computador. O worker cria no máximo um lote por canal por dia quando o horário local coincide.
399
+ 14. **Canais Youtube:** abra **Configurações > Canais Youtube**, clique em **Editar** num cartão, altere o nicho, Blueprint/Prompts do Canal, Narrador/Voz padrão e horário, guarde e confirme que o cartão é actualizado sem duplicar o canal. Clique em **Actualizar últimos 10 vídeos**, valide a vista Lista, mude para Kanban e abra **Editar vídeo** para testar título, estado, data, URL e notas.
400
+ 15. **Automação Youtube:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais Youtube** e são usados em novas tarefas. Inicie o Thunderbolt pelo launcher, confirme o aviso verde **Worker activo** e verifique que o relógio apresentado corresponde ao computador. O worker cria no máximo um lote por canal por dia quando o horário local coincide.
401
401
  16. **Configurações > Configurações Técnicas > Teste de vozes:** teste Edge/Azure ou provider configurado e confirme reprodução/download sem criação de tarefa.
402
- 17. **Upload directo:** em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada cartão Gmail apresenta o uploader **Documento de credenciais desta conta Google** e o campo **sessionInfo token desta conta Google**. O sessionInfo é guardado por conta e sincronizado no documento JSON, que reúne `SID`, `SSID`, `HSID`, `APISID`, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` por canal. Use **Repetir campos para nova conta** para preencher o formulário seguinte e o ícone **Apagar conta** para remover a conta e os dados privados associados. Em **Canais > Canais cadastrados**, associe cada canal à conta Google do documento; a UI não mostra nem edita o `DELEGATED_SESSION_ID`. O documento é guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`, e o uploader lê os valores directos exclusivamente desse documento. Não existem campos técnicos de cookies, INNERTUBE_API_KEY, chunk_size ou DELEGATED_SESSION_ID na parte inferior da UI e o método não extrai cookies automaticamente do navegador.
402
+ 17. **Upload directo:** em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada cartão Gmail apresenta o uploader **Documento de credenciais desta conta Google** e o campo **sessionInfo token desta conta Google**. O sessionInfo é guardado por conta e sincronizado no documento JSON, que reúne `SID`, `SSID`, `HSID`, `APISID`, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` por canal. Use **Repetir campos para nova conta** para preencher o formulário seguinte e o ícone **Apagar conta** para remover a conta e os dados privados associados. Em **Canais Youtube > Canais cadastrados**, associe cada canal à conta Google do documento; a UI não mostra nem edita o `DELEGATED_SESSION_ID`. O documento é guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`, e o uploader lê os valores directos exclusivamente desse documento. Não existem campos técnicos de cookies, INNERTUBE_API_KEY, chunk_size ou DELEGATED_SESSION_ID na parte inferior da UI e o método não extrai cookies automaticamente do navegador.
403
403
  18. **Configurações > MCP > Client MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
404
404
  19. **Configurações > MCP > Servidor MCP:** abra a subaba, mantenha o host `127.0.0.1`, active **Servidor MCP ON** e clique em **Guardar e iniciar Servidor MCP**. Confirme o endpoint `/mcp` e o health endpoint `/health`. Mantenha **Permitir ferramentas de escrita** desactivado até precisar que um agente crie lotes.
405
405
  20. **Configurações > MCP > Skill:** clique em **Guardar skill localmente** e confirme o ficheiro em `storage/skills/moneyprinterturbo-video.md`; opcionalmente use **Descarregar skill .md** para obter a cópia através do navegador.
406
406
 
407
407
  ## Gestão de Canais, edição e vídeos recentes
408
408
 
409
- Na página **Configurações > Canais**, cada canal cadastrado aparece num cartão com o botão **Editar**. O editor permite alterar o nome, URL, handle, idioma, estilo wide, **Canais de Referência / Nicho**, **Prompts do Canal** (Blueprint padrão), **Narrador** (Voz padrão), conta Google do Upload directo, descrição e Automação ON/horário. Guardar alterações actualiza o mesmo registo local; não é necessário apagar e criar o canal novamente.
409
+ Na página **Configurações > Canais Youtube**, cada canal cadastrado aparece num cartão com o botão **Editar**. O editor permite alterar o nome, URL, handle, idioma, estilo wide, **Canais de Referência / Nicho**, **Prompts do Canal** (Blueprint padrão), **Narrador** (Voz padrão), conta Google do Upload directo, descrição e Automação ON/horário. Guardar alterações actualiza o mesmo registo local; não é necessário apagar e criar o canal novamente.
410
410
 
411
411
  O cartão mostra o nicho ou Canais de Referência imediatamente abaixo do nome. Os três blocos de gestão usam os rótulos **Prompts do Canal**, **Canais de Referência** e **Narrador**, com botões que abrem o editor do próprio canal.
412
412
 
@@ -414,7 +414,7 @@ A secção **Últimos 10 vídeos publicados** fica abaixo das configurações do
414
414
 
415
415
  ## Canais em lote por conta Google/YouTube
416
416
 
417
- A subaba **Canais em lote gmail**, dentro de **Canais**, não lê e-mails, mensagens, contactos ou a caixa Gmail. O nome identifica a conta Google/Gmail que gere os canais YouTube. Configure cada conta em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, preenchendo e-mail, OAuth Client ID, OAuth Client Secret e o `sessionInfo token desta conta Google`.
417
+ A subaba **Canais em lote gmail**, dentro de **Canais Youtube**, não lê e-mails, mensagens, contactos ou a caixa Gmail. O nome identifica a conta Google/Gmail que gere os canais YouTube. Configure cada conta em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, preenchendo e-mail, OAuth Client ID, OAuth Client Secret e o `sessionInfo token desta conta Google`.
418
418
 
419
419
  Na subaba, seleccione a conta e clique em **Autorizar conta Google**. A autorização abre o browser do sistema e guarda um refresh token separado para essa conta. Use um cliente OAuth do tipo **Desktop app**; o Thunderbolt usa a URI loopback `http://127.0.0.1:8765/`. Se usar um cliente Web application, adicione exactamente essa URI em Google Cloud > APIs e serviços > Credenciais > URIs de redireccionamento autorizados, incluindo a porta e a barra final. Caso contrário, o Google devolve `Erro 400: redirect_uri_mismatch`. Depois clique em **Listar canais desta conta**. O Thunderbolt chama a YouTube Data API com `channels.list`, `mine=true`, `part=snippet,contentDetails,statistics`, suporta páginas sucessivas e apresenta os canais encontrados para selecção.
420
420
 
@@ -428,7 +428,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
428
428
 
429
429
  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.
430
430
 
431
- 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 @danhachuel/thunderbolt@0.2.52 install`; o instalador detecta e reutiliza o que já estiver válido.
431
+ 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 @danhachuel/thunderbolt@0.2.54 install`; o instalador detecta e reutiliza o que já estiver válido.
432
432
 
433
433
  ### Niche Finder Apify
434
434
 
@@ -440,7 +440,7 @@ O menu **Models AI** foi adicionado abaixo de **Edição**. As abas **Personagen
440
440
 
441
441
  ## Edição: Limpador de Metadados, Cortes e Editor Python
442
442
 
443
- A aba **Limpador de Metadados** continua funcional e foi movida para **Edição**. A aba **Cortes** permanece reservada e não executa operações. A aba **Editor Python** é funcional e permite escolher vídeos gerados, indicar uma pasta local ou fazer upload manual; as operações são manuais e criam cópias sem alterar os originais.
443
+ A aba **Limpador de Metadados** continua funcional e foi movida para **Edição**. A aba **Cortes** é um Clip Generator local inspirado no [OpenShorts](https://github.com/mutonby/openshorts): permite upload de vídeo, URL directa, vídeos gerados ou pasta local, formatos 9:16/1:1/16:9, opções avançadas, modo manual ou automático por segmentos locais e confirmação de direitos antes da geração. Os clips são processados com FFmpeg, guardados em `storage/cuts/runs/<id>/`, apresentados com preview e downloads individual/ZIP, acompanhados de manifesto JSON e histórico. A aba **Editor Python** é funcional e permite escolher vídeos gerados, indicar uma pasta local ou fazer upload manual; as operações são manuais e criam cópias sem alterar os originais.
444
444
 
445
445
  ## Editor Python baseado no PYEdit
446
446
 
@@ -448,6 +448,8 @@ O **Editor Python** adapta o recorte do [PYEdit](https://github.com/Congren/PYEd
448
448
 
449
449
  Cada operação usa FFmpeg local, preserva o original, cria a saída em `storage/python_editor/outputs/`, guarda o histórico em `storage/state/python_editor_edits.json` e permite descarregar o resultado e um manifesto JSON. Na subaba **Código Python**, pode criar e guardar scripts em `storage/python_editor/scripts/` ou carregar scripts existentes. Por segurança, a UI não executa código Python e não executa scripts automaticamente.
450
450
 
451
+ No modo automático de **Cortes**, a aplicação não inventa uma selecção viral: sem transcrição ou provider de IA configurado, são gerados segmentos locais distribuídos pelo vídeo; o modo manual permite definir início e fim exactos. Abrir a página não inicia download, análise ou processamento.
452
+
451
453
  ## Criação de Vídeos — IA editorial e pacotes criativos
452
454
 
453
455
  O formulário permite escrever manualmente o **Tópico ou briefing** ou clicar em **Gerar tópico/briefing com IA**. O botão usa o provider LLM, Base URL, API key e modelo guardados em **Configurações Técnicas > API Keys**, incorpora a descrição, o nicho e o Blueprint do canal e coloca o resultado no campo para revisão. Abrir a página não inicia chamadas externas.
@@ -460,7 +462,7 @@ No modo **Lote geral**, todos os canais cadastrados são incluídos automaticame
460
462
 
461
463
  O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`, o selector `Estilo IA` apresenta os 12 estilos disponíveis. Ao seleccionar `Apenas Música`, a UI obriga a escolher uma música existente, carregar um ficheiro ou solicitar uma música a um endpoint Suno configurado. O vídeo recebe `background_mode=none`, sem fundo Pexels/Pixabay ou IA.
462
464
 
463
- A aba **Automação** lista os canais e vídeos, permite escolher e guardar **Blueprint padrão** e **Voz padrão** por canal, guarda **Automação ON** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais** 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.
465
+ A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista os canais e vídeos, permite escolher e guardar **Blueprint padrão** e **Voz padrão** por canal, guarda **Automação ON** 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.
464
466
 
465
467
  A área **Teste de vozes**, dentro de **Configurações > Configurações Técnicas**, é 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.
466
468
 
@@ -468,11 +470,11 @@ A área **Teste de vozes**, dentro de **Configurações > Configurações Técni
468
470
 
469
471
  Em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada conta aparece como um expander identificado por nome e e-mail. Dentro dele existe o uploader **Subir documento de cookies/credenciais** e o input **sessionInfo token desta conta Google**. O documento JSON único contém cookies, INNERTUBE_API_KEY, chunk_size e o mapa de IDs delegados por canal; o sessionInfo preenchido na conta é sincronizado no mesmo ficheiro, guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`.
470
472
 
471
- Em **Canais > Canais cadastrados**, a secção **Upload directo — documento da conta deste canal** mostra apenas a conta Google associada e confirma se o documento contém o ID delegado desse canal. O `DELEGATED_SESSION_ID` permanece exclusivamente no mapa do documento; o upload bloqueia a operação se faltar qualquer elemento técnico.
473
+ Em **Canais Youtube > Canais cadastrados**, a secção **Upload directo — documento da conta deste canal** mostra apenas a conta Google associada e confirma se o documento contém o ID delegado desse canal. O `DELEGATED_SESSION_ID` permanece exclusivamente no mapa do documento; o upload bloqueia a operação se faltar qualquer elemento técnico.
472
474
 
473
475
  A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Cada conta Google tem um único `credentials.json`, criado automaticamente ao adicionar a conta, com cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e `delegated_session_ids` por canal. O uploader aceita um documento JSON completo ou parcial; o merge actualiza somente os valores presentes e preserva os restantes. O documento é guardado fora de `storage/state/`, com permissões locais restritas.
474
476
 
475
- Associe cada canal à conta Google correcta em **Canais**; não introduza o ID delegado na UI. A associação do canal é permitida mesmo que o documento esteja incompleto. No momento do upload, o Thunderbolt lê o documento da conta, encontra o `DELEGATED_SESSION_ID` pela chave do canal e bloqueia apenas a operação se faltar conta, documento válido, cookies, sessionInfo, INNERTUBE_API_KEY, ID delegado ou vídeo elegível. O tamanho de chunk é normalizado para múltiplos de 262144 bytes. O método é uma integração não oficial de sessão do YouTube; não extraia cookies automaticamente, não os coloque no repositório e não os partilhe.
477
+ Associe cada canal à conta Google correcta em **Canais Youtube**; não introduza o ID delegado na UI. A associação do canal é permitida mesmo que o documento esteja incompleto. No momento do upload, o Thunderbolt lê o documento da conta, encontra o `DELEGATED_SESSION_ID` pela chave do canal e bloqueia apenas a operação se faltar conta, documento válido, cookies, sessionInfo, INNERTUBE_API_KEY, ID delegado ou vídeo elegível. O tamanho de chunk é normalizado para múltiplos de 262144 bytes. O método é uma integração não oficial de sessão do YouTube; não extraia cookies automaticamente, não os coloque no repositório e não os partilhe.
476
478
 
477
479
  ## MCP e integrações externas
478
480
 
package/README.md CHANGED
@@ -12,14 +12,14 @@ A primeira versão implementa a camada UI independente com:
12
12
  |---|---|
13
13
  | Início | Resumo de canais, tarefas, backlog, execução e falhas, com as filas do Pipeline inline |
14
14
  | Pipeline | Menu expansível com Criação de Vídeos, Criação de Músicas e Upload |
15
- | Blueprints | Leitura da pasta `storage/blueprints/`, upload/validação de JSON e criação a partir de link YouTube |
15
+ | Blueprints Youtube | Leitura da pasta `storage/blueprints/`, upload/validação de JSON e criação a partir de link YouTube |
16
16
  | Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
17
- | Canais | Subabas de importação pública sem API Key, Canais em lote gmail por conta Google/YouTube via OAuth e cadastro manual independente; cartões com botão Editar, nicho/referências visível, Prompts do Canal/Blueprint, Narrador/voz e gestão dos últimos 10 vídeos |
17
+ | Canais Youtube | Subabas de importação pública sem API Key, Canais em lote gmail por conta Google/YouTube via OAuth e cadastro manual independente; cartões com botão Editar, nicho/referências visível, Prompts do Canal/Blueprint, Narrador/voz e gestão dos últimos 10 vídeos |
18
18
  | Criação de Vídeos / Criação de Músicas | Subabas Criar vídeo e Vídeos; lotes; 51 rótulos de idioma; Pexels/Pixabay, full IA com Estilo IA e Apenas Música com agente musical; a segunda página reutiliza o mesmo fluxo com título próprio |
19
- | Automação | Lista de vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON, horário diário HH:MM e worker local baseado no relógio do computador |
19
+ | Automação | Menu expansível com a subaba **Automação Youtube**, onde ficam os vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON, horário diário HH:MM e worker local baseado no relógio do computador |
20
20
  | Models AI | Menu expansível com Personagens e Redes Sociais reservados para desenvolvimento futuro |
21
21
  | Niche Finder | Menu expansível com duas alternativas independentes: Niche Finder Kaggle e Niche Finder Apify, com parâmetros, execução e resultados separados |
22
- | Edição | Menu expansível abaixo de Automação com Limpador de Metadados funcional, Cortes reservado e Editor Python inspirado no PYEdit para vídeos e scripts locais |
22
+ | Edição | Menu expansível abaixo de Automação com Limpador de Metadados, Clip Generator local em Cortes e Editor Python inspirado no PYEdit para vídeos e scripts locais |
23
23
  | Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo via sessão Frontend API com cookies/sessionInfo por conta e DELEGATED_SESSION_ID por canal, TikTok, Instagram e Facebook Pages no front end |
24
24
  | MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
25
25
  | Configurações Técnicas | Provedores LLM, OpenAI/NVIDIA NIM com API key, Base URL e selector de modelos, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID, Client Secret e sessionInfo por conta, cartões expansíveis por nome/e-mail, documento credentials.json criado automaticamente e eliminação individual, cookies SID/SSID/HSID/APISID/SAPISID no documento JSON, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
@@ -38,15 +38,15 @@ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs,
38
38
 
39
39
  Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
40
40
 
41
- ## Navegação da UI 0.2.52
41
+ ## Navegação da UI 0.2.54
42
42
 
43
- A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes** e **Editor Python**, nessa ordem. **Models AI** é expansível e contém **Personagens** e **Redes Sociais**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
43
+ A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes** e **Editor Python**, nessa ordem. **Models AI** é expansível e contém **Personagens** e **Redes Sociais**, nessa ordem. **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** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
44
44
 
45
45
  Dentro de **Configurações Técnicas**, a UI está organizada em três subabas, sem numeração visível: **Contas Google/YouTube — canais em lote**, **API Keys** e **Teste de vozes**. A primeira contém cartões expansíveis de contas Google, sessionInfo e documentos de Upload directo; o formulário **Adicionar outra conta Gmail** fica fora dos cartões. **API Keys** reúne execução local, OAuth, LLM, TTS, materiais e restantes credenciais; **Teste de vozes** contém exclusivamente o preview de áudio.
46
46
 
47
- ## Canais — edição por cartão e vídeos recentes
47
+ ## Canais Youtube — edição por cartão e vídeos recentes
48
48
 
49
- A página **Canais** 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, **Canais de Referência / Nicho**, **Prompts do Canal** (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**.
49
+ 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, **Canais de Referência / Nicho**, **Prompts do Canal** (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**.
50
50
 
51
51
  Os blocos do cartão usam a nomenclatura solicitada: **Prompts do Canal**, **Canais de Referência** e **Narrador**. Os botões de acção abrem o mesmo editor persistente, sem criar um segundo canal nem perder as associações existentes.
52
52
 
@@ -161,9 +161,9 @@ thunderbolt
161
161
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
162
162
 
163
163
  ```powershell
164
- npx.cmd --yes @danhachuel/thunderbolt@0.2.50 install
165
- npx.cmd --yes @danhachuel/thunderbolt@0.2.50 doctor
166
- npx.cmd --yes @danhachuel/thunderbolt@0.2.50
164
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.54 install
165
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.54 doctor
166
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.54
167
167
  ```
168
168
 
169
169
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -187,7 +187,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
187
187
 
188
188
  A interface apresenta dentro da aba os parâmetros da busca: número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, categoria de engagement, intervalo de datas e tags. A página permanece sem resultados até o primeiro clique em **Analisar Nichos**; depois, se os parâmetros forem alterados, mostra os resultados anteriores e pede novo clique para aplicar os filtros actuais. O núcleo aplica normalização, filtros, `log1p`, `StandardScaler`, K-Means e FP-Growth. Os resultados aparecem em DataFrames para clusters, itemsets frequentes, regras de associação e dados analisados, acompanhados por uma visualização Plotly nativa e pesquisa de palavras nos clusters.
189
189
 
190
- **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.50 install`; o instalador detecta e reutiliza componentes já válidos.
190
+ **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.54 install`; o instalador detecta e reutiliza componentes já válidos.
191
191
 
192
192
  ## Editor Python baseado no PYEdit
193
193
 
@@ -226,9 +226,9 @@ storage/
226
226
 
227
227
  Para usar outro local, defina `THUNDERBOLT_STORAGE_DIR`. O estado é escrito de forma atómica e ficheiros JSON inválidos são preservados com cópia `.corrupt-*` antes de serem recriados.
228
228
 
229
- ## Blueprints e Brandings
229
+ ## Blueprints Youtube e Brandings
230
230
 
231
- Coloque ficheiros `.json` em `storage/blueprints/canais`, `storage/blueprints/nichos` ou `storage/blueprints/importados`. A aba **Blueprints** relê a pasta e mostra o conteúdo estruturado. A instalação já inclui 13 Blueprints seed, copiados apenas quando o ficheiro ainda não existe; actualizações não substituem Blueprints locais. Também é possível importar através do carregador da própria interface.
231
+ Coloque ficheiros `.json` em `storage/blueprints/canais`, `storage/blueprints/nichos` ou `storage/blueprints/importados`. A aba **Blueprints Youtube** relê a pasta e mostra o conteúdo estruturado. A instalação já inclui 13 Blueprints seed, copiados apenas quando o ficheiro ainda não existe; actualizações não substituem Blueprints locais. Também é possível importar através do carregador da própria interface.
232
232
 
233
233
  A mesma aba possui a subaba **Brandings**. No formulário **Criar blueprint a partir de link**, cole um link de canal, informe o **Nome do Blueprint**, o nicho e o idioma e escolha entre **Apenas Blueprint** ou **Blueprint + Branding completo**. O nome é guardado no campo `name` do JSON e também é usado no nome do ficheiro. O primeiro modo grava o blueprint forense local; o segundo grava também um ficheiro de Branding com identidade do canal, handle, descrição, hashtags, keywords, prompts de imagem de perfil e banner, direcção visual de thumbnails, assets e checklist de revisão.
234
234
 
@@ -240,9 +240,17 @@ A aba **Limpador de Metadados** foi adaptada do workflow `YTBMetadataGenerator.j
240
240
 
241
241
  A descrição segue a composição do workflow de referência: preview, secção de links e capítulos/timestamps. O resultado inclui um manifesto JSON para utilizar os metadados num fluxo posterior de upload. A versão local não usa o trigger RSS, o Apify ou a actualização automática de um vídeo YouTube; em vez disso, o utilizador fornece directamente o vídeo terceiro e controla a edição antes de publicar.
242
242
 
243
+ ## Cortes — Clip Generator local
244
+
245
+ A aba **Cortes** é um Clip Generator local inspirado no fluxo do [OpenShorts](https://github.com/mutonby/openshorts). Permite fazer upload de um vídeo, descarregar uma URL directa de vídeo, seleccionar um vídeo gerado pela pipeline ou escolher uma pasta local. O original é preservado em `storage/cuts/inputs/`.
246
+
247
+ O formulário apresenta os formatos **9:16 — Shorts/Reels/TikTok**, **1:1 — Feed posts** e **16:9 — YouTube/landscape**, além de opções avançadas para modo automático por segmentos locais ou corte manual, número de clips, durações mínima/máxima e intervalo de início/fim. A execução só começa depois da confirmação explícita de direitos e do clique em **Gerar Clips**.
248
+
249
+ Os clips são gerados localmente com FFmpeg, redimensionados para o formato escolhido sem ampliar desnecessariamente a fonte e guardados em `storage/cuts/runs/<id>/`. A página apresenta estado de análise, preview dos resultados, download individual, download ZIP, manifesto JSON e histórico em `storage/state/cuts_runs.json`. A selecção automática de momentos virais por IA permanece um ponto de extensão: sem transcrição/provider configurado, o modo manual continua disponível e não são inventados resultados.
250
+
243
251
  ## Canais YouTube
244
252
 
245
- A aba **Canais** está dividida em dois fluxos independentes. Em **Importar do YouTube**, o método padrão **Página pública — sem API Key** consulta a página pública do canal e preenche nome, ID, handle, URL, descrição e thumbnail quando esses dados estão disponíveis. A opção **YouTube Data API — API Key opcional** pode ser escolhida para métricas oficiais quando uma **YouTube Data API Key própria** estiver configurada em **Configurações** ou em `YOUTUBE_API_KEY`; essa chave é distinta do OAuth Client ID e Client Secret.
253
+ A aba **Canais Youtube** está dividida em dois fluxos independentes. Em **Importar do YouTube**, o método padrão **Página pública — sem API Key** consulta a página pública do canal e preenche nome, ID, handle, URL, descrição e thumbnail quando esses dados estão disponíveis. A opção **YouTube Data API — API Key opcional** pode ser escolhida para métricas oficiais quando uma **YouTube Data API Key própria** estiver configurada em **Configurações** ou em `YOUTUBE_API_KEY`; essa chave é distinta do OAuth Client ID e Client Secret.
246
254
 
247
255
  Em **Cadastro manual**, nenhum pedido ao YouTube é feito e não existe qualquer dependência de API Key. O utilizador pode preencher nome, URL, handle, descrição, métricas, thumbnail, idioma, estilo, Blueprint padrão, voz padrão, `DELEGATED_SESSION_ID` e configuração de Automação. A importação pública nunca grava automaticamente: os dados aparecem num formulário de revisão antes de guardar. A resolução pública aceita URLs `/channel/UC...`, handles e subpáginas; quando existe um ID mas o HTML não traz todos os dados, tenta o feed RSS público. Se o YouTube responder que o canal não existe ou não fornecer metadados, a UI mostra uma mensagem clara e não mantém o formulário de uma pesquisa anterior. Cada cartão de canal tem **Activo** e, logo abaixo, **Apagar canal**, com confirmação; tarefas e artefactos não são apagados.
248
256
 
@@ -256,11 +264,11 @@ Use **Autorizar fallback OAuth** apenas se precisar de uma autorização separad
256
264
 
257
265
  A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Ela usa cookies, `sessionInfo`, `INNERTUBE_API_KEY` e `DELEGATED_SESSION_ID` fornecidos manualmente pelo utilizador, cria o vídeo através do endpoint interno e envia o ficheiro em chunks de 256 KiB. Este caminho é experimental, não extrai cookies automaticamente e fica separado do agente YouTube principal.
258
266
 
259
- ## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
267
+ ## Pipeline: Criação de Vídeos, Criação de Músicas e Automação Youtube
260
268
 
261
269
  Em **Criação de Vídeos**, o estilo visual `Pexels/Pixabay` é o modo de materiais, `full_ia` abre o selector **Estilo IA** com os 12 estilos solicitados e **Apenas Música** exige um áudio local, um upload musical ou um pedido ao endpoint Suno configurado. **Criação de Músicas** reutiliza o mesmo conteúdo e formulário com título próprio. Neste último modo, a task fica com `background_mode=none`: não são gerados fundos Pexels/Pixabay nem fundos IA.
262
270
 
263
- 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**, abra **Definir Blueprint e voz padrão** no cartão do canal para guardar os defaults; em **Automação**, 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** 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.
271
+ 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.
264
272
 
265
273
  ## Teste de vozes
266
274
 
package/app/main.py CHANGED
@@ -30,6 +30,7 @@ from app.modules.niche_finder.summarizer import summarize_items
30
30
  from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
31
31
  from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
32
32
  from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEditorError, change_speed, editor_manifest, extract_audio, list_edit_records as list_python_editor_records, list_generated_videos, list_scripts, list_video_files, read_script, remove_audio, replace_audio, resize_video, save_edit_record as save_python_editor_record, save_script, store_uploaded_asset, trim_video
33
+ from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips, list_generated_videos as list_cut_generated_videos, list_runs as list_cut_runs, list_video_files as list_cut_video_files, manifest_bytes as cut_manifest_bytes, store_uploaded_video, zip_run as zip_cut_run
33
34
  from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
34
35
  from hermes_ui.mcp_server import server_status, start_server, stop_server
35
36
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
@@ -150,7 +151,16 @@ st.markdown("""
150
151
  .content-label { color:#8ba6bb; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; }
151
152
  .content-value { color:#f4f8fb; font-size:1.8rem; font-weight:700; margin-top:.3rem; }
152
153
  .stage { border-left:3px solid #35a7ff; padding:.65rem .8rem; margin:.4rem 0; background:#101d2a; border-radius:8px; }
153
- .small-muted { color:#8ba6bb; font-size:.85rem; }
154
+ .small-muted { color:#8ba6bb; font-size:.85rem; }
155
+ .tb-cuts-hero { max-width:860px; margin:0 auto 1.1rem; padding:1.6rem 1.4rem 1.35rem; text-align:center; border:1px solid #263d50; border-radius:18px; background:radial-gradient(circle at 50% 0, rgba(164,126,55,.16), transparent 58%), linear-gradient(145deg, rgba(17,27,37,.98), rgba(9,15,22,.98)); box-shadow:0 18px 48px rgba(0,0,0,.18); }
156
+ .tb-cuts-hero .tb-cuts-kicker { color:#c59b55; font-size:.68rem; letter-spacing:.18em; text-transform:uppercase; font-weight:700; }
157
+ .tb-cuts-hero h2 { color:#f5f0e8; font-family:Georgia,serif; font-size:2rem; font-weight:500; margin:.42rem 0 .25rem; text-transform:lowercase; }
158
+ .tb-cuts-hero p { color:#9cafbf; margin:0 auto; max-width:620px; font-size:.9rem; }
159
+ [data-testid="stRadio"] [role="radiogroup"] { gap:.6rem; }
160
+ [data-testid="stRadio"] label { border:1px solid #2a4052; border-radius:12px; padding:.65rem .8rem; background:#101b25; min-height:4.3rem; }
161
+ [data-testid="stRadio"] label:has(input:checked) { border-color:#c59b55; background:linear-gradient(145deg, rgba(96,71,33,.42), rgba(17,27,37,.95)); }
162
+ [data-testid="stStatusWidget"] { border-color:#2a4052 !important; background:#101b25 !important; }
163
+
154
164
  /* Cores dos chips por identidade da plataforma, sem depender da ordem de selecção. */
155
165
  [data-testid="stMultiSelectTagsContainer"] span[data-tag] { color:#ffffff !important; border:0 !important; font-weight:700 !important; }
156
166
  [data-testid="stMultiSelectTagsContainer"] span[data-tag] span[title],
@@ -490,7 +500,7 @@ def render_dashboard():
490
500
 
491
501
 
492
502
  def render_blueprints():
493
- st.title("Blueprints")
503
+ st.title("Blueprints Youtube")
494
504
  st.caption(f"Biblioteca local lida directamente de `{BLUEPRINTS}`")
495
505
  blueprint_tab, branding_tab = st.tabs(["Blueprints", "Brandings"])
496
506
  with blueprint_tab:
@@ -588,7 +598,7 @@ def render_blueprints():
588
598
 
589
599
 
590
600
  def render_channels():
591
- st.title("Canais")
601
+ st.title("Canais Youtube")
592
602
  st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
593
603
  settings = read_json("settings.json", {})
594
604
  youtube = YouTubeAdapter(settings=settings)
@@ -1485,6 +1495,189 @@ def render_edit_placeholder(page_title: str, description: str):
1485
1495
  st.info("Esta aba está reservada para desenvolvimento futuro e ainda não executa nenhuma operação.")
1486
1496
 
1487
1497
 
1498
+ def render_cuts():
1499
+ st.title("Cortes")
1500
+ st.caption("Crie clips verticais, quadrados ou horizontais a partir de vídeos longos, com um fluxo local inspirado no Clip Generator do OpenShorts.")
1501
+
1502
+ st.markdown(
1503
+ "<div class='tb-cuts-hero'><div class='tb-cuts-kicker'>01 · CLIP GENERATOR</div><h2>Create Viral Shorts</h2><p>Escolha um vídeo longo, defina o formato e gere clips locais sem sobrescrever a fonte.</p></div>",
1504
+ unsafe_allow_html=True,
1505
+ )
1506
+
1507
+ source_path = None
1508
+ source_tab, url_tab, generated_tab, folder_tab = st.tabs(["Upload ficheiro", "URL de vídeo", "Vídeos gerados", "Pasta local"])
1509
+ with source_tab:
1510
+ uploaded_video = st.file_uploader(
1511
+ "Clique para carregar ou arraste um vídeo",
1512
+ type=sorted(extension.lstrip(".") for extension in VIDEO_EXTENSIONS),
1513
+ key="cuts_video_upload",
1514
+ help="MP4, MOV, MKV, WEBM e formatos suportados pelo FFmpeg. O original fica preservado.",
1515
+ )
1516
+ if uploaded_video is not None:
1517
+ try:
1518
+ source_path = store_uploaded_video(uploaded_video.name, uploaded_video.getvalue())
1519
+ st.session_state["cuts_source_path"] = str(source_path)
1520
+ st.session_state["cuts_source_label"] = f"Upload · {uploaded_video.name}"
1521
+ except CutsError as exc:
1522
+ st.error(str(exc))
1523
+ with url_tab:
1524
+ url_value = st.text_input("URL directa do vídeo", placeholder="https://exemplo.com/video.mp4", key="cuts_video_url")
1525
+ st.caption("A URL deve apontar directamente para um ficheiro de vídeo HTTP/HTTPS. O download só ocorre depois de clicar no botão.")
1526
+ if st.button("Descarregar vídeo", key="cuts_download_url", use_container_width=True):
1527
+ try:
1528
+ source_path = download_direct_video_url(url_value)
1529
+ st.session_state["cuts_source_path"] = str(source_path)
1530
+ st.session_state["cuts_source_label"] = f"URL · {url_value}"
1531
+ st.success("Vídeo descarregado e pronto para análise.")
1532
+ except CutsError as exc:
1533
+ st.error(str(exc))
1534
+ with generated_tab:
1535
+ generated_paths = list_cut_generated_videos(read_json("tasks.json", []))
1536
+ if not generated_paths:
1537
+ st.info("Ainda não existem vídeos gerados com caminho registado na pipeline.")
1538
+ else:
1539
+ generated_labels = [f"{path.name} — {path}" for path in generated_paths]
1540
+ selected_generated = st.selectbox("Vídeo gerado", range(len(generated_paths)), format_func=lambda index: generated_labels[index], key="cuts_generated_index")
1541
+ if st.button("Usar vídeo seleccionado", key="cuts_use_generated", use_container_width=True):
1542
+ source_path = generated_paths[selected_generated]
1543
+ st.session_state["cuts_source_path"] = str(source_path)
1544
+ st.session_state["cuts_source_label"] = f"Pipeline · {source_path.name}"
1545
+ with folder_tab:
1546
+ folder_value = st.text_input("Pasta de vídeos", value=str(STORAGE / "videos"), key="cuts_video_folder")
1547
+ folder_paths = list_cut_video_files(folder_value)
1548
+ if not folder_paths:
1549
+ st.info("Não foram encontrados vídeos nessa pasta.")
1550
+ else:
1551
+ folder_labels = [f"{path.name} — {path}" for path in folder_paths]
1552
+ selected_folder = st.selectbox("Vídeo da pasta", range(len(folder_paths)), format_func=lambda index: folder_labels[index], key="cuts_folder_index")
1553
+ if st.button("Usar vídeo da pasta", key="cuts_use_folder", use_container_width=True):
1554
+ source_path = folder_paths[selected_folder]
1555
+ st.session_state["cuts_source_path"] = str(source_path)
1556
+ st.session_state["cuts_source_label"] = f"Pasta local · {source_path.name}"
1557
+
1558
+ stored_source = st.session_state.get("cuts_source_path", "")
1559
+ if not source_path and stored_source:
1560
+ candidate = Path(stored_source)
1561
+ if candidate.is_file():
1562
+ source_path = candidate
1563
+ if source_path and source_path.is_file():
1564
+ st.markdown(f"**Fonte seleccionada:** `{st.session_state.get('cuts_source_label', source_path.name)}`")
1565
+ source_cols = st.columns([1.4, 1])
1566
+ with source_cols[0]:
1567
+ st.video(str(source_path))
1568
+ with source_cols[1]:
1569
+ st.caption(f"{source_path.name}")
1570
+ st.caption(f"{source_path.stat().st_size / (1024 * 1024):.2f} MB")
1571
+ st.caption("A fonte original não é alterada.")
1572
+ else:
1573
+ st.info("Escolha um ficheiro, descarregue uma URL, seleccione um vídeo gerado ou indique uma pasta local.")
1574
+
1575
+ with st.container(border=True):
1576
+ st.markdown("**Output format**")
1577
+ format_options = ["9:16", "1:1", "16:9"]
1578
+ output_format = st.radio(
1579
+ "Formato de saída",
1580
+ format_options,
1581
+ format_func=lambda value: {"9:16": "9:16\\nShorts · Reels · TikTok", "1:1": "1:1\\nFeed posts", "16:9": "16:9\\nYouTube · landscape"}[value],
1582
+ horizontal=True,
1583
+ key="cuts_output_format",
1584
+ label_visibility="collapsed",
1585
+ )
1586
+ with st.expander("advanced options", expanded=False):
1587
+ strategy_label = st.selectbox("Estratégia", ["Automático · segmentos locais", "Manual · um intervalo"], key="cuts_strategy")
1588
+ strategy = "manual" if strategy_label.startswith("Manual") else "automatic"
1589
+ options_cols = st.columns(3)
1590
+ with options_cols[0]:
1591
+ max_clips = st.number_input("Número máximo de clips", min_value=1, max_value=20, value=3, step=1, key="cuts_max_clips")
1592
+ with options_cols[1]:
1593
+ min_duration = st.number_input("Duração mínima (s)", min_value=1.0, max_value=600.0, value=15.0, step=1.0, key="cuts_min_duration")
1594
+ with options_cols[2]:
1595
+ max_duration = st.number_input("Duração máxima (s)", min_value=1.0, max_value=600.0, value=60.0, step=1.0, key="cuts_max_duration")
1596
+ manual_start = manual_end = 0.0
1597
+ if strategy == "manual":
1598
+ manual_cols = st.columns(2)
1599
+ with manual_cols[0]:
1600
+ manual_start = st.number_input("Início do intervalo (s)", min_value=0.0, value=0.0, step=0.5, key="cuts_manual_start")
1601
+ with manual_cols[1]:
1602
+ manual_end = st.number_input("Fim do intervalo (s)", min_value=0.5, value=30.0, step=0.5, key="cuts_manual_end")
1603
+ st.caption("O modo automático cria segmentos locais distribuídos pelo vídeo. A selecção viral por IA fica disponível como extensão quando houver transcrição/provider configurado.")
1604
+
1605
+ rights_confirmed = st.checkbox(
1606
+ "Confirmo que possuo os direitos ou autorização para processar este conteúdo.",
1607
+ key="cuts_rights_confirmed",
1608
+ )
1609
+ generate_button = st.button(
1610
+ "Gerar Clips",
1611
+ type="primary",
1612
+ use_container_width=True,
1613
+ disabled=not (source_path and source_path.is_file() and rights_confirmed),
1614
+ key="cuts_generate_button",
1615
+ )
1616
+
1617
+ if generate_button and source_path and source_path.is_file():
1618
+ settings = read_json("settings.json", {})
1619
+ with st.status("A analisar e a gerar clips…", expanded=True) as status:
1620
+ st.write("A validar fonte e parâmetros…")
1621
+ try:
1622
+ record = generate_clips(
1623
+ source_path,
1624
+ output_format=output_format,
1625
+ strategy=strategy,
1626
+ max_clips=int(max_clips),
1627
+ min_duration=float(min_duration),
1628
+ max_duration=float(max_duration),
1629
+ rights_confirmed=rights_confirmed,
1630
+ ffmpeg_path=settings.get("ffmpeg_path", ""),
1631
+ manual_start=float(manual_start),
1632
+ manual_end=float(manual_end) if strategy == "manual" else None,
1633
+ )
1634
+ st.session_state["cuts_last_run"] = record
1635
+ status.update(label="Clips gerados", state="complete", expanded=False)
1636
+ except CutsError as exc:
1637
+ status.update(label="A geração falhou", state="error", expanded=True)
1638
+ st.error(str(exc))
1639
+
1640
+ last_run = st.session_state.get("cuts_last_run")
1641
+ if last_run:
1642
+ st.divider()
1643
+ status_label = {"complete": "CONCLUÍDO", "processing": "A PROCESSAR", "error": "ERRO"}.get(last_run.get("status"), str(last_run.get("status", "—")).upper())
1644
+ st.markdown(f"### Live Analysis · `{status_label}`")
1645
+ if last_run.get("status") == "complete":
1646
+ clips = [clip for clip in last_run.get("clips", []) if Path(str(clip.get("path", ""))).is_file()]
1647
+ result_cols = st.columns([3, 1, 1])
1648
+ with result_cols[0]:
1649
+ st.subheader("Generated Shorts")
1650
+ with result_cols[1]:
1651
+ st.metric("Clips", len(clips))
1652
+ with result_cols[2]:
1653
+ st.metric("Formato", last_run.get("output_format", "—"))
1654
+ for index in range(0, len(clips), 3):
1655
+ clip_cols = st.columns(3)
1656
+ for col, clip in zip(clip_cols, clips[index:index + 3]):
1657
+ with col:
1658
+ with st.container(border=True):
1659
+ st.caption(f"Clip {clip.get('index', '—')} · {float(clip.get('duration', 0)):.1f}s")
1660
+ st.video(str(clip["path"]))
1661
+ st.download_button("Descarregar clip", data=Path(clip["path"]).read_bytes(), file_name=clip["name"], mime="video/mp4", key=f"cuts_download_{last_run['id']}_{clip['index']}", use_container_width=True)
1662
+ try:
1663
+ _, archive_bytes = zip_cut_run(last_run)
1664
+ st.download_button("Descarregar todos os clips (ZIP)", data=archive_bytes, file_name=f"{last_run['id']}.zip", mime="application/zip", key=f"cuts_download_zip_{last_run['id']}", use_container_width=True)
1665
+ except CutsError as exc:
1666
+ st.warning(str(exc))
1667
+ st.download_button("Descarregar manifesto JSON", data=cut_manifest_bytes(last_run), file_name=f"{last_run['id']}.json", mime="application/json", key=f"cuts_download_manifest_{last_run['id']}", use_container_width=True)
1668
+ elif last_run.get("error"):
1669
+ st.error(last_run["error"])
1670
+
1671
+ runs = list_cut_runs()
1672
+ if runs:
1673
+ with st.expander("Histórico do Clip Generator"):
1674
+ st.dataframe(
1675
+ [{"Data": run.get("created_at", "—"), "Fonte": run.get("source_name", "—"), "Formato": run.get("output_format", "—"), "Clips": len(run.get("clips", [])), "Estado": run.get("status", "—")} for run in runs[:20]],
1676
+ use_container_width=True,
1677
+ hide_index=True,
1678
+ )
1679
+
1680
+
1488
1681
  def render_python_editor():
1489
1682
  st.title("Editor Python")
1490
1683
  st.caption("Editor local inspirado no PYEdit para scripts Python e edição manual de vídeos do Thunderbolt.")
@@ -1651,7 +1844,7 @@ def render_videos():
1651
1844
 
1652
1845
 
1653
1846
  def render_automation():
1654
- st.title("Automação")
1847
+ st.title("Automação Youtube")
1655
1848
  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.")
1656
1849
  worker_status = load_worker_status()
1657
1850
  local_now = datetime.now().astimezone()
@@ -2601,8 +2794,8 @@ def main():
2601
2794
  ("Redes Sociais", ":material/share:", "Redes Sociais"),
2602
2795
  ]
2603
2796
  settings_items = [
2604
- ("Canais", ":material/ondemand_video:", "Canais"),
2605
- ("Blueprints", ":material/library_books:", "Blueprints"),
2797
+ ("Canais Youtube", ":material/ondemand_video:", "Canais Youtube"),
2798
+ ("Blueprints Youtube", ":material/library_books:", "Blueprints Youtube"),
2606
2799
  ("MCP", ":material/hub:", "MCP"),
2607
2800
  ("Configurações Técnicas", ":material/settings:", "Configurações Técnicas"),
2608
2801
  ]
@@ -2610,6 +2803,9 @@ def main():
2610
2803
  ("Niche Finder Kaggle", ":material/search:", "Niche Finder Kaggle"),
2611
2804
  ("Niche Finder Apify", ":material/api:", "Niche Finder Apify"),
2612
2805
  ]
2806
+ automation_items = [
2807
+ ("Automação Youtube", ":material/schedule:", "Automação Youtube"),
2808
+ ]
2613
2809
  top_pages = [
2614
2810
  ("Início", ":material/home:", "Início"),
2615
2811
  ("Niche Finder", ":material/search:", "Niche Finder"),
@@ -2625,10 +2821,13 @@ def main():
2625
2821
  "Vídeos": "Criação de Vídeos",
2626
2822
  "Limpador de metadado": "Limpador de Metadados",
2627
2823
  "Niche Finder": "Niche Finder Kaggle",
2824
+ "Automação": "Automação Youtube",
2825
+ "Canais": "Canais Youtube",
2826
+ "Blueprints": "Blueprints Youtube",
2628
2827
  "Configurações Técnicas": "Configurações Técnicas",
2629
2828
  }
2630
2829
  current_page = aliases.get(st.session_state.get("page", "Início"), st.session_state.get("page", "Início"))
2631
- if current_page not in {item[0] for item in top_pages + pipeline_items + edition_items + models_ai_items + niche_finder_items + settings_items}:
2830
+ if current_page not in {item[0] for item in top_pages + pipeline_items + automation_items + edition_items + models_ai_items + niche_finder_items + settings_items}:
2632
2831
  current_page = "Início"
2633
2832
  st.session_state["page"] = current_page
2634
2833
 
@@ -2648,6 +2847,10 @@ def main():
2648
2847
  with st.expander("Pipeline", expanded=current_page in {item[0] for item in pipeline_items}, icon=":material/account_tree:"):
2649
2848
  for child_target, child_icon, child_label in pipeline_items:
2650
2849
  render_nav_button(child_target, child_icon, child_label, child=True)
2850
+ elif target == "Automação":
2851
+ with st.expander("Automação", expanded=current_page in {item[0] for item in automation_items}, icon=":material/schedule:"):
2852
+ for child_target, child_icon, child_label in automation_items:
2853
+ render_nav_button(child_target, child_icon, child_label, child=True)
2651
2854
  elif target == "Edição":
2652
2855
  with st.expander("Edição", expanded=current_page in {item[0] for item in edition_items}, icon=":material/edit:"):
2653
2856
  for child_target, child_icon, child_label in edition_items:
@@ -2671,19 +2874,19 @@ def main():
2671
2874
  "Início": render_dashboard,
2672
2875
  "Criação de Vídeos": render_new_video,
2673
2876
  "Criação de Músicas": render_music_creation,
2674
- "Automação": render_automation,
2877
+ "Automação Youtube": render_automation,
2675
2878
  "Niche Finder Kaggle": render_niche_finder,
2676
2879
  "Niche Finder Apify": render_niche_finder_apify,
2677
2880
  "Edição": lambda: render_edit_placeholder("Edição", "Seleccione uma das abas de edição no menu expansível."),
2678
2881
  "Limpador de Metadados": render_metadata_cleaner,
2679
- "Cortes": lambda: render_edit_placeholder("Cortes", "Área reservada para a futura funcionalidade de cortes de vídeo."),
2882
+ "Cortes": render_cuts,
2680
2883
  "Editor Python": render_python_editor,
2681
2884
  "Models AI": lambda: render_edit_placeholder("Models AI", "Seleccione uma das abas Models AI no menu expansível."),
2682
2885
  "Personagens": lambda: render_edit_placeholder("Personagens", "Área reservada para a futura funcionalidade de personagens."),
2683
2886
  "Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
2684
2887
  "Upload": render_upload,
2685
- "Canais": render_channels,
2686
- "Blueprints": render_blueprints,
2888
+ "Canais Youtube": render_channels,
2889
+ "Blueprints Youtube": render_blueprints,
2687
2890
  "MCP": render_mcp,
2688
2891
  "Configurações Técnicas": render_settings,
2689
2892
  }
@@ -0,0 +1,454 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import unicodedata
9
+ import uuid
10
+ import zipfile
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Iterable
14
+ from urllib.parse import urlparse
15
+
16
+ import requests
17
+
18
+ from . import storage
19
+ from .metadata_cleaner import VIDEO_EXTENSIONS, _resolve_ffmpeg
20
+
21
+
22
+ class CutsError(RuntimeError):
23
+ """Raised when a local clip-generation operation cannot be completed."""
24
+
25
+
26
+ OUTPUT_FORMATS: dict[str, dict[str, Any]] = {
27
+ "9:16": {"width": 1080, "height": 1920, "label": "9:16 · Shorts / Reels / TikTok"},
28
+ "1:1": {"width": 1080, "height": 1080, "label": "1:1 · Feed posts"},
29
+ "16:9": {"width": 1920, "height": 1080, "label": "16:9 · YouTube / landscape"},
30
+ }
31
+
32
+ VIDEO_SUFFIXES = {f".{extension.lower().lstrip('.')}" for extension in VIDEO_EXTENSIONS}
33
+ MAX_SOURCE_BYTES = 500 * 1024 * 1024
34
+
35
+
36
+ def _now() -> str:
37
+ return datetime.now(timezone.utc).isoformat()
38
+
39
+
40
+ def _root() -> Path:
41
+ path = storage.STORAGE / "cuts"
42
+ path.mkdir(parents=True, exist_ok=True)
43
+ return path
44
+
45
+
46
+ def _inputs() -> Path:
47
+ path = _root() / "inputs"
48
+ path.mkdir(parents=True, exist_ok=True)
49
+ return path
50
+
51
+
52
+ def _runs_root() -> Path:
53
+ path = _root() / "runs"
54
+ path.mkdir(parents=True, exist_ok=True)
55
+ return path
56
+
57
+
58
+ def _safe_filename(name: str, fallback: str = "video") -> str:
59
+ normalized = unicodedata.normalize("NFKD", Path(name).name)
60
+ ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
61
+ safe = re.sub(r"[^A-Za-z0-9._-]+", "-", ascii_name).strip(".-")
62
+ return safe or fallback
63
+
64
+
65
+ def _validate_video(source: Path) -> Path:
66
+ candidate = Path(source).expanduser()
67
+ if not candidate.is_file():
68
+ raise CutsError("O vídeo seleccionado não existe ou não é um ficheiro.")
69
+ if candidate.suffix.lower() not in VIDEO_SUFFIXES:
70
+ raise CutsError("O vídeo seleccionado não tem uma extensão suportada.")
71
+ return candidate.resolve()
72
+
73
+
74
+ def _safe_float(value: Any, fallback: float = 0.0) -> float:
75
+ try:
76
+ return float(value)
77
+ except (TypeError, ValueError):
78
+ return fallback
79
+
80
+
81
+ def normalize_output_format(output_format: str) -> str:
82
+ key = str(output_format or "").strip()
83
+ if key not in OUTPUT_FORMATS:
84
+ raise CutsError("Seleccione um formato de saída válido: 9:16, 1:1 ou 16:9.")
85
+ return key
86
+
87
+
88
+ def store_uploaded_video(filename: str, content: bytes) -> Path:
89
+ if not content:
90
+ raise CutsError("O ficheiro de vídeo está vazio.")
91
+ suffix = Path(filename).suffix.lower()
92
+ if suffix not in VIDEO_SUFFIXES:
93
+ raise CutsError("O ficheiro enviado não tem uma extensão de vídeo suportada.")
94
+ digest = hashlib.sha256(content).hexdigest()[:16]
95
+ existing = next(iter(sorted(_inputs().glob(f"{digest}-*"))), None)
96
+ if existing and existing.is_file():
97
+ return existing.resolve()
98
+ target = _inputs() / f"{digest}-{_safe_filename(filename, 'video')}"
99
+ if not target.exists():
100
+ target.write_bytes(content)
101
+ return target.resolve()
102
+
103
+
104
+ def list_video_files(folder: str | Path) -> list[Path]:
105
+ candidate = Path(folder).expanduser()
106
+ if not candidate.exists():
107
+ return []
108
+ if candidate.is_file():
109
+ return [candidate.resolve()] if candidate.suffix.lower() in VIDEO_SUFFIXES else []
110
+ try:
111
+ return sorted(
112
+ (path.resolve() for path in candidate.iterdir() if path.is_file() and path.suffix.lower() in VIDEO_SUFFIXES),
113
+ key=lambda path: path.stat().st_mtime,
114
+ reverse=True,
115
+ )
116
+ except OSError as exc:
117
+ raise CutsError(f"Não foi possível ler a pasta de vídeos: {exc}") from exc
118
+
119
+
120
+ def list_generated_videos(tasks: Iterable[dict[str, Any]]) -> list[Path]:
121
+ paths: dict[str, Path] = {}
122
+ for task in tasks:
123
+ artifacts = task.get("artifacts") or {}
124
+ value = artifacts.get("video")
125
+ if isinstance(value, str) and value.strip():
126
+ candidate = Path(value).expanduser()
127
+ if candidate.is_file() and candidate.suffix.lower() in VIDEO_SUFFIXES:
128
+ paths[str(candidate.resolve())] = candidate.resolve()
129
+ return sorted(paths.values(), key=lambda path: path.stat().st_mtime, reverse=True)
130
+
131
+
132
+ def resolve_ffmpeg(configured_path: str | None = None) -> str:
133
+ try:
134
+ return _resolve_ffmpeg(configured_path)
135
+ except RuntimeError as exc:
136
+ raise CutsError(str(exc)) from exc
137
+
138
+
139
+ def resolve_ffprobe(configured_path: str | None = None) -> str | None:
140
+ configured = str(configured_path or "").strip()
141
+ candidates: list[str] = []
142
+ if configured:
143
+ configured_path_obj = Path(configured).expanduser()
144
+ if configured_path_obj.name.lower().startswith("ffmpeg"):
145
+ candidates.append(str(configured_path_obj.with_name("ffprobe" + configured_path_obj.suffix)))
146
+ candidates.append(configured)
147
+ detected = shutil.which("ffprobe")
148
+ if detected:
149
+ candidates.append(detected)
150
+ for candidate in candidates:
151
+ resolved = shutil.which(candidate) or (candidate if Path(candidate).is_file() else None)
152
+ if resolved:
153
+ return str(resolved)
154
+ return None
155
+
156
+
157
+ def probe_duration(source: Path, *, ffprobe_path: str | None = None) -> float | None:
158
+ source = _validate_video(source)
159
+ binary = resolve_ffprobe(ffprobe_path)
160
+ if not binary:
161
+ return None
162
+ command = [binary, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(source)]
163
+ try:
164
+ completed = subprocess.run(command, capture_output=True, text=True, check=False)
165
+ except OSError:
166
+ return None
167
+ if completed.returncode != 0:
168
+ return None
169
+ duration = _safe_float(completed.stdout.strip(), 0.0)
170
+ return duration if duration > 0 else None
171
+
172
+
173
+ def validate_cut_request(
174
+ *,
175
+ source: Path,
176
+ output_format: str,
177
+ strategy: str,
178
+ max_clips: int,
179
+ min_duration: float,
180
+ max_duration: float,
181
+ rights_confirmed: bool,
182
+ manual_start: float = 0.0,
183
+ manual_end: float | None = None,
184
+ ) -> dict[str, Any]:
185
+ source = _validate_video(source)
186
+ output_format = normalize_output_format(output_format)
187
+ if strategy not in {"automatic", "manual"}:
188
+ raise CutsError("Seleccione uma estratégia de corte válida.")
189
+ if not rights_confirmed:
190
+ raise CutsError("Confirme que possui os direitos ou autorização para processar este conteúdo.")
191
+ max_clips = int(max_clips)
192
+ if max_clips < 1 or max_clips > 20:
193
+ raise CutsError("A quantidade de clips deve estar entre 1 e 20.")
194
+ min_duration = _safe_float(min_duration)
195
+ max_duration = _safe_float(max_duration)
196
+ if min_duration < 1 or max_duration > 600 or max_duration < min_duration:
197
+ raise CutsError("Defina uma duração válida: mínimo ≥ 1s, máximo ≤ 600s e máximo ≥ mínimo.")
198
+ manual_start = _safe_float(manual_start)
199
+ if manual_start < 0:
200
+ raise CutsError("O início manual não pode ser negativo.")
201
+ normalized_end = None if manual_end is None else _safe_float(manual_end)
202
+ if strategy == "manual" and (normalized_end is None or normalized_end <= manual_start):
203
+ raise CutsError("No modo manual, o fim deve ser superior ao início.")
204
+ return {
205
+ "source": str(source),
206
+ "source_name": source.name,
207
+ "output_format": output_format,
208
+ "strategy": strategy,
209
+ "max_clips": max_clips,
210
+ "min_duration": min_duration,
211
+ "max_duration": max_duration,
212
+ "manual_start": manual_start,
213
+ "manual_end": normalized_end,
214
+ "rights_confirmed": True,
215
+ }
216
+
217
+
218
+ def plan_segments(
219
+ duration: float | None,
220
+ *,
221
+ strategy: str,
222
+ max_clips: int,
223
+ min_duration: float,
224
+ max_duration: float,
225
+ manual_start: float = 0.0,
226
+ manual_end: float | None = None,
227
+ ) -> list[dict[str, float]]:
228
+ if strategy == "manual":
229
+ if manual_end is None or manual_end <= manual_start:
230
+ raise CutsError("No modo manual, o fim deve ser superior ao início.")
231
+ return [{"index": 1, "start": round(float(manual_start), 3), "end": round(float(manual_end), 3), "duration": round(float(manual_end - manual_start), 3)}]
232
+ if duration is None or duration <= 0:
233
+ raise CutsError("Não foi possível detectar a duração do vídeo. Use o modo manual ou instale/configure FFprobe.")
234
+ if duration < min_duration:
235
+ raise CutsError(f"O vídeo tem apenas {duration:.1f}s e é menor que a duração mínima configurada.")
236
+ count = min(int(max_clips), max(1, int(duration // min_duration)))
237
+ clip_duration = min(float(max_duration), max(float(min_duration), duration / count))
238
+ if clip_duration > duration:
239
+ clip_duration = duration
240
+ if count == 1:
241
+ starts = [0.0]
242
+ else:
243
+ starts = [(duration - clip_duration) * index / (count - 1) for index in range(count)]
244
+ return [
245
+ {"index": index + 1, "start": round(start, 3), "end": round(min(duration, start + clip_duration), 3), "duration": round(min(duration, start + clip_duration) - start, 3)}
246
+ for index, start in enumerate(starts)
247
+ ]
248
+
249
+
250
+ def _video_filter(output_format: str) -> str:
251
+ dimensions = OUTPUT_FORMATS[normalize_output_format(output_format)]
252
+ width = dimensions["width"]
253
+ height = dimensions["height"]
254
+ return f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
255
+
256
+
257
+ def build_clip_command(source: Path, output: Path, segment: dict[str, float], output_format: str, *, ffmpeg_path: str) -> list[str]:
258
+ start = max(0.0, float(segment["start"]))
259
+ duration = max(0.1, float(segment["duration"]))
260
+ return [
261
+ ffmpeg_path,
262
+ "-y",
263
+ "-hide_banner",
264
+ "-loglevel",
265
+ "error",
266
+ "-ss",
267
+ f"{start:.3f}",
268
+ "-t",
269
+ f"{duration:.3f}",
270
+ "-i",
271
+ str(source),
272
+ "-map",
273
+ "0:v:0",
274
+ "-map",
275
+ "0:a?",
276
+ "-vf",
277
+ _video_filter(output_format),
278
+ "-c:v",
279
+ "libx264",
280
+ "-preset",
281
+ "veryfast",
282
+ "-crf",
283
+ "22",
284
+ "-c:a",
285
+ "aac",
286
+ "-movflags",
287
+ "+faststart",
288
+ str(output),
289
+ ]
290
+
291
+
292
+ def _run_ffmpeg(command: list[str], output: Path, *, source: Path, segment: dict[str, float]) -> dict[str, Any]:
293
+ try:
294
+ completed = subprocess.run(command, capture_output=True, text=True, check=False)
295
+ except OSError as exc:
296
+ raise CutsError(f"Não foi possível iniciar FFmpeg: {exc}") from exc
297
+ if completed.returncode != 0 or not output.is_file() or output.stat().st_size == 0:
298
+ if output.exists():
299
+ output.unlink()
300
+ detail = (completed.stderr or completed.stdout or "erro desconhecido").strip()
301
+ raise CutsError(f"O clip não pôde ser gerado: {detail[-1200:]}")
302
+ return {"source": str(source), "output": str(output), "command": command, "segment": segment, "created_at": _now()}
303
+
304
+
305
+ def _write_manifest(record: dict[str, Any], run_dir: Path) -> Path:
306
+ path = run_dir / "manifest.json"
307
+ path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
308
+ return path
309
+
310
+
311
+ def generate_clips(
312
+ source: Path,
313
+ *,
314
+ output_format: str,
315
+ strategy: str,
316
+ max_clips: int,
317
+ min_duration: float,
318
+ max_duration: float,
319
+ rights_confirmed: bool,
320
+ ffmpeg_path: str | None = None,
321
+ manual_start: float = 0.0,
322
+ manual_end: float | None = None,
323
+ ) -> dict[str, Any]:
324
+ request = validate_cut_request(
325
+ source=source,
326
+ output_format=output_format,
327
+ strategy=strategy,
328
+ max_clips=max_clips,
329
+ min_duration=min_duration,
330
+ max_duration=max_duration,
331
+ rights_confirmed=rights_confirmed,
332
+ manual_start=manual_start,
333
+ manual_end=manual_end,
334
+ )
335
+ duration = probe_duration(source)
336
+ segments = plan_segments(
337
+ duration,
338
+ strategy=strategy,
339
+ max_clips=max_clips,
340
+ min_duration=min_duration,
341
+ max_duration=max_duration,
342
+ manual_start=manual_start,
343
+ manual_end=manual_end,
344
+ )
345
+ run_id = f"cut_{datetime.now().strftime('%Y%m%d-%H%M%S')}_{uuid.uuid4().hex[:8]}"
346
+ run_dir = _runs_root() / run_id
347
+ run_dir.mkdir(parents=True, exist_ok=True)
348
+ record: dict[str, Any] = {
349
+ "id": run_id,
350
+ "created_at": _now(),
351
+ "status": "processing",
352
+ "source": request["source"],
353
+ "source_name": request["source_name"],
354
+ "source_duration": duration,
355
+ "output_format": output_format,
356
+ "strategy": strategy,
357
+ "parameters": {key: value for key, value in request.items() if key not in {"source", "source_name"}},
358
+ "clips": [],
359
+ "error": "",
360
+ }
361
+ try:
362
+ binary = resolve_ffmpeg(ffmpeg_path)
363
+ for segment in segments:
364
+ output = run_dir / f"clip-{int(segment['index']):02d}-{_safe_filename(source.stem, 'video')}-{output_format.replace(':', 'x')}.mp4"
365
+ command = build_clip_command(source, output, segment, output_format, ffmpeg_path=binary)
366
+ run_info = _run_ffmpeg(command, output, source=source, segment=segment)
367
+ record["clips"].append({
368
+ "index": int(segment["index"]),
369
+ "path": str(output),
370
+ "name": output.name,
371
+ "start": segment["start"],
372
+ "end": segment["end"],
373
+ "duration": segment["duration"],
374
+ "size_bytes": output.stat().st_size,
375
+ "command": run_info["command"],
376
+ })
377
+ record["status"] = "complete"
378
+ except (CutsError, OSError, ValueError) as exc:
379
+ record["status"] = "error"
380
+ record["error"] = str(exc)
381
+ _write_manifest(record, run_dir)
382
+ storage.append_json("cuts_runs.json", record)
383
+ if record["status"] == "error":
384
+ raise CutsError(record["error"])
385
+ return record
386
+
387
+
388
+ def list_runs() -> list[dict[str, Any]]:
389
+ records = storage.read_json("cuts_runs.json", [])
390
+ return list(reversed(records)) if isinstance(records, list) else []
391
+
392
+
393
+ def manifest_bytes(record: dict[str, Any]) -> bytes:
394
+ return (json.dumps(record, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
395
+
396
+
397
+ def zip_run(record: dict[str, Any]) -> tuple[Path, bytes]:
398
+ run_id = _safe_filename(str(record.get("id") or "run"), "run")
399
+ run_dir = _runs_root() / run_id
400
+ if not run_dir.is_dir():
401
+ raise CutsError("A execução seleccionada já não existe no storage local.")
402
+ archive = run_dir / f"{run_id}.zip"
403
+ with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as handle:
404
+ for clip in record.get("clips", []):
405
+ path = Path(str(clip.get("path") or ""))
406
+ if path.is_file() and run_dir.resolve() in path.resolve().parents:
407
+ handle.write(path, arcname=path.name)
408
+ manifest = run_dir / "manifest.json"
409
+ if manifest.is_file():
410
+ handle.write(manifest, arcname=manifest.name)
411
+ return archive, archive.read_bytes()
412
+
413
+
414
+ def download_direct_video_url(url: str, *, timeout: int = 30) -> Path:
415
+ parsed = urlparse(str(url).strip())
416
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
417
+ raise CutsError("Informe uma URL HTTP/HTTPS directa para um ficheiro de vídeo.")
418
+ try:
419
+ response = requests.get(url, stream=True, timeout=timeout, headers={"User-Agent": "Thunderbolt/0.2"})
420
+ response.raise_for_status()
421
+ except requests.RequestException as exc:
422
+ raise CutsError(f"Não foi possível descarregar a URL do vídeo: {exc}") from exc
423
+ content_length = _safe_float(response.headers.get("content-length"), 0.0)
424
+ if content_length > MAX_SOURCE_BYTES:
425
+ raise CutsError("A fonte excede o limite de 500 MB.")
426
+ suffix = Path(parsed.path).suffix.lower()
427
+ if suffix not in VIDEO_SUFFIXES:
428
+ content_type = str(response.headers.get("content-type") or "").lower()
429
+ suffix = ".mp4" if "mp4" in content_type or "video" in content_type else ""
430
+ if suffix not in VIDEO_SUFFIXES:
431
+ raise CutsError("A URL deve apontar para um ficheiro de vídeo MP4, MOV ou formato suportado.")
432
+ digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
433
+ target = _inputs() / f"{digest}-url{suffix}"
434
+ if target.exists():
435
+ return target.resolve()
436
+ total = 0
437
+ temporary = target.with_suffix(target.suffix + ".part")
438
+ try:
439
+ with temporary.open("wb") as handle:
440
+ for chunk in response.iter_content(chunk_size=1024 * 1024):
441
+ if not chunk:
442
+ continue
443
+ total += len(chunk)
444
+ if total > MAX_SOURCE_BYTES:
445
+ raise CutsError("A fonte excede o limite de 500 MB.")
446
+ handle.write(chunk)
447
+ temporary.replace(target)
448
+ except CutsError:
449
+ temporary.unlink(missing_ok=True)
450
+ raise
451
+ except OSError as exc:
452
+ temporary.unlink(missing_ok=True)
453
+ raise CutsError(f"Não foi possível guardar o vídeo descarregado: {exc}") from exc
454
+ return target.resolve()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.52",
3
+ "version": "0.2.54",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",