@danhachuel/thunderbolt 0.2.53 → 0.2.55

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.53
5
+ > **Versão deste manual:** 0.2.55
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.53 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.55 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.53 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.55 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.
@@ -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.53 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.55 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.
@@ -456,10 +458,12 @@ Depois do tópico, clique em **Gerar títulos e thumbnails com IA**. O Thunderbo
456
458
 
457
459
  No modo **Lote geral**, todos os canais cadastrados são incluídos automaticamente. Não existe selector **Canais incluídos**. O Thunderbolt cria exactamente uma task por canal e cada task recebe briefing, título, thumbnail, Blueprint, voz e contexto próprios. O mesmo tópico não é replicado entre canais.
458
460
 
459
- ## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
461
+ ## Pipeline: Criação de Vídeos, Criação de Músicas, Roteiros e Automação
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
 
465
+ 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.
466
+
463
467
  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
468
 
465
469
  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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ A primeira versão implementa a camada UI independente com:
11
11
  | Área | Incluído |
12
12
  |---|---|
13
13
  | Início | Resumo de canais, tarefas, backlog, execução e falhas, com as filas do Pipeline inline |
14
- | Pipeline | Menu expansível com Criação de Vídeos, Criação de Músicas e Upload |
14
+ | Pipeline | Menu expansível com Criação de Vídeos, Criação de Músicas, Roteiros e Upload |
15
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
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 |
@@ -19,7 +19,7 @@ A primeira versão implementa a camada UI independente com:
19
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,9 +38,9 @@ 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.53
41
+ ## Navegação da UI 0.2.55
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**. **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.
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**, **Roteiros** 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
 
