@danhachuel/thunderbolt 0.2.39 → 0.2.40
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 +11 -5
- package/README.md +14 -6
- package/app/main.py +131 -2
- package/hermes_ui/python_editor.py +269 -0
- package/hermes_ui/storage.py +2 -1
- 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.40
|
|
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.40 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.40 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.
|
|
@@ -396,7 +396,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
396
396
|
|
|
397
397
|
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.
|
|
398
398
|
|
|
399
|
-
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.
|
|
399
|
+
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.40 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
400
400
|
|
|
401
401
|
### Niche Finder Apify
|
|
402
402
|
|
|
@@ -408,7 +408,13 @@ O menu **Models AI** foi adicionado abaixo de **Edição**. As abas **Personagen
|
|
|
408
408
|
|
|
409
409
|
## Edição: Limpador de Metadados, Cortes e Editor Python
|
|
410
410
|
|
|
411
|
-
A aba **Limpador de Metadados** continua funcional e foi movida para **Edição**.
|
|
411
|
+
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.
|
|
412
|
+
|
|
413
|
+
## Editor Python baseado no PYEdit
|
|
414
|
+
|
|
415
|
+
O **Editor Python** adapta o recorte do [PYEdit](https://github.com/Congren/PYEdit) ao Thunderbolt. Na subaba **Vídeos**, pode seleccionar um vídeo já gerado e registado nos artefactos da pipeline, indicar uma pasta local de vídeos ou fazer upload manual. As operações disponíveis são cortar trecho, remover áudio, extrair áudio, substituir áudio, alterar velocidade e redimensionar vídeo.
|
|
416
|
+
|
|
417
|
+
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.
|
|
412
418
|
|
|
413
419
|
## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
|
|
414
420
|
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ A primeira versão implementa a camada UI independente com:
|
|
|
19
19
|
| Automação | Lista de vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON, horário diário HH:MM e worker local baseado no relógio do computador |
|
|
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
|
|
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 |
|
|
23
23
|
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo experimental, 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, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
|
|
@@ -27,7 +27,7 @@ A primeira versão implementa a camada UI independente com:
|
|
|
27
27
|
|
|
28
28
|
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.
|
|
29
29
|
|
|
30
|
-
## Navegação da UI 0.2.
|
|
30
|
+
## Navegação da UI 0.2.40
|
|
31
31
|
|
|
32
32
|
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes** e **Editor Python**, nessa ordem. **Models AI** é expansível e contém **Personagens** e **Redes Sociais**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
|
33
33
|
|
|
@@ -116,9 +116,9 @@ thunderbolt
|
|
|
116
116
|
No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
|
|
117
117
|
|
|
118
118
|
```powershell
|
|
119
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
120
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
121
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
119
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.40 install
|
|
120
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.40 doctor
|
|
121
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.40
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -142,7 +142,15 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
|
|
|
142
142
|
|
|
143
143
|
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.
|
|
144
144
|
|
|
145
|
-
**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.
|
|
145
|
+
**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.40 install`; o instalador detecta e reutiliza componentes já válidos.
|
|
146
|
+
|
|
147
|
+
## Editor Python baseado no PYEdit
|
|
148
|
+
|
|
149
|
+
O **Editor Python** combina o recorte seguro do [PYEdit](https://github.com/Congren/PYEdit) com o armazenamento local do Thunderbolt. Na subaba **Vídeos**, pode escolher um vídeo já gerado e registado nos artefactos da pipeline, indicar qualquer pasta local de vídeos ou fazer upload manual. A página não inicia nenhuma edição ao ser aberta.
|
|
150
|
+
|
|
151
|
+
As operações disponíveis são cortar trecho, remover áudio, extrair áudio, substituir áudio, alterar velocidade e redimensionar vídeo. Cada operação cria uma cópia em `storage/python_editor/outputs/`, preserva o original, regista um histórico separado em `storage/state/python_editor_edits.json` e permite descarregar o resultado e um manifesto JSON. As operações usam FFmpeg local, aproveitando o caminho configurado em **Configurações Técnicas** ou o fallback `imageio-ffmpeg`.
|
|
152
|
+
|
|
153
|
+
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, não possui botão de execução e não executa scripts automaticamente.
|
|
146
154
|
|
|
147
155
|
## Armazenamento local
|
|
148
156
|
|
package/app/main.py
CHANGED
|
@@ -19,13 +19,14 @@ except (OSError, json.JSONDecodeError):
|
|
|
19
19
|
|
|
20
20
|
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel
|
|
21
21
|
from hermes_ui.automation_worker import load_worker_status
|
|
22
|
-
from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
|
|
22
|
+
from hermes_ui.storage import BLUEPRINTS, STORAGE, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
|
|
23
23
|
from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
|
|
24
24
|
from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
|
|
25
25
|
from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
|
|
26
26
|
from app.modules.niche_finder.summarizer import summarize_items
|
|
27
27
|
from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
|
|
28
28
|
from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
|
|
29
|
+
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
|
|
29
30
|
from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
|
|
30
31
|
from hermes_ui.mcp_server import server_status, start_server, stop_server
|
|
31
32
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
@@ -888,6 +889,134 @@ def render_edit_placeholder(page_title: str, description: str):
|
|
|
888
889
|
st.info("Esta aba está reservada para desenvolvimento futuro e ainda não executa nenhuma operação.")
|
|
889
890
|
|
|
890
891
|
|
|
892
|
+
def render_python_editor():
|
|
893
|
+
st.title("Editor Python")
|
|
894
|
+
st.caption("Editor local inspirado no PYEdit para scripts Python e edição manual de vídeos do Thunderbolt.")
|
|
895
|
+
st.info("Escolha um vídeo gerado, indique uma pasta local ou faça upload de um vídeo. Nenhum código Python é executado nesta aba; as operações de vídeo só começam depois de clicar no botão da operação.")
|
|
896
|
+
|
|
897
|
+
video_tab, code_tab = st.tabs(["Vídeos", "Código Python"])
|
|
898
|
+
with video_tab:
|
|
899
|
+
st.subheader("Escolher vídeo")
|
|
900
|
+
source_mode = st.radio("Origem", ["Vídeos gerados", "Pasta local", "Upload manual"], horizontal=True, key="python_editor_source_mode")
|
|
901
|
+
source_path = None
|
|
902
|
+
if source_mode == "Vídeos gerados":
|
|
903
|
+
tasks = read_json("tasks.json", [])
|
|
904
|
+
generated_paths = list_generated_videos(tasks)
|
|
905
|
+
if not generated_paths:
|
|
906
|
+
st.info("Ainda não existem vídeos gerados com caminho registado nos artefactos da pipeline.")
|
|
907
|
+
else:
|
|
908
|
+
labels = [f"{path.name} — {path}" for path in generated_paths]
|
|
909
|
+
selected_index = st.selectbox("Vídeo gerado", range(len(generated_paths)), format_func=lambda index: labels[index], key="python_editor_generated_index")
|
|
910
|
+
source_path = generated_paths[selected_index]
|
|
911
|
+
elif source_mode == "Pasta local":
|
|
912
|
+
default_folder = str(STORAGE / "videos")
|
|
913
|
+
folder_value = st.text_input("Pasta de vídeos", value=st.session_state.get("python_editor_folder", default_folder), key="python_editor_folder")
|
|
914
|
+
folder_paths = list_video_files(folder_value)
|
|
915
|
+
if not folder_paths:
|
|
916
|
+
st.info("Não foram encontrados vídeos nessa pasta. Pode indicar a pasta de vídeos gerados ou qualquer outra pasta local.")
|
|
917
|
+
else:
|
|
918
|
+
labels = [f"{path.name} — {path}" for path in folder_paths]
|
|
919
|
+
selected_index = st.selectbox("Vídeo da pasta", range(len(folder_paths)), format_func=lambda index: labels[index], key="python_editor_folder_index")
|
|
920
|
+
source_path = folder_paths[selected_index]
|
|
921
|
+
else:
|
|
922
|
+
uploaded_video = st.file_uploader("Subir vídeo para editar", type=sorted(extension.lstrip(".") for extension in VIDEO_EXTENSIONS), key="python_editor_video_upload")
|
|
923
|
+
if uploaded_video is not None:
|
|
924
|
+
try:
|
|
925
|
+
source_path = store_uploaded_asset(uploaded_video.name, uploaded_video.getvalue())
|
|
926
|
+
except PythonEditorError as exc:
|
|
927
|
+
st.error(str(exc))
|
|
928
|
+
|
|
929
|
+
if source_path and source_path.is_file():
|
|
930
|
+
st.session_state["python_editor_source_path"] = str(source_path)
|
|
931
|
+
st.caption(f"Fonte seleccionada: `{source_path}` · {source_path.stat().st_size / (1024 * 1024):.2f} MB")
|
|
932
|
+
st.video(str(source_path))
|
|
933
|
+
st.divider()
|
|
934
|
+
st.subheader("Operação PYEdit")
|
|
935
|
+
operation_options = ["Cortar trecho", "Remover áudio", "Extrair áudio", "Substituir áudio", "Alterar velocidade", "Redimensionar vídeo"]
|
|
936
|
+
operation = st.selectbox("Operação", operation_options, key="python_editor_operation")
|
|
937
|
+
with st.form("python_editor_video_form", clear_on_submit=False):
|
|
938
|
+
start_seconds = end_seconds = speed = None
|
|
939
|
+
width = height = None
|
|
940
|
+
replacement_audio = None
|
|
941
|
+
if operation == "Cortar trecho":
|
|
942
|
+
op_cols = st.columns(2)
|
|
943
|
+
with op_cols[0]:
|
|
944
|
+
start_seconds = st.number_input("Início (segundos)", min_value=0.0, value=0.0, step=0.5, key="python_editor_trim_start")
|
|
945
|
+
with op_cols[1]:
|
|
946
|
+
end_seconds = st.number_input("Fim (segundos)", min_value=0.5, value=10.0, step=0.5, key="python_editor_trim_end")
|
|
947
|
+
elif operation == "Substituir áudio":
|
|
948
|
+
replacement_audio = st.file_uploader("Novo áudio", type=sorted(extension.lstrip(".") for extension in AUDIO_EXTENSIONS), key="python_editor_audio_upload")
|
|
949
|
+
st.caption("O áudio substitui a faixa original e a duração final fica limitada ao menor fluxo.")
|
|
950
|
+
elif operation == "Alterar velocidade":
|
|
951
|
+
speed = st.number_input("Velocidade", min_value=0.25, max_value=4.0, value=1.0, step=0.25, key="python_editor_speed")
|
|
952
|
+
elif operation == "Redimensionar vídeo":
|
|
953
|
+
resize_cols = st.columns(2)
|
|
954
|
+
with resize_cols[0]:
|
|
955
|
+
width = st.number_input("Largura", min_value=16, max_value=7680, value=1280, step=2, key="python_editor_width")
|
|
956
|
+
with resize_cols[1]:
|
|
957
|
+
height = st.number_input("Altura", min_value=16, max_value=7680, value=720, step=2, key="python_editor_height")
|
|
958
|
+
apply_operation = st.form_submit_button(f"Aplicar: {operation}", type="primary", use_container_width=True)
|
|
959
|
+
if apply_operation:
|
|
960
|
+
try:
|
|
961
|
+
ffmpeg_path = read_json("settings.json", {}).get("ffmpeg_path", "")
|
|
962
|
+
if operation == "Cortar trecho":
|
|
963
|
+
output, run_info = trim_video(source_path, float(start_seconds), float(end_seconds), ffmpeg_path=ffmpeg_path)
|
|
964
|
+
elif operation == "Remover áudio":
|
|
965
|
+
output, run_info = remove_audio(source_path, ffmpeg_path=ffmpeg_path)
|
|
966
|
+
elif operation == "Extrair áudio":
|
|
967
|
+
output, run_info = extract_audio(source_path, ffmpeg_path=ffmpeg_path)
|
|
968
|
+
elif operation == "Substituir áudio":
|
|
969
|
+
if replacement_audio is None:
|
|
970
|
+
raise PythonEditorError("Seleccione o novo áudio antes de aplicar a operação.")
|
|
971
|
+
audio_path = store_uploaded_asset(replacement_audio.name, replacement_audio.getvalue(), audio=True)
|
|
972
|
+
output, run_info = replace_audio(source_path, audio_path, ffmpeg_path=ffmpeg_path)
|
|
973
|
+
elif operation == "Alterar velocidade":
|
|
974
|
+
output, run_info = change_speed(source_path, float(speed), ffmpeg_path=ffmpeg_path)
|
|
975
|
+
else:
|
|
976
|
+
output, run_info = resize_video(source_path, int(width), int(height), ffmpeg_path=ffmpeg_path)
|
|
977
|
+
record = save_python_editor_record(source_path, output, run_info)
|
|
978
|
+
st.session_state["python_editor_last_record"] = record
|
|
979
|
+
st.success(f"Operação concluída: {output.name}")
|
|
980
|
+
if output.suffix.lower() in AUDIO_EXTENSIONS:
|
|
981
|
+
st.audio(output.read_bytes(), format="audio/mpeg")
|
|
982
|
+
else:
|
|
983
|
+
st.video(str(output))
|
|
984
|
+
st.download_button("Descarregar resultado", data=output.read_bytes(), file_name=output.name, mime="video/mp4" if output.suffix.lower() in VIDEO_EXTENSIONS else "audio/mpeg", key=f"python_editor_download_{record['id']}")
|
|
985
|
+
st.download_button("Descarregar manifesto JSON", data=editor_manifest(record), file_name=f"{output.stem}.json", mime="application/json", key=f"python_editor_manifest_{record['id']}")
|
|
986
|
+
except (PythonEditorError, OSError, ValueError) as exc:
|
|
987
|
+
st.error(str(exc))
|
|
988
|
+
else:
|
|
989
|
+
st.caption("Seleccione uma fonte de vídeo para activar as operações inspiradas no PYEdit.")
|
|
990
|
+
|
|
991
|
+
records = list_python_editor_records()
|
|
992
|
+
if records:
|
|
993
|
+
with st.expander("Histórico do Editor Python"):
|
|
994
|
+
st.dataframe([{key: record.get(key, "") for key in ("created_at", "operation", "source_name", "output_name")} for record in records[:20]], use_container_width=True, hide_index=True)
|
|
995
|
+
|
|
996
|
+
with code_tab:
|
|
997
|
+
st.subheader("Scripts Python locais")
|
|
998
|
+
st.warning("Por segurança, o Editor Python guarda e edita scripts, mas não oferece execução de código dentro da UI.")
|
|
999
|
+
scripts = list_scripts()
|
|
1000
|
+
script_options = ["Novo script"] + [str(path.name) for path in scripts]
|
|
1001
|
+
selected_script = st.selectbox("Script", script_options, key="python_editor_script_name")
|
|
1002
|
+
if selected_script != "Novo script":
|
|
1003
|
+
selected_path = next((path for path in scripts if path.name == selected_script), None)
|
|
1004
|
+
if selected_path and st.button("Carregar script", key="python_editor_load_script"):
|
|
1005
|
+
st.session_state["python_editor_code"] = read_script(selected_path)
|
|
1006
|
+
st.rerun()
|
|
1007
|
+
with st.form("python_editor_code_form", clear_on_submit=False):
|
|
1008
|
+
script_name = st.text_input("Nome do script", value="script.py" if selected_script == "Novo script" else selected_script, key="python_editor_code_name")
|
|
1009
|
+
script_content = st.text_area("Código Python", value=st.session_state.get("python_editor_code", ""), height=420, key="python_editor_code_text")
|
|
1010
|
+
save_code = st.form_submit_button("Guardar script localmente", type="primary")
|
|
1011
|
+
if save_code:
|
|
1012
|
+
try:
|
|
1013
|
+
path = save_script(script_name, script_content)
|
|
1014
|
+
st.session_state["python_editor_code"] = script_content
|
|
1015
|
+
st.success(f"Script guardado em `{path}`.")
|
|
1016
|
+
except PythonEditorError as exc:
|
|
1017
|
+
st.error(str(exc))
|
|
1018
|
+
|
|
1019
|
+
|
|
891
1020
|
def render_videos():
|
|
892
1021
|
st.subheader("Vídeos e backlog")
|
|
893
1022
|
st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
|
|
@@ -1735,7 +1864,7 @@ def main():
|
|
|
1735
1864
|
"Edição": lambda: render_edit_placeholder("Edição", "Seleccione uma das abas de edição no menu expansível."),
|
|
1736
1865
|
"Limpador de Metadados": render_metadata_cleaner,
|
|
1737
1866
|
"Cortes": lambda: render_edit_placeholder("Cortes", "Área reservada para a futura funcionalidade de cortes de vídeo."),
|
|
1738
|
-
"Editor Python":
|
|
1867
|
+
"Editor Python": render_python_editor,
|
|
1739
1868
|
"Models AI": lambda: render_edit_placeholder("Models AI", "Seleccione uma das abas Models AI no menu expansível."),
|
|
1740
1869
|
"Personagens": lambda: render_edit_placeholder("Personagens", "Área reservada para a futura funcionalidade de personagens."),
|
|
1741
1870
|
"Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
|
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import storage
|
|
14
|
+
from .metadata_cleaner import VIDEO_EXTENSIONS as METADATA_VIDEO_EXTENSIONS
|
|
15
|
+
from .metadata_cleaner import _resolve_ffmpeg
|
|
16
|
+
|
|
17
|
+
VIDEO_EXTENSIONS = {f".{extension.lower().lstrip('.')}" for extension in METADATA_VIDEO_EXTENSIONS}
|
|
18
|
+
AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PythonEditorError(RuntimeError):
|
|
22
|
+
"""Raised when a controlled local editing operation cannot be completed."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _root() -> Path:
|
|
26
|
+
return storage.STORAGE / "python_editor"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _outputs() -> Path:
|
|
30
|
+
path = _root() / "outputs"
|
|
31
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
return path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _scripts() -> Path:
|
|
36
|
+
path = _root() / "scripts"
|
|
37
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _now() -> str:
|
|
42
|
+
return datetime.now(timezone.utc).isoformat()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _safe_filename(name: str, fallback: str = "video") -> str:
|
|
46
|
+
normalized = unicodedata.normalize("NFKD", Path(name).name)
|
|
47
|
+
ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
|
|
48
|
+
ascii_name = re.sub(r"[^A-Za-z0-9._-]+", "-", ascii_name).strip(".-")
|
|
49
|
+
return ascii_name or fallback
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _output_path(source: Path, operation: str, suffix: str | None = None) -> Path:
|
|
53
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
54
|
+
source_name = _safe_filename(source.stem, "video")
|
|
55
|
+
extension = suffix or source.suffix or ".mp4"
|
|
56
|
+
if not extension.startswith("."):
|
|
57
|
+
extension = f".{extension}"
|
|
58
|
+
return _outputs() / f"{operation}-{stamp}-{source_name}{extension.lower()}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _validate_video(source: Path) -> Path:
|
|
62
|
+
candidate = Path(source).expanduser()
|
|
63
|
+
if not candidate.is_file():
|
|
64
|
+
raise PythonEditorError("O vídeo seleccionado não existe ou não é um ficheiro.")
|
|
65
|
+
if candidate.suffix.lower() not in VIDEO_EXTENSIONS:
|
|
66
|
+
raise PythonEditorError("O ficheiro seleccionado não tem uma extensão de vídeo suportada.")
|
|
67
|
+
return candidate.resolve()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _validate_audio(source: Path) -> Path:
|
|
71
|
+
candidate = Path(source).expanduser()
|
|
72
|
+
if not candidate.is_file():
|
|
73
|
+
raise PythonEditorError("O áudio seleccionado não existe ou não é um ficheiro.")
|
|
74
|
+
if candidate.suffix.lower() not in AUDIO_EXTENSIONS:
|
|
75
|
+
raise PythonEditorError("O ficheiro seleccionado não tem uma extensão de áudio suportada.")
|
|
76
|
+
return candidate.resolve()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_video_files(folder: str | Path) -> list[Path]:
|
|
80
|
+
candidate = Path(folder).expanduser()
|
|
81
|
+
if not candidate.exists():
|
|
82
|
+
return []
|
|
83
|
+
if candidate.is_file():
|
|
84
|
+
return [candidate.resolve()] if candidate.suffix.lower() in VIDEO_EXTENSIONS else []
|
|
85
|
+
try:
|
|
86
|
+
return sorted(
|
|
87
|
+
(path.resolve() for path in candidate.iterdir() if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS),
|
|
88
|
+
key=lambda path: path.stat().st_mtime,
|
|
89
|
+
reverse=True,
|
|
90
|
+
)
|
|
91
|
+
except OSError as exc:
|
|
92
|
+
raise PythonEditorError(f"Não foi possível ler a pasta de vídeos: {exc}") from exc
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def list_generated_videos(tasks: list[dict[str, Any]]) -> list[Path]:
|
|
96
|
+
paths: dict[str, Path] = {}
|
|
97
|
+
for task in tasks:
|
|
98
|
+
artifacts = task.get("artifacts") or {}
|
|
99
|
+
value = artifacts.get("video")
|
|
100
|
+
if isinstance(value, str) and value.strip():
|
|
101
|
+
candidate = Path(value).expanduser()
|
|
102
|
+
if candidate.is_file() and candidate.suffix.lower() in VIDEO_EXTENSIONS:
|
|
103
|
+
paths[str(candidate.resolve())] = candidate.resolve()
|
|
104
|
+
return sorted(paths.values(), key=lambda path: path.stat().st_mtime, reverse=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def list_scripts() -> list[Path]:
|
|
108
|
+
return sorted((path for path in _scripts().glob("*.py") if path.is_file()), key=lambda path: path.stat().st_mtime, reverse=True)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_script(path: Path) -> str:
|
|
112
|
+
candidate = path.resolve()
|
|
113
|
+
if _scripts().resolve() not in candidate.parents:
|
|
114
|
+
raise PythonEditorError("O script seleccionado tem de estar na pasta local de scripts do Thunderbolt.")
|
|
115
|
+
try:
|
|
116
|
+
return candidate.read_text(encoding="utf-8")
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
raise PythonEditorError(f"Não foi possível ler o script: {exc}") from exc
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def save_script(name: str, content: str) -> Path:
|
|
122
|
+
safe_name = _safe_filename(name, "script")
|
|
123
|
+
if not safe_name.lower().endswith(".py"):
|
|
124
|
+
safe_name += ".py"
|
|
125
|
+
if not content.strip():
|
|
126
|
+
raise PythonEditorError("O script não pode ficar vazio.")
|
|
127
|
+
target = (_scripts() / safe_name).resolve()
|
|
128
|
+
if _scripts().resolve() not in target.parents:
|
|
129
|
+
raise PythonEditorError("O nome do script não é válido.")
|
|
130
|
+
target.write_text(content, encoding="utf-8")
|
|
131
|
+
return target
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def store_uploaded_asset(name: str, content: bytes, *, audio: bool = False) -> Path:
|
|
135
|
+
if not content:
|
|
136
|
+
raise PythonEditorError("O ficheiro enviado está vazio.")
|
|
137
|
+
allowed = AUDIO_EXTENSIONS if audio else VIDEO_EXTENSIONS
|
|
138
|
+
suffix = Path(name).suffix.lower()
|
|
139
|
+
if suffix not in allowed:
|
|
140
|
+
raise PythonEditorError("O ficheiro enviado não tem uma extensão suportada.")
|
|
141
|
+
directory = _root() / ("audio_inputs" if audio else "video_inputs")
|
|
142
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
digest = hashlib.sha256(content).hexdigest()[:16]
|
|
144
|
+
target = directory / f"{digest}-{_safe_filename(name, 'media')}"
|
|
145
|
+
if not target.exists():
|
|
146
|
+
target.write_bytes(content)
|
|
147
|
+
return target
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _run(command: list[str], output: Path, operation: str, source: Path, *, extra: dict[str, Any] | None = None) -> tuple[Path, dict[str, Any]]:
|
|
151
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
try:
|
|
153
|
+
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
154
|
+
except OSError as exc:
|
|
155
|
+
raise PythonEditorError(f"Não foi possível iniciar FFmpeg: {exc}") from exc
|
|
156
|
+
if completed.returncode != 0 or not output.is_file() or output.stat().st_size == 0:
|
|
157
|
+
if output.exists():
|
|
158
|
+
output.unlink()
|
|
159
|
+
detail = (completed.stderr or completed.stdout or "erro desconhecido").strip()
|
|
160
|
+
raise PythonEditorError(f"A operação {operation} falhou: {detail[-1200:]}")
|
|
161
|
+
run_info = {
|
|
162
|
+
"operation": operation,
|
|
163
|
+
"source": str(source),
|
|
164
|
+
"output": str(output),
|
|
165
|
+
"command": command,
|
|
166
|
+
"created_at": _now(),
|
|
167
|
+
}
|
|
168
|
+
if extra:
|
|
169
|
+
run_info.update(extra)
|
|
170
|
+
return output, run_info
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _ffmpeg(ffmpeg_path: str | None = None) -> str:
|
|
174
|
+
try:
|
|
175
|
+
return _resolve_ffmpeg(ffmpeg_path)
|
|
176
|
+
except RuntimeError as exc:
|
|
177
|
+
raise PythonEditorError(str(exc)) from exc
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _atempo_chain(speed: float) -> str:
|
|
181
|
+
factors: list[float] = []
|
|
182
|
+
remaining = float(speed)
|
|
183
|
+
while remaining > 2.0:
|
|
184
|
+
factors.append(2.0)
|
|
185
|
+
remaining /= 2.0
|
|
186
|
+
while remaining < 0.5:
|
|
187
|
+
factors.append(0.5)
|
|
188
|
+
remaining /= 0.5
|
|
189
|
+
factors.append(remaining)
|
|
190
|
+
return ",".join(f"atempo={factor:.6f}" for factor in factors)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def trim_video(source: Path, start_seconds: float, end_seconds: float, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
|
|
194
|
+
source = _validate_video(source)
|
|
195
|
+
start = max(0.0, float(start_seconds))
|
|
196
|
+
end = float(end_seconds)
|
|
197
|
+
if end <= start:
|
|
198
|
+
raise PythonEditorError("O fim do corte deve ser superior ao início.")
|
|
199
|
+
output = _output_path(source, "corte")
|
|
200
|
+
command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-ss", f"{start:.3f}", "-to", f"{end:.3f}", "-i", str(source), "-map", "0", "-c", "copy", "-avoid_negative_ts", "make_zero", str(output)]
|
|
201
|
+
return _run(command, output, "corte", source, extra={"start_seconds": start, "end_seconds": end})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def remove_audio(source: Path, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
|
|
205
|
+
source = _validate_video(source)
|
|
206
|
+
output = _output_path(source, "sem-audio")
|
|
207
|
+
command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-i", str(source), "-map", "0:v:0", "-c:v", "copy", "-an", str(output)]
|
|
208
|
+
return _run(command, output, "remoção de áudio", source)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def extract_audio(source: Path, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
|
|
212
|
+
source = _validate_video(source)
|
|
213
|
+
output = _output_path(source, "audio", ".mp3")
|
|
214
|
+
command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-i", str(source), "-vn", "-codec:a", "libmp3lame", "-q:a", "2", str(output)]
|
|
215
|
+
return _run(command, output, "extracção de áudio", source)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def resize_video(source: Path, width: int, height: int, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
|
|
219
|
+
source = _validate_video(source)
|
|
220
|
+
width = int(width)
|
|
221
|
+
height = int(height)
|
|
222
|
+
if width < 16 or height < 16:
|
|
223
|
+
raise PythonEditorError("As dimensões do vídeo devem ser pelo menos 16x16.")
|
|
224
|
+
output = _output_path(source, "redimensionado")
|
|
225
|
+
scale = f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
|
|
226
|
+
command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-i", str(source), "-vf", scale, "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-movflags", "+faststart", str(output)]
|
|
227
|
+
return _run(command, output, "redimensionamento", source, extra={"width": width, "height": height})
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def change_speed(source: Path, speed: float, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
|
|
231
|
+
source = _validate_video(source)
|
|
232
|
+
speed = float(speed)
|
|
233
|
+
if speed < 0.25 or speed > 4.0:
|
|
234
|
+
raise PythonEditorError("A velocidade deve estar entre 0,25x e 4x.")
|
|
235
|
+
output = _output_path(source, "velocidade")
|
|
236
|
+
command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-i", str(source), "-filter:v", f"setpts=PTS/{speed:.6f}", "-filter:a", _atempo_chain(speed), "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-movflags", "+faststart", str(output)]
|
|
237
|
+
return _run(command, output, "alteração de velocidade", source, extra={"speed": speed})
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def replace_audio(source: Path, audio: Path, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
|
|
241
|
+
source = _validate_video(source)
|
|
242
|
+
audio = _validate_audio(audio)
|
|
243
|
+
output = _output_path(source, "audio-substituido")
|
|
244
|
+
command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-i", str(source), "-i", str(audio), "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac", "-shortest", str(output)]
|
|
245
|
+
return _run(command, output, "substituição de áudio", source, extra={"audio": str(audio)})
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def save_edit_record(source: Path, output: Path, run_info: dict[str, Any]) -> dict[str, Any]:
|
|
249
|
+
record = {
|
|
250
|
+
"id": hashlib.sha256(f"{source}:{output}:{run_info.get('created_at', '')}".encode("utf-8")).hexdigest()[:16],
|
|
251
|
+
"source_name": source.name,
|
|
252
|
+
"source_path": str(source),
|
|
253
|
+
"output_name": output.name,
|
|
254
|
+
"output_path": str(output),
|
|
255
|
+
"operation": run_info.get("operation", "editor"),
|
|
256
|
+
"parameters": {key: value for key, value in run_info.items() if key not in {"command", "source", "output", "created_at", "operation"}},
|
|
257
|
+
"created_at": run_info.get("created_at", _now()),
|
|
258
|
+
}
|
|
259
|
+
storage.append_json("python_editor_edits.json", record)
|
|
260
|
+
return record
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def editor_manifest(record: dict[str, Any]) -> bytes:
|
|
264
|
+
return (json.dumps(record, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def list_edit_records() -> list[dict[str, Any]]:
|
|
268
|
+
records = storage.read_json("python_editor_edits.json", [])
|
|
269
|
+
return list(reversed(records)) if isinstance(records, list) else []
|
package/hermes_ui/storage.py
CHANGED
|
@@ -23,6 +23,7 @@ DEFAULTS: dict[str, Any] = {
|
|
|
23
23
|
"uploads.json": [],
|
|
24
24
|
"niche_apify_runs.json": [],
|
|
25
25
|
"metadata_edits.json": [],
|
|
26
|
+
"python_editor_edits.json": [],
|
|
26
27
|
"mcp_server.json": {
|
|
27
28
|
"enabled": False,
|
|
28
29
|
"host": "127.0.0.1",
|
|
@@ -227,7 +228,7 @@ def seed_blueprints() -> None:
|
|
|
227
228
|
|
|
228
229
|
|
|
229
230
|
def ensure_storage() -> None:
|
|
230
|
-
for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", NICHES_DATA]:
|
|
231
|
+
for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", STORAGE / "python_editor", NICHES_DATA]:
|
|
231
232
|
path.mkdir(parents=True, exist_ok=True)
|
|
232
233
|
seed_blueprints()
|
|
233
234
|
for filename, default in DEFAULTS.items():
|
package/package.json
CHANGED