@danhachuel/thunderbolt 0.2.54 → 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.54
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.54 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.54 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.54 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
 
@@ -458,10 +458,12 @@ Depois do tópico, clique em **Gerar títulos e thumbnails com IA**. O Thunderbo
458
458
 
459
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.
460
460
 
461
- ## 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
462
462
 
463
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.
464
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
+
465
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.
466
468
 
467
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 |
@@ -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.54
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.54 install
165
- npx.cmd --yes @danhachuel/thunderbolt@0.2.54 doctor
166
- npx.cmd --yes @danhachuel/thunderbolt@0.2.54
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.54 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
 
@@ -268,6 +268,10 @@ A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https:/
268
268
 
269
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.
270
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
+
271
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.
272
276
 
273
277
  ## Teste de vozes
package/app/main.py CHANGED
@@ -34,6 +34,8 @@ from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips,
34
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
35
35
  from hermes_ui.mcp_server import server_status, start_server, stop_server
36
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
37
39
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
38
40
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
39
41
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
@@ -1239,6 +1241,150 @@ def render_music_creation():
1239
1241
  render_new_video(page_title="Criação de Músicas")
1240
1242
 
1241
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
+
1242
1388
  @st.cache_data(show_spinner=False)
1243
1389
  def _cached_niche_download():
1244
1390
  """Download or reuse the dataset only after the user submits the form."""
@@ -1809,6 +1955,7 @@ def render_python_editor():
1809
1955
  def render_videos():
1810
1956
  st.subheader("Vídeos e backlog")
1811
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'}`.")
1812
1959
  tasks = read_json("tasks.json", [])
1813
1960
  if not tasks:
1814
1961
  st.info("Nenhum vídeo criado.")
@@ -2782,6 +2929,7 @@ def main():
2782
2929
  pipeline_items = [
2783
2930
  ("Criação de Vídeos", ":material/add_circle:", "Criação de Vídeos"),
2784
2931
  ("Criação de Músicas", ":material/music_note:", "Criação de Músicas"),
2932
+ ("Roteiros", ":material/article:", "Roteiros"),
2785
2933
  ("Upload", ":material/cloud_upload:", "Upload"),
2786
2934
  ]
2787
2935
  edition_items = [
@@ -2874,6 +3022,7 @@ def main():
2874
3022
  "Início": render_dashboard,
2875
3023
  "Criação de Vídeos": render_new_video,
2876
3024
  "Criação de Músicas": render_music_creation,
3025
+ "Roteiros": render_scripts,
2877
3026
  "Automação Youtube": render_automation,
2878
3027
  "Niche Finder Kaggle": render_niche_finder,
2879
3028
  "Niche Finder Apify": render_niche_finder_apify,
@@ -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.54",
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",