@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.
- package/MANUAL-INSTALACAO.md +7 -5
- package/README.md +11 -7
- package/app/main.py +149 -0
- package/hermes_ui/script_documents.py +82 -0
- package/hermes_ui/script_generation.py +91 -0
- package/hermes_ui/storage.py +3 -2
- package/package.json +1 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
165
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
166
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
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.
|
|
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
|
+
}
|
package/hermes_ui/storage.py
CHANGED
|
@@ -24,8 +24,9 @@ DEFAULTS: dict[str, Any] = {
|
|
|
24
24
|
"uploads.json": [],
|
|
25
25
|
"niche_apify_runs.json": [],
|
|
26
26
|
"metadata_edits.json": [],
|
|
27
|
-
|
|
28
|
-
|
|
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