@@ -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.55 install
165
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.55 doctor
166
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.55
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.55 install`; o instalador detecta e reutiliza componentes já válidos.
191
191
 
192
192
  ## Editor Python baseado no PYEdit
193
193
 
@@ -240,6 +240,14 @@ 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
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.
@@ -260,6 +268,10 @@ A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https:/
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
 
271
+ A subaba **Roteiros**, entre **Criação de Músicas** e **Upload**, permite escolher um canal opcional, um Blueprint, o tipo **Roteiro de vídeo** ou **Letra de música**, idioma, briefing e notas de estrutura. O botão **Gerar com IA a partir do Blueprint** usa o provider LLM configurado, devolve um rascunho Markdown editável e só guarda o documento quando o utilizador confirmar. Os ficheiros ficam em `storage/scripts/` e o índice do histórico em `storage/state/scripts.json`; a própria página mostra o caminho absoluto correspondente ao storage local.
272
+
273
+ 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.
274
+
263
275
  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
276
 
265
277
  ## Teste de vozes
package/app/main.py CHANGED
@@ -30,9 +30,12 @@ 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
37
+ from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
38
+ from hermes_ui.script_generation import generate_script_document
36
39
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
37
40
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
38
41
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
@@ -150,7 +153,16 @@ st.markdown("""
150
153
  .content-label { color:#8ba6bb; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; }
151
154
  .content-value { color:#f4f8fb; font-size:1.8rem; font-weight:700; margin-top:.3rem; }
152
155
  .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; }
156
+ .small-muted { color:#8ba6bb; font-size:.85rem; }
157
+ .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); }
158
+ .tb-cuts-hero .tb-cuts-kicker { color:#c59b55; font-size:.68rem; letter-spacing:.18em; text-transform:uppercase; font-weight:700; }
159
+ .tb-cuts-hero h2 { color:#f5f0e8; font-family:Georgia,serif; font-size:2rem; font-weight:500; margin:.42rem 0 .25rem; text-transform:lowercase; }
160
+ .tb-cuts-hero p { color:#9cafbf; margin:0 auto; max-width:620px; font-size:.9rem; }
161
+ [data-testid="stRadio"] [role="radiogroup"] { gap:.6rem; }
162
+ [data-testid="stRadio"] label { border:1px solid #2a4052; border-radius:12px; padding:.65rem .8rem; background:#101b25; min-height:4.3rem; }
163
+ [data-testid="stRadio"] label:has(input:checked) { border-color:#c59b55; background:linear-gradient(145deg, rgba(96,71,33,.42), rgba(17,27,37,.95)); }
164
+ [data-testid="stStatusWidget"] { border-color:#2a4052 !important; background:#101b25 !important; }
165
+
154
166
  /* Cores dos chips por identidade da plataforma, sem depender da ordem de selecção. */
155
167
  [data-testid="stMultiSelectTagsContainer"] span[data-tag] { color:#ffffff !important; border:0 !important; font-weight:700 !important; }
156
168
  [data-testid="stMultiSelectTagsContainer"] span[data-tag] span[title],
@@ -1229,6 +1241,150 @@ def render_music_creation():
1229
1241
  render_new_video(page_title="Criação de Músicas")
1230
1242
 
1231
1243
 
1244
+ def render_scripts():
1245
+ st.title("Roteiros")
1246
+ st.caption("Produza e guarde roteiros de vídeos ou letras de músicas a partir dos Blueprints do Thunderbolt.")
1247
+ script_dir = script_storage_path()
1248
+ st.info(f"**Ficheiros guardados em:** `{script_dir}` · o conteúdo fica no storage local do Thunderbolt e não é enviado automaticamente para plataformas.")
1249
+
1250
+ create_tab, history_tab = st.tabs(["Novo roteiro/letra", "Histórico guardado"])
1251
+ with create_tab:
1252
+ all_channels = [channel for channel in read_json("channels.json", []) if isinstance(channel, dict)]
1253
+ active_channels = [channel for channel in all_channels if channel.get("active", True)]
1254
+ channel_options: list[dict[str, Any] | None] = [None] + active_channels
1255
+ selected_channel = st.selectbox(
1256
+ "Canal (opcional)",
1257
+ channel_options,
1258
+ format_func=lambda channel: "Documento independente" if channel is None else str(channel.get("name") or "Canal sem nome"),
1259
+ key="script_channel",
1260
+ )
1261
+ blueprint_options = blueprint_catalog()
1262
+ blueprint_ids = [identifier for identifier, _label in blueprint_options]
1263
+ blueprint_labels = {identifier: label for identifier, label in blueprint_options}
1264
+ selected_blueprint_id = st.selectbox(
1265
+ "Blueprint",
1266
+ blueprint_ids,
1267
+ format_func=lambda identifier: blueprint_labels.get(identifier, "Sem Blueprint padrão"),
1268
+ key="script_blueprint",
1269
+ )
1270
+ selected_blueprint: dict[str, Any] = {}
1271
+ if selected_blueprint_id:
1272
+ for path in list_blueprint_files():
1273
+ try:
1274
+ blueprint = load_blueprint_file(path)
1275
+ except (OSError, ValueError, json.JSONDecodeError):
1276
+ continue
1277
+ identifiers = {str(blueprint.get("id") or ""), path.stem, str(blueprint.get("name") or "")}
1278
+ if selected_blueprint_id in identifiers:
1279
+ selected_blueprint = dict(blueprint)
1280
+ selected_blueprint.setdefault("id", selected_blueprint_id)
1281
+ selected_blueprint.setdefault("name", blueprint_labels.get(selected_blueprint_id, selected_blueprint_id))
1282
+ break
1283
+ if selected_blueprint:
1284
+ st.caption(f"Blueprint aplicado ao contexto: **{selected_blueprint.get('name', selected_blueprint_id)}**")
1285
+ else:
1286
+ st.warning("Sem Blueprint seleccionado: o documento pode ser criado, mas não terá contexto editorial de Blueprint.")
1287
+
1288
+ document_type = st.radio("Tipo de documento", ["Roteiro de vídeo", "Letra de música"], horizontal=True, key="script_document_type")
1289
+ title = st.text_input("Título", key="script_title", placeholder="Ex.: A verdade esquecida sobre…")
1290
+ brief = st.text_area(
1291
+ "Tema ou briefing",
1292
+ key="script_brief",
1293
+ height=120,
1294
+ placeholder="Descreva o tema, a mensagem, o conflito ou a ideia musical que o Blueprint deve orientar.",
1295
+ )
1296
+ language_options = VIDEO_LANGUAGE_OPTIONS
1297
+ language = st.selectbox("Idioma", language_options, key="script_language")
1298
+ structure_notes = st.text_area(
1299
+ "Estrutura e notas opcionais",
1300
+ key="script_structure_notes",
1301
+ height=90,
1302
+ placeholder="Ex.: 6 cenas, narração documental, refrão repetível, atmosfera sombria…",
1303
+ )
1304
+ generate_col, clear_col = st.columns([1.4, 1])
1305
+ with generate_col:
1306
+ generate_clicked = st.button("Gerar com IA a partir do Blueprint", type="primary", use_container_width=True, key="generate_script_document")
1307
+ with clear_col:
1308
+ clear_clicked = st.button("Limpar rascunho", use_container_width=True, key="clear_script_document")
1309
+ if clear_clicked:
1310
+ for key in ("script_draft", "script_draft_title", "script_draft_content", "script_draft_summary"):
1311
+ st.session_state.pop(key, None)
1312
+ st.rerun()
1313
+ if generate_clicked:
1314
+ try:
1315
+ with st.spinner("A gerar o documento com o provider LLM configurado…"):
1316
+ generated = generate_script_document(
1317
+ read_json("settings.json", {}),
1318
+ document_type=document_type,
1319
+ title=title,
1320
+ brief=brief,
1321
+ language=language,
1322
+ channel=selected_channel or {},
1323
+ blueprint=selected_blueprint,
1324
+ structure_notes=structure_notes,
1325
+ )
1326
+ st.session_state["script_draft"] = generated
1327
+ st.session_state["script_draft_title"] = generated["title"]
1328
+ st.session_state["script_draft_summary"] = generated.get("summary", "")
1329
+ st.session_state["script_draft_content"] = generated["content"]
1330
+ st.success("Rascunho gerado. Reveja e edite o texto antes de guardar.")
1331
+ except CreativeGenerationError as exc:
1332
+ st.error(str(exc))
1333
+
1334
+ draft = st.session_state.get("script_draft")
1335
+ if draft:
1336
+ st.divider()
1337
+ st.subheader("Rascunho editável")
1338
+ if "script_draft_title" not in st.session_state:
1339
+ st.session_state["script_draft_title"] = str(draft.get("title") or title or "Documento")
1340
+ if "script_draft_summary" not in st.session_state:
1341
+ st.session_state["script_draft_summary"] = str(draft.get("summary") or "")
1342
+ if "script_draft_content" not in st.session_state:
1343
+ st.session_state["script_draft_content"] = str(draft.get("content") or "")
1344
+ draft_title = st.text_input("Título do rascunho", key="script_draft_title")
1345
+ draft_summary = st.text_input("Resumo", key="script_draft_summary")
1346
+ draft_content = st.text_area("Conteúdo guardado", height=460, key="script_draft_content")
1347
+ if st.button("Guardar documento no storage", type="primary", use_container_width=True, key="save_script_document"):
1348
+ try:
1349
+ record = save_script_document(
1350
+ {
1351
+ **draft,
1352
+ "title": draft_title,
1353
+ "summary": draft_summary,
1354
+ "content": draft_content,
1355
+ "document_type": "video_script" if document_type == "Roteiro de vídeo" else "music_lyrics",
1356
+ "language": language,
1357
+ "channel_id": str((selected_channel or {}).get("id") or ""),
1358
+ "channel_name": str((selected_channel or {}).get("name") or "Documento independente"),
1359
+ "blueprint_id": str(selected_blueprint.get("id") or selected_blueprint_id or ""),
1360
+ "blueprint_name": str(selected_blueprint.get("name") or blueprint_labels.get(selected_blueprint_id, "SEM BLUEPRINT CONFIGURADO")),
1361
+ }
1362
+ )
1363
+ st.success(f"Documento guardado em `{record['path']}`.")
1364
+ except (OSError, ValueError) as exc:
1365
+ st.error(f"Não foi possível guardar o documento: {exc}")
1366
+ else:
1367
+ st.caption("Gere um rascunho com IA para o editar aqui, ou seleccione um Blueprint e preencha o briefing para começar.")
1368
+
1369
+ with history_tab:
1370
+ st.caption(f"Histórico persistente: `{script_dir}` · índice em `{STORAGE / 'state' / 'scripts.json'}`")
1371
+ records = list_script_documents()
1372
+ if not records:
1373
+ st.info("Ainda não existem roteiros ou letras guardados.")
1374
+ for record in records[:50]:
1375
+ label = f"{record.get('title', 'Documento')} · {record.get('document_type', 'documento')}"
1376
+ with st.expander(label, expanded=False):
1377
+ st.caption(f"{record.get('created_at', '—')} · {record.get('channel_name', 'Documento independente')} · Blueprint: {record.get('blueprint_name', '—')}")
1378
+ stored_path = Path(str(record.get("path") or ""))
1379
+ content = read_script_document(record)
1380
+ if content:
1381
+ st.text_area("Conteúdo", value=content, height=220, key=f"stored_script_{record.get('id')}")
1382
+ if stored_path.is_file():
1383
+ st.download_button("Descarregar Markdown", data=stored_path.read_bytes(), file_name=stored_path.name, mime="text/markdown", key=f"download_script_{record.get('id')}")
1384
+ else:
1385
+ st.warning("O ficheiro deste registo já não está disponível no storage.")
1386
+
1387
+
1232
1388
  @st.cache_data(show_spinner=False)
1233
1389
  def _cached_niche_download():
1234
1390
  """Download or reuse the dataset only after the user submits the form."""
@@ -1485,6 +1641,189 @@ def render_edit_placeholder(page_title: str, description: str):
1485
1641
  st.info("Esta aba está reservada para desenvolvimento futuro e ainda não executa nenhuma operação.")
1486
1642
 
1487
1643
 
1644
+ def render_cuts():
1645
+ st.title("Cortes")
1646
+ st.caption("Crie clips verticais, quadrados ou horizontais a partir de vídeos longos, com um fluxo local inspirado no Clip Generator do OpenShorts.")
1647
+
1648
+ st.markdown(
1649
+ "<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>",
1650
+ unsafe_allow_html=True,
1651
+ )
1652
+
1653
+ source_path = None
1654
+ source_tab, url_tab, generated_tab, folder_tab = st.tabs(["Upload ficheiro", "URL de vídeo", "Vídeos gerados", "Pasta local"])
1655
+ with source_tab:
1656
+ uploaded_video = st.file_uploader(
1657
+ "Clique para carregar ou arraste um vídeo",
1658
+ type=sorted(extension.lstrip(".") for extension in VIDEO_EXTENSIONS),
1659
+ key="cuts_video_upload",
1660
+ help="MP4, MOV, MKV, WEBM e formatos suportados pelo FFmpeg. O original fica preservado.",
1661
+ )
1662
+ if uploaded_video is not None:
1663
+ try:
1664
+ source_path = store_uploaded_video(uploaded_video.name, uploaded_video.getvalue())
1665
+ st.session_state["cuts_source_path"] = str(source_path)
1666
+ st.session_state["cuts_source_label"] = f"Upload · {uploaded_video.name}"
1667
+ except CutsError as exc:
1668
+ st.error(str(exc))
1669
+ with url_tab:
1670
+ url_value = st.text_input("URL directa do vídeo", placeholder="https://exemplo.com/video.mp4", key="cuts_video_url")
1671
+ 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.")
1672
+ if st.button("Descarregar vídeo", key="cuts_download_url", use_container_width=True):
1673
+ try:
1674
+ source_path = download_direct_video_url(url_value)
1675
+ st.session_state["cuts_source_path"] = str(source_path)
1676
+ st.session_state["cuts_source_label"] = f"URL · {url_value}"
1677
+ st.success("Vídeo descarregado e pronto para análise.")
1678
+ except CutsError as exc:
1679
+ st.error(str(exc))
1680
+ with generated_tab:
1681
+ generated_paths = list_cut_generated_videos(read_json("tasks.json", []))
1682
+ if not generated_paths:
1683
+ st.info("Ainda não existem vídeos gerados com caminho registado na pipeline.")
1684
+ else:
1685
+ generated_labels = [f"{path.name} — {path}" for path in generated_paths]
1686
+ selected_generated = st.selectbox("Vídeo gerado", range(len(generated_paths)), format_func=lambda index: generated_labels[index], key="cuts_generated_index")
1687
+ if st.button("Usar vídeo seleccionado", key="cuts_use_generated", use_container_width=True):
1688
+ source_path = generated_paths[selected_generated]
1689
+ st.session_state["cuts_source_path"] = str(source_path)
1690
+ st.session_state["cuts_source_label"] = f"Pipeline · {source_path.name}"
1691
+ with folder_tab:
1692
+ folder_value = st.text_input("Pasta de vídeos", value=str(STORAGE / "videos"), key="cuts_video_folder")
1693
+ folder_paths = list_cut_video_files(folder_value)
1694
+ if not folder_paths:
1695
+ st.info("Não foram encontrados vídeos nessa pasta.")
1696
+ else:
1697
+ folder_labels = [f"{path.name} — {path}" for path in folder_paths]
1698
+ selected_folder = st.selectbox("Vídeo da pasta", range(len(folder_paths)), format_func=lambda index: folder_labels[index], key="cuts_folder_index")
1699
+ if st.button("Usar vídeo da pasta", key="cuts_use_folder", use_container_width=True):
1700
+ source_path = folder_paths[selected_folder]
1701
+ st.session_state["cuts_source_path"] = str(source_path)
1702
+ st.session_state["cuts_source_label"] = f"Pasta local · {source_path.name}"
1703
+
1704
+ stored_source = st.session_state.get("cuts_source_path", "")
1705
+ if not source_path and stored_source:
1706
+ candidate = Path(stored_source)
1707
+ if candidate.is_file():
1708
+ source_path = candidate
1709
+ if source_path and source_path.is_file():
1710
+ st.markdown(f"**Fonte seleccionada:** `{st.session_state.get('cuts_source_label', source_path.name)}`")
1711
+ source_cols = st.columns([1.4, 1])
1712
+ with source_cols[0]:
1713
+ st.video(str(source_path))
1714
+ with source_cols[1]:
1715
+ st.caption(f"{source_path.name}")
1716
+ st.caption(f"{source_path.stat().st_size / (1024 * 1024):.2f} MB")
1717
+ st.caption("A fonte original não é alterada.")
1718
+ else:
1719
+ st.info("Escolha um ficheiro, descarregue uma URL, seleccione um vídeo gerado ou indique uma pasta local.")
1720
+
1721
+ with st.container(border=True):
1722
+ st.markdown("**Output format**")
1723
+ format_options = ["9:16", "1:1", "16:9"]
1724
+ output_format = st.radio(
1725
+ "Formato de saída",
1726
+ format_options,
1727
+ format_func=lambda value: {"9:16": "9:16\\nShorts · Reels · TikTok", "1:1": "1:1\\nFeed posts", "16:9": "16:9\\nYouTube · landscape"}[value],
1728
+ horizontal=True,
1729
+ key="cuts_output_format",
1730
+ label_visibility="collapsed",
1731
+ )
1732
+ with st.expander("advanced options", expanded=False):
1733
+ strategy_label = st.selectbox("Estratégia", ["Automático · segmentos locais", "Manual · um intervalo"], key="cuts_strategy")
1734
+ strategy = "manual" if strategy_label.startswith("Manual") else "automatic"
1735
+ options_cols = st.columns(3)
1736
+ with options_cols[0]:
1737
+ 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")
1738
+ with options_cols[1]:
1739
+ 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")
1740
+ with options_cols[2]:
1741
+ 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")
1742
+ manual_start = manual_end = 0.0
1743
+ if strategy == "manual":
1744
+ manual_cols = st.columns(2)
1745
+ with manual_cols[0]:
1746
+ manual_start = st.number_input("Início do intervalo (s)", min_value=0.0, value=0.0, step=0.5, key="cuts_manual_start")
1747
+ with manual_cols[1]:
1748
+ manual_end = st.number_input("Fim do intervalo (s)", min_value=0.5, value=30.0, step=0.5, key="cuts_manual_end")
1749
+ 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.")
1750
+
1751
+ rights_confirmed = st.checkbox(
1752
+ "Confirmo que possuo os direitos ou autorização para processar este conteúdo.",
1753
+ key="cuts_rights_confirmed",
1754
+ )
1755
+ generate_button = st.button(
1756
+ "Gerar Clips",
1757
+ type="primary",
1758
+ use_container_width=True,
1759
+ disabled=not (source_path and source_path.is_file() and rights_confirmed),
1760
+ key="cuts_generate_button",
1761
+ )
1762
+
1763
+ if generate_button and source_path and source_path.is_file():
1764
+ settings = read_json("settings.json", {})
1765
+ with st.status("A analisar e a gerar clips…", expanded=True) as status:
1766
+ st.write("A validar fonte e parâmetros…")
1767
+ try:
1768
+ record = generate_clips(
1769
+ source_path,
1770
+ output_format=output_format,
1771
+ strategy=strategy,
1772
+ max_clips=int(max_clips),
1773
+ min_duration=float(min_duration),
1774
+ max_duration=float(max_duration),
1775
+ rights_confirmed=rights_confirmed,
1776
+ ffmpeg_path=settings.get("ffmpeg_path", ""),
1777
+ manual_start=float(manual_start),
1778
+ manual_end=float(manual_end) if strategy == "manual" else None,
1779
+ )
1780
+ st.session_state["cuts_last_run"] = record
1781
+ status.update(label="Clips gerados", state="complete", expanded=False)
1782
+ except CutsError as exc:
1783
+ status.update(label="A geração falhou", state="error", expanded=True)
1784
+ st.error(str(exc))
1785
+
1786
+ last_run = st.session_state.get("cuts_last_run")
1787
+ if last_run:
1788
+ st.divider()
1789
+ status_label = {"complete": "CONCLUÍDO", "processing": "A PROCESSAR", "error": "ERRO"}.get(last_run.get("status"), str(last_run.get("status", "—")).upper())
1790
+ st.markdown(f"### Live Analysis · `{status_label}`")
1791
+ if last_run.get("status") == "complete":
1792
+ clips = [clip for clip in last_run.get("clips", []) if Path(str(clip.get("path", ""))).is_file()]
1793
+ result_cols = st.columns([3, 1, 1])
1794
+ with result_cols[0]:
1795
+ st.subheader("Generated Shorts")
1796
+ with result_cols[1]:
1797
+ st.metric("Clips", len(clips))
1798
+ with result_cols[2]:
1799
+ st.metric("Formato", last_run.get("output_format", "—"))
1800
+ for index in range(0, len(clips), 3):
1801
+ clip_cols = st.columns(3)
1802
+ for col, clip in zip(clip_cols, clips[index:index + 3]):
1803
+ with col:
1804
+ with st.container(border=True):
1805
+ st.caption(f"Clip {clip.get('index', '—')} · {float(clip.get('duration', 0)):.1f}s")
1806
+ st.video(str(clip["path"]))
1807
+ 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)
1808
+ try:
1809
+ _, archive_bytes = zip_cut_run(last_run)
1810
+ 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)
1811
+ except CutsError as exc:
1812
+ st.warning(str(exc))
1813
+ 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)
1814
+ elif last_run.get("error"):
1815
+ st.error(last_run["error"])
1816
+
1817
+ runs = list_cut_runs()
1818
+ if runs:
1819
+ with st.expander("Histórico do Clip Generator"):
1820
+ st.dataframe(
1821
+ [{"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]],
1822
+ use_container_width=True,
1823
+ hide_index=True,
1824
+ )
1825
+
1826
+
1488
1827
  def render_python_editor():
1489
1828
  st.title("Editor Python")
1490
1829
  st.caption("Editor local inspirado no PYEdit para scripts Python e edição manual de vídeos do Thunderbolt.")
@@ -1616,6 +1955,7 @@ def render_python_editor():
1616
1955
  def render_videos():
1617
1956
  st.subheader("Vídeos e backlog")
1618
1957
  st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
1958
+ st.caption(f"Os vídeos são guardados em `{STORAGE / 'videos'}`.")
1619
1959
  tasks = read_json("tasks.json", [])
1620
1960
  if not tasks:
1621
1961
  st.info("Nenhum vídeo criado.")
@@ -2589,6 +2929,7 @@ def main():
2589
2929
  pipeline_items = [
2590
2930
  ("Criação de Vídeos", ":material/add_circle:", "Criação de Vídeos"),
2591
2931
  ("Criação de Músicas", ":material/music_note:", "Criação de Músicas"),
2932
+ ("Roteiros", ":material/article:", "Roteiros"),
2592
2933
  ("Upload", ":material/cloud_upload:", "Upload"),
2593
2934
  ]
2594
2935
  edition_items = [
@@ -2681,12 +3022,13 @@ def main():
2681
3022
  "Início": render_dashboard,
2682
3023
  "Criação de Vídeos": render_new_video,
2683
3024
  "Criação de Músicas": render_music_creation,
3025
+ "Roteiros": render_scripts,
2684
3026
  "Automação Youtube": render_automation,
2685
3027
  "Niche Finder Kaggle": render_niche_finder,
2686
3028
  "Niche Finder Apify": render_niche_finder_apify,
2687
3029
  "Edição": lambda: render_edit_placeholder("Edição", "Seleccione uma das abas de edição no menu expansível."),
2688
3030
  "Limpador de Metadados": render_metadata_cleaner,
2689
- "Cortes": lambda: render_edit_placeholder("Cortes", "Área reservada para a futura funcionalidade de cortes de vídeo."),
3031
+ "Cortes": render_cuts,
2690
3032
  "Editor Python": render_python_editor,
2691
3033
  "Models AI": lambda: render_edit_placeholder("Models AI", "Seleccione uma das abas Models AI no menu expansível."),
2692
3034
  "Personagens": lambda: render_edit_placeholder("Personagens", "Área reservada para a futura funcionalidade de personagens."),
@@ -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()
@@ -0,0 +1,82 @@
1
+ """Local Markdown storage for Thunderbolt scripts and music lyrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .storage import STORAGE, append_json, ensure_storage, read_json, write_json
12
+
13
+ SCRIPT_HISTORY_FILE = "scripts.json"
14
+
15
+
16
+ def _slug(value: str, fallback: str = "documento") -> str:
17
+ normalized = re.sub(r"[^a-zA-Z0-9À-ÿ]+", "-", str(value or "").strip(), flags=re.UNICODE).strip("-").lower()
18
+ return (normalized or fallback)[:90]
19
+
20
+
21
+ def script_storage_path() -> Path:
22
+ ensure_storage()
23
+ path = STORAGE / "scripts"
24
+ path.mkdir(parents=True, exist_ok=True)
25
+ return path
26
+
27
+
28
+ def save_script_document(document: dict[str, Any]) -> dict[str, Any]:
29
+ content = str(document.get("content") or "").strip()
30
+ if not content:
31
+ raise ValueError("O documento não pode ser guardado vazio.")
32
+ created_at = datetime.now(timezone.utc).isoformat()
33
+ document_id = str(document.get("id") or uuid.uuid4().hex[:12])
34
+ title = str(document.get("title") or "Documento").strip()
35
+ document_type = str(document.get("document_type") or "video_script").strip()
36
+ prefix = "roteiro" if document_type == "video_script" else "letra"
37
+ filename = f"{prefix}-{_slug(title)}-{document_id}.md"
38
+ path = script_storage_path() / filename
39
+ front_matter = [
40
+ "---",
41
+ f"id: {document_id}",
42
+ f"type: {document_type}",
43
+ f"title: {title}",
44
+ f"language: {str(document.get('language') or '')}",
45
+ f"channel: {str(document.get('channel_name') or '')}",
46
+ f"blueprint: {str(document.get('blueprint_name') or '')}",
47
+ f"created_at: {created_at}",
48
+ "---",
49
+ "",
50
+ ]
51
+ summary = str(document.get("summary") or "").strip()
52
+ if summary:
53
+ front_matter.extend([f"> {summary}", ""])
54
+ path.write_text("\n".join(front_matter) + content.rstrip() + "\n", encoding="utf-8")
55
+ record = {
56
+ **document,
57
+ "id": document_id,
58
+ "title": title,
59
+ "document_type": document_type,
60
+ "path": str(path),
61
+ "filename": filename,
62
+ "created_at": created_at,
63
+ "content": content,
64
+ }
65
+ history = read_json(SCRIPT_HISTORY_FILE, [])
66
+ if not isinstance(history, list):
67
+ history = []
68
+ history.insert(0, {key: value for key, value in record.items() if key != "content"})
69
+ write_json(SCRIPT_HISTORY_FILE, history[:200])
70
+ return record
71
+
72
+
73
+ def list_script_documents() -> list[dict[str, Any]]:
74
+ history = read_json(SCRIPT_HISTORY_FILE, [])
75
+ return [item for item in history if isinstance(item, dict)] if isinstance(history, list) else []
76
+
77
+
78
+ def read_script_document(record: dict[str, Any]) -> str:
79
+ path = Path(str(record.get("path") or ""))
80
+ if not path.is_file():
81
+ return ""
82
+ return path.read_text(encoding="utf-8")
@@ -0,0 +1,91 @@
1
+ """Blueprint-aware generation of video scripts and music lyrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .creative_generation import CreativeGenerationError, _chat_json, channel_context
9
+
10
+
11
+ DOCUMENT_TYPES = {
12
+ "Roteiro de vídeo": "video_script",
13
+ "Letra de música": "music_lyrics",
14
+ }
15
+
16
+
17
+ def generate_script_document(
18
+ settings: dict[str, Any],
19
+ *,
20
+ document_type: str,
21
+ title: str,
22
+ brief: str,
23
+ language: str,
24
+ channel: dict[str, Any] | None = None,
25
+ blueprint: dict[str, Any] | None = None,
26
+ structure_notes: str = "",
27
+ ) -> dict[str, Any]:
28
+ """Generate an editable Markdown document from the configured LLM.
29
+
30
+ The function deliberately returns text only. It never writes to storage; the UI
31
+ decides whether the generated draft should be saved after the user reviews it.
32
+ """
33
+ normalized_type = DOCUMENT_TYPES.get(document_type, document_type)
34
+ if normalized_type not in {"video_script", "music_lyrics"}:
35
+ raise CreativeGenerationError("Escolha Roteiro de vídeo ou Letra de música.")
36
+ if not brief.strip():
37
+ raise CreativeGenerationError("Escreva um tema ou briefing antes de gerar o documento.")
38
+
39
+ channel_context_value = channel_context(channel or {}, blueprint or {})
40
+ blueprint_payload = blueprint or {}
41
+ if normalized_type == "video_script":
42
+ output_requirements = {
43
+ "content": "roteiro completo em Markdown, com título, gancho, cenas, narração e indicações visuais/sonoras",
44
+ "summary": "resumo editorial em uma frase",
45
+ }
46
+ system = (
47
+ "És um roteirista editorial de vídeos faceless. Cria um roteiro original, natural e executável, "
48
+ "alinhado exclusivamente ao nicho e às regras do Blueprint fornecido. Não inventes factos sensíveis, "
49
+ "não uses introduções genéricas, não escrevas comentários sobre IA e não incluas um CTA vazio. "
50
+ "Responde apenas com JSON válido nas chaves title, summary e content."
51
+ )
52
+ else:
53
+ output_requirements = {
54
+ "content": "letra completa em Markdown, com secções [Verso], [Pré-refrão], [Refrão], [Ponte] quando fizer sentido",
55
+ "summary": "resumo da intenção musical em uma frase",
56
+ }
57
+ system = (
58
+ "És um compositor de letras originais. Cria uma letra cantável, coerente com o tema, idioma, "
59
+ "Blueprint e direcção musical fornecidos. Não copies letras existentes, não cites artistas sem pedido "
60
+ "e não acrescentes explicações dentro da letra. Responde apenas com JSON válido nas chaves title, summary e content."
61
+ )
62
+
63
+ user = json.dumps(
64
+ {
65
+ "document_type": normalized_type,
66
+ "requested_title": title.strip(),
67
+ "brief": brief.strip(),
68
+ "language": language.strip(),
69
+ "channel": channel_context_value,
70
+ "blueprint": blueprint_payload,
71
+ "structure_notes": structure_notes.strip(),
72
+ "output_requirements": output_requirements,
73
+ },
74
+ ensure_ascii=False,
75
+ )
76
+ result = _chat_json(settings, system, user)
77
+ content = str(result.get("content") or "").strip()
78
+ if not content:
79
+ raise CreativeGenerationError("O provider LLM devolveu um documento sem conteúdo.")
80
+ return {
81
+ "document_type": normalized_type,
82
+ "title": str(result.get("title") or title or brief).strip(),
83
+ "summary": str(result.get("summary") or "").strip(),
84
+ "content": content,
85
+ "language": language.strip(),
86
+ "blueprint_id": str(blueprint_payload.get("id") or ""),
87
+ "blueprint_name": str(blueprint_payload.get("name") or "SEM BLUEPRINT CONFIGURADO"),
88
+ "channel_id": str((channel or {}).get("id") or ""),
89
+ "channel_name": str((channel or {}).get("name") or "Documento independente"),
90
+ "generated_by": "configured_llm",
91
+ }
@@ -24,8 +24,9 @@ DEFAULTS: dict[str, Any] = {
24
24
  "uploads.json": [],
25
25
  "niche_apify_runs.json": [],
26
26
  "metadata_edits.json": [],
27
- "python_editor_edits.json": [],
28
- "mcp_server.json": {
27
+ "python_editor_edits.json": [],
28
+ "scripts.json": [],
29
+ "mcp_server.json": {
29
30
  "enabled": False,
30
31
  "host": "127.0.0.1",
31
32
  "port": 3031,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.53",
3
+ "version": "0.2.55",
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",